Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5d92b5edaa | ||
|
|
4a6a8592e0 | ||
|
|
b8f321dfd5 | ||
|
|
336ed4ee7c | ||
|
|
929452ca33 | ||
|
|
8daca54840 | ||
|
|
0f5f3ae23e | ||
|
|
8ec485b93e | ||
|
|
ea740fc378 | ||
|
|
718e3a0177 | ||
|
|
5030fcd8b4 | ||
|
|
6d8e1ad35e | ||
|
|
0ac133f750 | ||
|
|
d3038b3c7f | ||
|
|
847e74b7b8 | ||
|
|
394d51d827 | ||
|
|
264778522c | ||
|
|
e55078fb5d | ||
|
|
4e585cbed1 | ||
|
|
89ad09a04d | ||
|
|
bf2c46b959 | ||
|
|
fa64e32f65 | ||
|
|
04b6f20a93 | ||
|
|
acee806180 | ||
|
|
1e3992f872 | ||
|
|
57cc6705f2 | ||
|
|
c9e9113a45 | ||
|
|
5e7ec903d3 | ||
|
|
0b946b743c | ||
|
|
86d486fb69 | ||
|
|
b4b25551ff | ||
|
|
8a12f07703 | ||
|
|
a84bc7ed21 | ||
|
|
7524f943d2 | ||
|
|
4f182a22b6 | ||
|
|
34b38dd5d5 | ||
|
|
b2133fd25c | ||
|
|
179fad450d | ||
|
|
85b7b07f53 | ||
|
|
5ef661a06f | ||
|
|
408cd933f4 | ||
|
|
3395fb4f90 | ||
|
|
537c494daa | ||
|
|
69b708dad3 | ||
|
|
e109e003c0 | ||
|
|
cbe0cda15d |
+121
@@ -1,5 +1,126 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## v0.40.0 — 2026-09-21 13:06
|
||||||
|
- **Top-bar version link (ISS-080).** The header's version tag (`components/Map.tsx`) is now an anchor to the repo's `CHANGELOG.md` on Gitea (`app-version` testid, opens in a new tab); one small CSS rule keeps it subtle against the dark header. E2E: `e2e/map.spec.ts` pins the exact href
|
||||||
|
- **On-board ICAO tags (ISS-081).** The on-board package row in the fleet list (`components/LeftPane.tsx`) shows 4-char ICAO tags instead of full airport names — both the route span (new `onboard-route-{id}` testid) and the "Fly to …" button label + title. Other name displays (in-flight leg, plane location, map popup, open-package rows, toasts) are unchanged. E2E: one new assertion in `e2e/actions.spec.ts` pins the ICAO route on a loaded package
|
||||||
|
|
||||||
|
## v0.39.0 — 2026-09-21 12:02
|
||||||
|
- **Maintenance (ISS-069).** Planes wear out and can be repaired: distance-based health, a broken state that burns more fuel, and a repair action that grounds the plane for 3 sim days. New balance constants in `lib/game/catalog.ts` (all ponytail-tunable): `WEAR_PER_KM = 0.005`, `BROKEN_BURN_MULTIPLIER = 1.5`, `REPAIR_COST = 1_500` (flat), `REPAIR_DURATION_MS = 3 × 86_400_000`, plus the pure helper `conditionBurnMultiplier(health)` — exactly 1 above zero health, ×1.5 at/below it
|
||||||
|
- **Schema.** Two new `planes` columns in all four places: Drizzle schema (`lib/db/schema.ts`), raw DDL + guarded `ALTER TABLE … ADD COLUMN` migrations in `migrateColumns()` (pre-existing rows backfill to full health / NULL gate, mirroring the weight_kg/special precedent), and the seed insert path — starter plane in `buildSeed` (`lib/game/seed.ts`) and every `buyPlane` delivery start at `health: 100`. New plane status `'repairing'` added to the three pinned unions (schema enum, `PlaneState`, OpenAPI) plus `mapData.ts`'s feature props
|
||||||
|
- **Wear.** At landing only — `deliverPackages` (`lib/sim/engine.ts`) drops health by `WEAR_PER_KM × distanceKm` per landed leg (clamped at 0), in both the grounded and auto-chain write branches. A broken plane is one with `health ≤ 0`: it still flies, but burns ×1.5 more fuel — applied as a third factor alongside wind at ALL FIVE burn sites that hold the plane row: `flyPlane` dispatch feasibility + `refuelPlane` forFlight requirement (`lib/game/actions.ts`), engine landed-fuel + auto-chain next-leg check (`lib/sim/engine.ts`), and the live in-flight gauge in `livePlane()` (`lib/game/state.ts`). The engine's landed fuel uses the PRE-landing health (frozen at departure, like fuel) so it stays bit-consistent with the UI gauge; the chained leg's feasibility uses the post-wear value
|
||||||
|
- **Repair.** New action `repairPlane` (`lib/game/actions.ts`) + POST `/api/planes/{planeId}/repair` (no body): 404 unknown plane, 409 not grounded ("only grounded planes can be repaired"), free no-op at full health (mirrors refuel), else charges the flat `REPAIR_COST`, restores health to 100 and grounds the plane in `'repairing'` with a gate at `clockMs + REPAIR_DURATION_MS`. The engine's new `finishRepairs` pass (same shape as `expirePackages`) runs inside every advancing tick — even when nothing landed — flipping gated planes back to `'available'` and clearing the gate. While repairing, the existing `status !== 'available'` guards in load/fly/refuel/sell/abandon block everything automatically
|
||||||
|
- **Snapshot & OpenAPI.** `PlaneState` gains `health: number` + `repairUntilSimTime: number | null`; the repair endpoint is documented (200 → snapshot, 404/409). Pins updated in lockstep: `lib/openapi.test.ts` (fourteen endpoints, nine action-endpoint loop with errorCodes `['404','409']`, PlaneState key list) and `e2e/api.spec.ts`
|
||||||
|
- **UI.** The fleet rows (`components/LeftPane.tsx`) show an amber health bar (new `.health-bar`, modeled on `.fuel-bar`) + a `{n}%` readout next to the fuel text; grounded planes get a red "broken" pill at zero health and an amber "in repair" pill while the gate runs (`.status-broken` / `.status-repairing`). A Repair button appears for any grounded plane below full health — no cost preview, flat cost is server-side only
|
||||||
|
- **Test hook.** POST `/api/test/set-health` (`FLIGHT_TEST_HOOKS=1` only): `{ planeId, health?, repairUntilMs? }` forces a plane's health (finite in [0,100]) and/or sets its repair gate to `clock + repairUntilMs` — lets e2e force the broken state and shorten an active 3-day gate without flying thousands of km
|
||||||
|
- **Tests.** New unit tests: wear accumulation per landed leg + clamping at 0 across multiple legs; broken-state ×1.5 landed fuel vs a healthy control on the identical leg; `conditionBurnMultiplier` pins; `flyPlane` feasibility using the adjusted requirement (same tank flyable at full health, 409 when broken); `repairPlane` (exact cost/status/gate/health, free no-op, 409s for flying/repairing/insufficient funds, load/fly/refuel/sell all blocked while repairing) and `finishRepairs` (gate boundary + flip-back through a full `tickOnce` with nothing landed). E2E: new `e2e/maintenance.spec.ts` — the visible flow per the issue (force 42 → UI shows it; force 0 → broken pill + short full-tank leg lands with exactly the ×1.5-adjusted fuel from the real lib helpers; click Repair → exact cash drop + "in repair" pill; shorten the gate via the hook → back to service at health 100), restoring a clean starter on exit
|
||||||
|
- **E2E drift mitigation.** Wear accumulates across the whole CI run (one shared DB, workers:1) — the self-heal helpers in `e2e/right-pane.spec.ts` (`flyHomeViaApi`), `e2e/two-planes.spec.ts` (`resetFleet`) and `e2e/actions.spec.ts` (`flyHomeViaApi`) now restore full health + clear any active repair gate first, so the farthest-flyable pickers and the FAR round-trip feasibility pin never see a worn or broken starter
|
||||||
|
|
||||||
|
## v0.38.0 — 2026-09-21 10:47
|
||||||
|
- **Reputation economy (ISS-072).** Reputation is no longer a dead stat — it drives the economy through three simple tunable formulas, all built on one new pure helper `repRate(reputation, perPoint, cap)` in `lib/game/catalog.ts` (linear above the starting value of 10, capped; exactly 0 at/below start so default-rep behavior is byte-identical):
|
||||||
|
- **Cheaper plane purchases** — `buyPlane` (`lib/game/actions.ts`) charges `price × (1 − repRate(rep, PLANE_DISCOUNT_PER_REP = 0.01, MAX_PLANE_DISCOUNT = 0.4))`, rounded to 2dp; the effective cost drives both the funds check and the deduction (up to 40% off at rep ≥ 50)
|
||||||
|
- **Cheaper refuel** — `refuelPlane` multiplies ONLY the price term: `liters × FUEL_PRICE_PER_L × (1 − repRate(rep, FUEL_DISCOUNT_PER_REP = 0.005, MAX_FUEL_DISCOUNT = 0.2))`; burn/target math (wind factor, ×1.5 forFlight margin) untouched
|
||||||
|
- **Better jobs at higher reputation** — `generatePackage` (`lib/game/seed.ts`) takes an optional `reputation` input (defaults to the starting value) and multiplies the final reward by `1 + repRate(rep, JOB_BONUS_PER_REP = 0.01, MAX_JOB_BONUS = 1.0)` at the same spot as the special multiplier (base × jobBonus × special, single round at the end; up to ×2 at rep ≥ 110). Consumes NO extra RNG draw
|
||||||
|
- **Spawn sites carry live reputation.** `spawnOpenPackages` + `spawnCoveragePackages` (`lib/sim/engine.ts`) read the account row once per spawn batch and thread its reputation into `generatePackage`; the test hook POST /api/test/create-package does the same (hook-created packages get current job quality, never a forced value). `buildSeed` keeps defaulting — no rep at seed time
|
||||||
|
- **UI shows the effect.** The market buy row (`components/RightPane.tsx`) previews the EFFECTIVE price computed client-side from the snapshot's reputation + the imported constants, with a sky-blue "−N%" pill (`market-buy-discount`, reusing `.special-badge`) when the discount is non-zero; tech-spec cards keep showing full list prices. The left-pane Refuel section (`components/LeftPane.tsx`) previews the discounted fill-up cost with its own `refuel-discount` pill. No new snapshot fields — rep was already in the state, so the OpenAPI schema is unchanged
|
||||||
|
- **OpenAPI (descriptions only).** buy-plane + refuel endpoint descriptions mention the rep-scaled discounts; `CatalogEntry.price`, `StateSnapshot.fuelPricePerL` and `AccountState.reputation` gain notes that the discount applies at purchase/refuel time. No properties or endpoints added/removed
|
||||||
|
- **Tests.** New unit tests: `repRate` (zero at/below start, linear mid-range exact values, cap clamping), `buyPlane` at rep 30 (exact discounted cash delta + funds boundary between discounted and full price), `refuelPlane` at fixed rep in both 'full' and 'forFlight' modes (discount on the price term only), `generatePackage` job bonus (×1.5 at rep 60, default-rep byte-identity, composition with the special multiplier, no extra RNG draw). All existing unit tests pass unmodified — at the starting reputation every rate is exactly 0. E2E: the full-price pins in `right-pane.spec.ts`, `two-planes.spec.ts` and `actions.spec.ts` now compute expectations from the LIVE snapshot's reputation via the imported helpers (rep accumulates across specs), plus a new assertion that the buy-row discount pill appears iff live rep > starting with text round(rate × 100)
|
||||||
|
|
||||||
|
## v0.37.0 — 2026-09-21 09:29
|
||||||
|
- **Special jobs: data-driven special-status registry for packages (ISS-073).** New `lib/game/specials.ts` holds a flat `SPECIALS` record (`special status → { rewardMultiplier, deadlineScale, repBonus }`) in the same house style as `PLANE_TYPES`, plus `SPECIAL_SPAWN_CHANCE = 0.15` and a pure `pickSpecial(rng, registry?)` helper (one chance draw, then a uniform pick among the registry's keys; the registry is a param defaulting to the real one). Adding or removing a special is an edit of ONE registry entry — no scattered branches. First status: **priority** (×1.5 reward, ×0.5 deadline window, +0.5 rep at delivery; all balance-tunable)
|
||||||
|
- **Spawn & modifiers.** `generatePackage` (`lib/game/seed.ts`) appends one RNG draw at the END of its fixed order to roll a special (~15% of packages); a forced `special` input (including explicit null) skips the draw, mirroring the other overrides. The reward multiplier applies where the reward is computed; the deadline scale applies only to the drawn 6–72h window — a forced `deadlineOffsetMs` skips scaling
|
||||||
|
- **Rep bonus at delivery.** `deliverPackages` (`lib/sim/engine.ts`) adds the registry's flat rep bonus on top of the distance-based gain for special packages, keeping the existing double-rounding convention (gain rounded to cents, total re-rounded)
|
||||||
|
- **State shape & DB.** `special: string | null` added in all four places — `SeedPackage`, Drizzle schema (`lib/db/schema.ts`), raw DDL and a guarded nullable `ALTER TABLE … ADD COLUMN special TEXT` migration in `migrateColumns()` (pre-existing rows stay NULL = none, mirroring the weight_kg precedent), and `PackageState` + its row mapping
|
||||||
|
- **UI badge.** Open-package rows (`components/RightPane.tsx`) and on-board rows (`components/LeftPane.tsx`) show a sky-blue "Priority" pill next to the route when the package has a special status (new `.special-badge` class, `package-special-{id}` testid); label via new `formatSpecialLabel` in `lib/format.ts`
|
||||||
|
- **Test hooks & spec.** POST /api/test/create-package accepts `special` (string or null; omitted → forced null so existing e2e reward/rep assertions stay deterministic — documented in the route header), and expire-package passes `special: null` explicitly. OpenAPI PackageState gains optional `special: string | null`
|
||||||
|
- **Tests.** New unit tests: registry application (reward ×multiplier, deadline ×scale, rep bonus at delivery), pickSpecial distribution with a fixed RNG, forced-value draw skip, and removing an entry cleanly disabling the status; existing reward/deadline assertions scoped for special packages. E2E: new right-pane test forces `special: 'priority'` via the hook and asserts the row shows the "Priority" badge
|
||||||
|
- **E2E self-heal hardening.** The `flyHomeViaApi` helper in `e2e/right-pane.spec.ts` now abandons any leftover loaded/expired package bound to plane-starter once it's back at EDDF (the "open packages render" test leaves a 4 t package on board, which made later hook-package loads hit the payload check), and both that helper and `e2e/slow-on-landing.spec.ts`'s equivalent now refuel-and-retry the repositioning flight when it fails on insufficient fuel (modeled on `e2e/two-planes.spec.ts`)
|
||||||
|
|
||||||
|
## v0.36.0 — 2026-09-21 08:45
|
||||||
|
- **Refuel-for-flight now buys 150% of the required fuel for the leg (ISS-071).** `refuelPlane` mode `'forFlight'` (`lib/game/actions.ts`) tops the tank up to `min(maxFuel, max(current fuel, 1.5 × required))` instead of exactly the requirement — a margin so burn variance doesn't strand planes at the far end. The "does not fit a full tank" 409 guard now compares against the weather-adjusted requirement (see below). No UI change: the per-leg Refuel button has no amount preview, and the left-pane "Refuel to full" row is unaffected
|
||||||
|
- **Wind affects per-leg fuel burn ±20% based on departure weather vs route heading (ISS-070).** The deterministic per-airport weather (`lib/weather.ts`, FNV-1a seeded by ICAO — no dayKey, stable across polls/sessions) now feeds the fuel math: a new `windBurnFactor(windKt, windDirDeg, bearingDeg)` in `lib/sim/movement.ts` returns `1 + headwindKt/150` (exactly [0.8, 1.2] over the 2–30 kt weather range), and a new `initialBearingDeg(lat1, lon1, lat2, lon2)` in `lib/geo.ts` gives the great-circle heading of each leg. Every fuel call site applies the departure airport's factor to `burnPerKm`: `flyPlane`'s feasibility check, `refuelPlane` forFlight, the engine's landed-fuel + auto-chain checks (`lib/sim/engine.ts`), and the live in-flight fuel display — computed server-side in `getStateSnapshot()`/`livePlane()` (`lib/game/state.ts`) from airport coordinates, so no schema change. Fully deterministic: one factor per leg, a pure function of (ICAO pair, catalog)
|
||||||
|
- **Tests & spec.** Unit tests added for both helpers and the ×1.5 expectation (computed in-test via the same helpers — no stale literals); e2e specs import the real helpers from `lib/` instead of mirroring the wind math (`e2e/right-pane.spec.ts`, `e2e/two-planes.spec.ts`, `e2e/actions.spec.ts`), and their "farthest flyable" airport pickers now require BOTH directions to fit a full tank under wind. OpenAPI: description-only updates (refuel endpoint + `burnPerKm` note)
|
||||||
|
|
||||||
|
## v0.35.0 — 2026-09-21 02:26
|
||||||
|
- **Job list shows cargo tonnage instead of count (ISS-066).** The open-package rows in the right pane's Jobs tab (`components/RightPane.tsx`) now show cargo as type + tonnage ("Mail 10.0t") via a new `formatCargo` in `lib/format.ts` — `(kg / 1000).toFixed(1) + 't'`, identical to the loadout display — instead of "type × count"; unit tests added to `lib/format.test.ts`; e2e assertion added to `e2e/right-pane.spec.ts` (the hook-created package is forced to 10 t and its row must contain "10.0t"). No server or OpenAPI changes — `weightKg` was already in the snapshot
|
||||||
|
|
||||||
|
## v0.34.0 — 2026-09-21 01:26
|
||||||
|
- **Duplicate queue legs rejected with 409 (ISS-065).** `queueLeg` (`lib/game/actions.ts`) now refuses to append a destination that is already in the plane's flight queue ("{icao} is already in the flight queue"), and — while the plane is flying — re-queuing the active leg's destination ("already flying to {icao}"). Previously the same route could be queued any number of times, producing pointless repeated/zero-length hops when landing auto-chains `queue[0]`. No UI or OpenAPI changes (the endpoint already documents error responses)
|
||||||
|
|
||||||
|
## v0.33.0 — 2026-09-21 00:45
|
||||||
|
- **Merged On board into the Fleet list (ISS-054).** The left pane's separate "On board" section is deleted; each Fleet row (`components/LeftPane.tsx`) is now the single per-plane block — status/fuel/loadout plus, when present, that plane's loaded/expired cargo rows (fly-to / refuel-for-flight / abandon + penalty preview) and its queue legs. The header shows "in flight" + the current leg for flying planes and the location ICAO when grounded; the empty "Queue" heading is no longer rendered (the sub-section renders only with ≥1 queued leg). All per-item testids unchanged (`onboard-package-*`, `fly-to-*`, `refuel-for-*`, `abandon-*`, `abandon-penalty-*`, `plane-leg-*`, `queue-leg-*`, `queue-cancel-*`); the removed one is `onboard-{plane.id}` (use `plane-{id}`). CSS: `.onboard-block .contract-row*` re-targeted to `.plane-row .contract-row*`; no server or OpenAPI changes
|
||||||
|
- **E2E updates.** The four `onboard-{plane.id}` references now target the fleet rows (`e2e/hover.spec.ts`, `e2e/right-pane.spec.ts`, `e2e/two-planes.spec.ts`); `e2e/panes.spec.ts` no longer asserts an "On board" heading — it asserts the heading is absent from both panes
|
||||||
|
|
||||||
|
## v0.32.0 — 2026-09-20 23:47
|
||||||
|
- **Queue via fly-to and Relocate; dedicated queue-add input removed (ISS-053).** The flight queue no longer has its own entry point in the On board block (`components/LeftPane.tsx`): the per-cargo "Fly to …" button is now enabled for ANY plane state, and posts to `/api/planes/{id}/queue` instead of `/fly` when the plane is `flying` (grounded → `/fly`, unchanged); Relocate's plane list widened from grounded-only to ALL planes (`relocatable = state.planes`; Refuel keeps its own `grounded` filter), and `relocate-go` posts to `/queue` when the SELECTED plane is flying, else `/fly`. The queue-add select + "Add to queue" button (and the shared `queueAddDestIcao` state) are deleted; leg rows + cancel buttons unchanged. No server or OpenAPI changes — both endpoints already existed
|
||||||
|
- **E2E updates.** `e2e/queue.spec.ts` test 1 now adds its queued leg via the Relocate section (selects plane-starter, which is flying at that point → `/queue`) instead of the removed queue-add control; `e2e/two-planes.spec.ts` "per-plane queues are independent" adds the starter's second leg via API POST `/api/planes/plane-starter/queue` (the starter is grounded there, so fly-to would start rather than queue). All `queue-leg-*` / `queue-cancel-*` assertions unchanged
|
||||||
|
|
||||||
|
## v0.31.0 — 2026-09-20 21:23
|
||||||
|
- **Two-plane Load is one-click again (ISS-055).** The reported "can't load cargo with two planes at the same airport" was UX, not a server bug: `LoadControls` (`components/RightPane.tsx`) rendered a plane picker for ≥2 eligible planes whose Load button stayed DISABLED until a pick existed, while the single-plane path is one-click. The multi-plane path now PRESELECTS the first eligible plane (`effective = … ?? eligible[0] ?? null`; the select already bound to `effective?.id`), so Load is enabled and one-click whenever ≥1 plane qualifies — the dropdown still allows picking a different plane
|
||||||
|
- **LeftPane queue testids scoped by plane id (ISS-055).** With two on-board blocks in the DOM the unscoped ids duplicated: `queue-leg-{i}` → `queue-leg-{plane.id}-{i}`, `queue-cancel-{i}` → `queue-cancel-{plane.id}-{i}`, plus the add control `queue-add-dest`/`queue-add` → `queue-add-dest-{plane.id}`/`queue-add-{plane.id}` (`components/LeftPane.tsx`). The shared pane-level destination pick is unchanged. `e2e/queue.spec.ts` locators updated to the scoped form (single-plane fleet, id `plane-starter`)
|
||||||
|
- **New test hook: POST /api/test/add-cash (ISS-055).** Grants cash so e2e can afford a second plane (ATR €200k vs the €20k starting balance) — body `{ amount?: number }` (positive finite, default €1M), gated on `FLIGHT_TEST_HOOKS=1` exactly like its siblings (`app/api/test/create-package`, `/expire-package`; same 404/400 error shape), returns the full state snapshot. Test-only: outside the OpenAPI spec, so the documented path list stays at thirteen
|
||||||
|
- **New serial e2e spec `e2e/two-planes.spec.ts` (ISS-055).** Six tests covering every two-plane gameplay flow — each buys its own second ATR via the UI (after an add-cash grant) and discovers it in /api/state as the plane that isn't `plane-starter`: **load** (picker lists both planes, preselect makes Load one-click without touching the select, explicit pick routes to the other plane, both on-board blocks render with cargo simultaneously), **fly** (parallel dispatch to different destinations; ISS-057 semantics — last landing leaves `paused === false && timeScale === 1`, dispatching a plane while another is grounded stays at 1×, dispatching the LAST grounded plane resumes `lastSelectedTimeScale` = 1000 in the /fly response), **refuel** (left-pane section lists both planes; refueling one deducts exactly its fill-up cost and tops only that tank), **relocate** (moves one plane, starter untouched), **queue** (per-plane scoped controls add/cancel independently; landing auto-chains the next queued leg at 1000×), **sell** (payout = round(price × SELL_FRACTION) added to cash, fleet back to 1, selling the last remaining plane 409s via API). Robust reset: beforeEach heals + sells every non-starter plane (queue cleared first so landings can't auto-chain mid-heal, leftover cargo abandoned so the sell guard never 409s) and repositions the starter home — every test starts AND ends at fleet = 1
|
||||||
|
- Verified: `tsc --noEmit` clean; unit suite green locally (327 passed in 32 files — unchanged, no new unit tests: the sibling test hooks have none, so add-cash follows suit); `npx playwright test --list` = 52 tests in 14 files (was 46/13 — six new two-plane tests); e2e not run locally by design — CI verifies against an isolated stack
|
||||||
|
|
||||||
|
## v0.30.0 — 2026-09-20 19:47
|
||||||
|
- **Landing slows the sim to 1× instead of pausing; dispatch restores the last selected speed (ISS-057).** Replaces the v0.24.0 pause-on-landing behavior with a slow-down: in `tickOnce` (`lib/sim/engine.ts`), when ≥1 plane landed this tick and ANY plane is grounded ('available') afterwards, the sim drops to `MIN_TIME_SCALE` (1×) — a just-landed plane counts, so any real landing slows; a chained plane stays 'flying' via deliverPackages' auto-chain branch, so a pure-chain tick with nothing grounded does NOT slow. Pause becomes manual-only: ticks never set it anymore
|
||||||
|
- **Resume on dispatch.** `flyPlane` (`lib/game/actions.ts`) still clears a manually-paused sim on dispatch (kept), and now ALSO restores the last manually selected speed when this dispatch took the LAST grounded plane airborne — fresh-select 'available' planes after the update, and if zero remain set `timeScale = lastSelectedTimeScale`. With another plane still grounded the sim stays at 1×
|
||||||
|
- **New column `last_selected_time_scale` on `sim_state`** (`lib/db/schema.ts`, DDL + guarded `ALTER TABLE … DEFAULT 1` migration in `lib/db/client.ts`; pre-existing rows backfill to their OWN current `time_scale`, so a legacy DB resumes at the speed it was running). Written by `applyTimeControls` whenever a POST /api/sim body carries `timeScale` (clamped value); a `{paused}`-only post never touches it — last manual intent wins as the resume target with no extra flag. Seed sets it to 1 (`lib/game/seed.ts`)
|
||||||
|
- No snapshot/OpenAPI/route/client changes: the slider and speed label re-read `timeScale` from each poll automatically, so the slow-down and the resume are visible in the UI without any client work
|
||||||
|
- Tests: unit — `lib/sim/engine.test.ts` pause-on-landing describe rewritten for the slow/resume semantics (last landing at 1000× → paused=false + timeScale=1 + lastSelectedTimeScale preserved; landing with another plane still flying → slows, literal issue reading; already at 1× stays; chained-plane tick does NOT slow; manually-paused sim untouched by ticks), `lib/game/actions.test.ts` dispatch test rewritten (sole grounded plane → resumes to lastSelectedTimeScale=250 + unpauses; second grounded plane → stays 1×), new `app/api/sim/route.test.ts` case (POST {timeScale} records the row's lastSelectedTimeScale, POST {paused}-only leaves it untouched — read via getGameDb()), `lib/game/seed.test.ts` exact-equality gains the field, `lib/db/client.test.ts` legacy-migration fixture bumps time_scale to 50 and asserts the backfill. E2E — `e2e/pause-on-landing.spec.ts` renamed to `e2e/slow-on-landing.spec.ts` and rewritten: after landing assert `paused === false && timeScale === 1`; the /fly post-action snapshot asserts `timeScale === 1000` via resume (setScale posts 1000, so lastSelected=1000). Other specs' setScale helpers post {timeScale, paused:false} and keep working unchanged
|
||||||
|
- Verified: `tsc --noEmit` clean; unit suite green locally (327, was 325 — +2 new cases; engine describe rewritten in place); `npx playwright test --list` = 46 tests in 13 files (unchanged — the spec rename keeps the count); e2e not run locally by design — CI verifies against an isolated stack
|
||||||
|
|
||||||
|
## v0.29.1 — 2026-09-20 18:22
|
||||||
|
- Removed the flaky 'time controls' e2e test from `e2e/panes.spec.ts` (keyboard-driven slider assertions timed out randomly in CI); pause semantics remain covered by `e2e/sim.spec.ts` and `e2e/pause-on-landing.spec.ts`.
|
||||||
|
|
||||||
|
## v0.29.0 — 2026-09-20 14:27
|
||||||
|
- **Ignore a job (ISS-047, part B).** A player can now dismiss an open job so it leaves the market permanently; new random jobs spawn in its place via the existing top-up logic (`spawnOpenPackages` counts 'open', so the market refills to 10 on the next tick). New terminal package status `'ignored'` added to the status union/enum in `lib/db/schema.ts`, `lib/game/state.ts`, `lib/openapi.ts` (PackageState.status) and the e2e-local interface copy in `e2e/right-pane.spec.ts`. No DB migration — the column is plain TEXT with no CHECK constraint. New action `ignorePackage` (`lib/game/actions.ts`) next to abandon: 404 unknown id, 409 unless open ("only open packages can be ignored"), otherwise flips status to 'ignored' — no body, no penalty, nothing else changes
|
||||||
|
- **New route.** `POST /api/packages/{id}/ignore` (`app/api/packages/[id]/ignore/route.ts`) — no body, mirrors the abandon route exactly (same performAction wrapper, full state snapshot on success). The 13th public endpoint: OpenAPI spec gains the path + 'ignored' in PackageState.status; `lib/openapi.test.ts` and `e2e/api.spec.ts` path lists updated twelve → thirteen
|
||||||
|
- **UI.** Each open-package row in the Jobs tab gains a small "Ignore" button beside the Load controls (`components/RightPane.tsx`, testid `ignore-{pkg.id}`, disabled while the pane action is busy, calls runAction with no body). Ignored rows vanish from the list automatically — the section filters status==='open'; no toast (deriveEvents untouched)
|
||||||
|
- Verified-safe consumers unchanged: expirePackages selects only open|loaded ('ignored' never expires), deliverPackages handles loaded/expired, RightPane filters 'open'
|
||||||
|
- Tests: unit — new `ignorePackage` block in `lib/game/actions.test.ts` (open → ignored with cash/planeId untouched; 409 for each of loaded/delivered/expired/abandoned/already-ignored; 404 unknown id), `lib/sim/engine.test.ts` terminal-statuses test gains an 'ignored' row, `lib/openapi.test.ts` status enum list gains 'ignored'. E2E — new "Ignore removes an open job from the list permanently" test in `e2e/right-pane.spec.ts`: hook-created package renders with an enabled Ignore button, UI click → /api/state shows 'ignored', row gone from `[data-testid="open-packages"]`, second API ignore 409s
|
||||||
|
- Verified: `tsc --noEmit` clean; unit suite green locally (325, was 318); `npx playwright test --list` = 47 tests in 13 files (was 46/13 — the new ignore test); e2e not run locally by design — CI verifies against an isolated stack
|
||||||
|
|
||||||
|
## v0.28.0 — 2026-09-20 13:55
|
||||||
|
- **Right pane reworked into tabs (ISS-047, part A).** The right pane now has a tab bar under the "Jobs & Market" heading with two tabs — `tab-jobs` ("Jobs", default-active) and `tab-planes-market` ("Planes market") (`components/RightPane.tsx`, new `.pane-tabs`/`.tab-btn` CSS in `app/globals.css`). The Jobs tab is the existing Packages section moved verbatim (content, testids and hover behavior unchanged); the Planes market tab holds the existing buy/sell rows (testids unchanged) plus a tech-spec panel per catalog type — one card (`plane-type-{model}`) with price, sell value (price × SELL_FRACTION), max payload in tonnes, cruise speed, fuel burn, max fuel and **max range**, ordered by price ascending. The inactive tab unmounts; the `cargoWeightFor`/`loadEligiblePlanes` exports are untouched
|
||||||
|
- **Five new plane types — progression ladder above the ATR starter (ISS-047).** CRJ-900 (€450k), Embraer E175 (€900k), Airbus A320-200 (€1.8M), Airbus A330-200 (€4.5M) and Boeing 777-300ER (€9M) added to `PLANE_TYPES` in `lib/game/catalog.ts` with the exact balance values from the issue (bigger tanks, faster airframes, more payload). Purely additive — everything downstream (buy validation, movement math, load validation, snapshot catalog) is data-driven via `PLANE_TYPES[model]`, so no endpoint or schema change; the OpenAPI spec and its e2e path list stay at twelve
|
||||||
|
- **Derived max range (ISS-047).** New pure `maxRangeKm(burnPerKm, emptyWeightKg, maxFuel)` in `lib/sim/movement.ts` — closed form of the weighted burn model solving `requiredFuelFor(d) = maxFuel`: `ln(1 + ρF/We)·(We + ρF)/(ρ·burnPerKm)` (reuses `FUEL_DENSITY_KG_PER_L`, guards We > 0, zero fuel → 0). ATR 72-600 full tank ≈ 4436 km. Display-only — the sim still does not model range exhaustion
|
||||||
|
- **Sell-plane cargo guard (ISS-047).** `sellPlane` (`lib/game/actions.ts`) now 409s with "cannot sell a plane carrying cargo" when the grounded plane has any `loaded`/`expired` packages bound to it — previously selling such a plane orphaned its cargo (packages stuck 'loaded' with a dangling planeId that could never deliver). Same query pattern as `loadPackage`; the last-plane and flying-plane guards are unchanged
|
||||||
|
- Tests: unit — new `lib/sim/movement.test.ts` block for `maxRangeKm` (ATR ≈ 4439 km ±5%, monotonic in tank size, inverse in burn rate, zero fuel → 0, We ≤ 0 throws), new `lib/game/catalog.test.ts` sanity file (positive specs + unique ids across all six types, atr72 is STARTER_MODEL), `lib/game/actions.test.ts` sellPlane gains a loaded-cargo 409 case (row left untouched). E2E — `e2e/right-pane.spec.ts`: the market test clicks `tab-planes-market` first and a new "Planes market tab lists all plane types with tech specs" test asserts six catalog entries via /api/state, the ATR card's name + range against an in-test mirror of `maxRangeKm`, and the Boeing 777 card with its €9M price; `e2e/toasts.spec.ts` clicks the tab before its market-buy flow; `e2e/panes.spec.ts` asserts both tab buttons render with Jobs default-active. No new endpoints — `lib/openapi.test.ts` untouched
|
||||||
|
- Verified: `tsc --noEmit` clean; unit suite green locally (318, was 311); `npx playwright test --list` = 46 tests in 13 files (was 45/13 — the new spec-panel test); e2e not run locally by design — CI verifies against an isolated stack
|
||||||
|
|
||||||
|
## v0.27.1 — 2026-09-20 13:16
|
||||||
|
- Right pane widened from 280px to 360px to match the left pane; toast stack offset adjusted to stay flush with the map's right edge (ISS-046).
|
||||||
|
|
||||||
|
## v0.27.0 — 2026-09-20 12:27
|
||||||
|
- **Packages now have real weight (ISS-045).** New `weight_kg REAL NOT NULL DEFAULT 2000` column on `packages` (`lib/db/schema.ts`, DDL + guarded `ALTER TABLE` migration in `lib/db/client.ts`; pre-existing rows backfill to the mid-range default). `generatePackage` draws it AFTER cargoCount: `UNIT_WEIGHT_KG[cargoType] × cargoCount × (0.7 + 0.6·rng())`, rounded to integer kg — one extra RNG draw per package, so the documented fixed draw order is now origin, dest, cargoType, cargoCount, weight jitter, [deadline window]. New tunable table `UNIT_WEIGHT_KG` in `lib/game/seed.ts` (Passengers 50 / Mail 60 / Perishables 70 / Electronics 40 / Auto parts 72 kg per passenger/pallet), chosen so every cargo type at MAX count × max jitter (1.3×) still fits the ATR alone and typical packages land ~400–2500 kg
|
||||||
|
- **Plane capacity is now max payload kg, not 6 slots (ISS-045).** `capacity` REMOVED from `PlaneType`/`CatalogEntry`/snapshot catalog; replaced by `maxPayloadKg` — ATR 72-600 = 4800 kg (`lib/game/catalog.ts`). Server load validation (`loadPackage`, `lib/game/actions.ts`) now sums `weightKg` over the plane's loaded+expired packages and 409s with `no payload room: the ATR 72-600 carries X/Y kg` when the new package doesn't fit; expired cargo still occupies its payload. Client mirrors it: `slotsUsedFor` → `cargoWeightFor(state, planeId)` (summed kg) in `components/RightPane.tsx`, and `loadEligiblePlanes` compares used + package weight ≤ catalog maxPayloadKg so the Load button state always matches what the server accepts
|
||||||
|
- **Payout now includes a per-kg part (ISS-045).** New `rewardFor(km, weightKg) = round(BASE_REWARD + REWARD_PER_KM·km + REWARD_PER_KG·weightKg)` with `REWARD_PER_KG = 0.5` (`lib/game/seed.ts`) — replaces the distance-only `rewardForDistanceKm`, which is deleted (no remaining callers). Typical hops stay in the ~€1–3k band
|
||||||
|
- **Loadout display shows tonnage (ISS-045).** Left-pane fleet rows now render `X.Xt / Y.Yt` (used/max payload, one decimal) instead of `n/6 pkgs`; the on-board block filter switches to weight-based. No new endpoints — OpenAPI only gains `PackageState.weightKg` and swaps `CatalogEntry.capacity` → `maxPayloadKg`, so the e2e path list stays at twelve
|
||||||
|
- **Test hook gains a forced weight (ISS-045).** `generatePackage` accepts an optional `weightKg` that skips the jitter RNG draw (same pattern as the v0.8.0 forced endpoints), and `POST /api/test/create-package` passes it through — so e2e flows that load several packages onto one plane stay under its payload deterministically instead of flaking on two heavy random draws (~13% would otherwise exceed 4800 kg). The hook is test-only (FLIGHT_TEST_HOOKS=1) and not part of the documented API, so the OpenAPI path list stays at twelve
|
||||||
|
- Tests: unit — `lib/game/seed.test.ts` (reward expectations → new formula incl. per-kg part; new balance-constraint test asserting every seeded package is a positive integer kg ≤ starter maxPayloadKg; forced-weight block), `lib/game/actions.test.ts` (loadPackage fill loop now inserts weights summing past maxPayloadKg → `/no payload room/`; expired-still-occupies reworked weight-based), `components/right-pane.test.ts` (`cargoWeightFor` sums + partial-payload eligibility case; CATALOG fixture drops capacity, gains maxPayloadKg), `lib/openapi.test.ts` (exact key lists for PackageState + CatalogEntry). Type-compat only: catalog fixtures in `deriveEvents`/`mapData` tests and the e2e interfaces. E2E — `e2e/right-pane.spec.ts` asserts `maxPayloadKg === 4800`; `e2e/panes.spec.ts` loadout regex → `/^\d+\.\dt \/ \d+\.\dt$/`; snapshot key lists in `app/api/state/route.test.ts` + `e2e/state.spec.ts` gain `weightKg`; the two multi-load tests in `e2e/actions.spec.ts` pass forced weights. Fuel math untouched — the refuel-forFlight exact-liter mirrors stay as-is
|
||||||
|
- Verified: `tsc --noEmit` clean; unit suite green locally (311, was 308); `npx playwright test --list` = 45 tests in 13 files (unchanged); e2e not run locally by design — CI verifies against an isolated stack
|
||||||
|
|
||||||
|
## v0.26.0 — 2026-09-20 11:28
|
||||||
|
- **Flight queue UI + map rendering (ISS-043, part 2).** Builds on the part-1 backend (`PlaneState.queue`, `POST /api/planes/{id}/queue` + `/queue/cancel`, auto-chain on landing) — no new endpoints, so the OpenAPI spec and its e2e path list are untouched
|
||||||
|
- **Dotted queue line on the map (ISS-043).** New pure builder `queuedRoutesGeoJson(planes, airportsByIcao)` in `lib/mapData.ts`: one LineString per FLYING plane with a non-empty queue, chaining `dest → queue[0] → …` — ONLY the not-yet-started legs (the active leg stays in the solid `routes-legs` layer). Reuses `greatCircleArc`, same feature/properties shape as `routesGeoJson` (`{planeId}`); arcs concatenated with the shared joint point dropped once; a plane is skipped, never guessed, when its dest or any queued ICAO is missing from the airport index. New GeoJSON source + line layer in `components/Map.tsx` — id `queue-routes`, muted slate `#64748b`, width 3, dotted via `'line-dasharray': [0.1, 1.5]` (distinct from the solid amber active leg and the `[2,2]` yellow hover dash), added after `routes-legs` but before the hover layers so a hovered trace still draws on top; pushed each poll in `applyDynamicRef` alongside the solid legs
|
||||||
|
- **Queue sub-section in the left pane's On board block (ISS-043).** Each on-board plane block now carries a "Queue" sub-section after its cargo rows: one row per queued leg (`queue-leg-{i}`) showing destination name + ICAO with a Cancel button (`queue-cancel-{i}` → `POST …/queue/cancel {index:i}`), plus an add control — airport `<select>` (`queue-add-dest`) + "Add to queue" button (`queue-add` → `POST …/queue {destIcao}`) reusing the pane's already-fetched `airportOptions` (no second `/api/airports` fetch); buttons disable on `actionsBusy`, add also disables until an airport is picked. The on-board block filter now ALSO includes planes with a non-empty queue, so a queued plane always shows its block even when grounded with nothing on board. Existing cargo-row testids/behavior (`onboard-package-*`, `fly-to-*`, `refuel-for-*`, `abandon-*`) unchanged
|
||||||
|
- Tests: unit — `lib/mapData.test.ts` gains a `queuedRoutesGeoJson` block mirroring the `routesGeoJson` tests (grounded plane with queue → nothing; flying with empty queue → nothing; flying with a 2-leg queue → one feature starting at DEST, passing through each queued airport exactly at the arc joints, ending at the last queued ICAO, length `n_legs × SEGMENTS + 1`; missing dest or queued ICAO → skipped; multiple planes → only qualifying ones draw). E2E — new `e2e/queue.spec.ts` (serial): (1) queue display + cancel through the real UI — load a package at EDDF, start leg 1 via the fly-to button, freeze mid-flight, add a leg via `queue-add-dest`/`queue-add`, assert `queue-leg-0` appears with its destination, cancel it and assert it's gone; (2) dotted map line — flying plane with one queued leg: fit to both endpoints and assert some rendered `queue-routes` segment starts within EPS of DEST and some ends within EPS of the queued airport ACROSS per-tile segments (plain-object mapping in-page per ORCHESTRATOR §6), no segment starts at the hub (the active leg is NOT in this layer) and every feature carries `planeId: plane-starter`; (3) auto-chain end-to-end — full tank, package EDDF→LEMD + queued return LEMD→EDDF, start leg 1, fast-forward at 1000× and assert the server AUTO-STARTS the chained leg (`status 'flying'` to EDDF with no manual dispatch) with the package already delivered (part-1 ordering: delivery before chaining); all three leave the starter grounded at EDDF for later specs
|
||||||
|
- Verified: `tsc --noEmit` clean; unit suite green locally (308, was 303); `npx playwright test --list` = 45 tests in 13 files (was 42/12 — the new queue spec); e2e not run locally by design — CI verifies against an isolated stack
|
||||||
|
|
||||||
|
## v0.25.0 — 2026-09-20 10:36
|
||||||
|
- **Planes now hold a queue of upcoming flight legs (ISS-043, part 1 — backend).** One new column on `planes`: `queue TEXT NOT NULL DEFAULT '[]'` (`lib/db/schema.ts`, DDL + guarded `ALTER TABLE` migration in `lib/db/client.ts`) storing an ordered JSON array of ICAO strings = the legs NOT yet started. The active leg stays exactly as before (`destIcao` + `departureSimTime`); full path = `[locationIcao, destIcao, ...queue]`. No new table — queues are short, per-plane, always read/written whole
|
||||||
|
- **Auto-chain on landing (ISS-043).** In `deliverPackages()` (`lib/sim/engine.ts`) a plane that arrives now reads its queue: if it holds legs AND fuel covers the first one (same `requiredFuelFor` feasibility rule as `/fly`), it immediately starts that leg from the arrival airport — stays `'flying'`, `departureSimTime` = the arrival instant, queue shortened. If fuel is short it grounds safely with the remaining queue kept intact for refuel + re-dispatch; an empty queue grounds exactly as before. Cargo delivery for the reached airport runs first and is unaffected by chaining. Ordering preserved on purpose: delivery runs BEFORE tickOnce's pause check (ISS-041), so a chained plane counts as still flying there — chaining never triggers the last-plane pause
|
||||||
|
- **New endpoints (ISS-043).** `POST /api/planes/{planeId}/queue` body `{destIcao}` appends a not-yet-started leg (works grounded or flying — pre-build a route; nothing auto-starts, 400 unknown ICAO/bad body, 404 unknown plane); `POST /api/planes/{planeId}/queue/cancel` body `{index}` removes a queued leg by index (only queued legs are cancellable — the active in-flight leg is never touched; 400 non-integer/out-of-range index, 404 unknown plane). Both run through `performAction()` like every other action and return the full state snapshot. New actions `queueLeg`/`cancelQueuedLeg` in `lib/game/actions.ts`; routes under `app/api/planes/[planeId]/queue/`
|
||||||
|
- **Snapshot + spec (ISS-043).** `PlaneState.queue: string[]` added to the `/api/state` snapshot (`lib/game/state.ts`, parsed with a null/malformed-safe helper shared by the engine); OpenAPI updated — `PlaneState.queue`, `QueueLegRequest`, `CancelQueuedLegRequest`, both new paths (200→snapshot, 400, 404)
|
||||||
|
- Tests: unit — `lib/sim/engine.test.ts`: auto-chain block in `deliverPackages` (lands with queue + enough fuel → `'flying'` to the next dest at the arrival instant with the queue shortened; lands short on fuel → grounds with the queue intact; empty queue → unchanged grounding) plus a pause-on-landing regression guard — a chained plane does NOT trigger the last-plane pause. `lib/game/actions.test.ts`: `queueLeg` (appends in order, works while flying without touching the active leg, 404 unknown plane, 400 unknown ICAO) and `cancelQueuedLeg` (removes the right index keeping order, 400 out-of-range/negative/empty-queue, 404 unknown plane). `lib/openapi.test.ts`: endpoint count ten → twelve, PlaneState keys + both request schemas. Type-compat only: `queue: []` added to the `PlaneState`/seed fixtures in `deriveEvents`, `right-pane`, `mapData`, `seed` tests
|
||||||
|
- Part 1 of ISS-043 (backend) — UI/map rendering of queued legs lands in a follow-up part. Verified: `tsc --noEmit` clean; unit suite green locally; `npx playwright test --list` unchanged at 42 tests in 12 files; e2e not run locally by design — CI verifies against an isolated stack
|
||||||
|
|
||||||
|
## v0.24.0 — 2026-09-20 09:48
|
||||||
|
- **Landing now pauses the sim instead of auto-slowing to 1× (ISS-041).** When the LAST plane in flight lands, `tickOnce` (`lib/sim/engine.ts`) sets `sim_state.paused = true` WITHOUT touching `timeScale` — speed is preserved. The trigger condition carries over from ISS-011's auto-slow unchanged: ≥1 landing this tick AND no `'flying'` planes remain (a landing while another plane is still airborne does not pause — there would be nothing to auto-resume it). No idle-enforcement or auto-restore of the pause exists
|
||||||
|
- **Starting a flight auto-resumes a paused sim (ISS-041).** `flyPlane()` (`lib/game/actions.ts`) writes `sim_state.paused = false` before persisting — inside `runAction()`, so tick + unpause + dispatch are atomic under the sim lock. The cycle: dispatch unpauses → last landing pauses (speed preserved) → next dispatch unpauses again
|
||||||
|
- Tests: unit — `lib/sim/engine.test.ts`: the auto-slow describe block rewritten for pause semantics (last plane lands → `paused === true` AND stored `timeScale` UNCHANGED, persisted on the DB row; a landing while another plane is still flying → no pause; last landing at scale 1 → pauses anyway; paused tick with a fully grounded fleet → trigger never fires). `lib/game/actions.test.ts`: new test — dispatching on a paused sim clears `paused` and leaves `timeScale` untouched. E2E — `e2e/auto-slow.spec.ts` renamed to `e2e/pause-on-landing.spec.ts` and reworked: after the starter lands, asserts `simState.paused === true` AND `timeScale` still 1000 (NOT reset), then a subsequent `/fly` dispatch clears `paused` on its own post-action snapshot. Cross-cutting fix for the new pause: every per-spec `setScale` helper (`actions`, `hover`, `map-interactions`, `relocation`, `right-pane`) now POSTs `{ timeScale, paused: false }` — a landing pauses the sim, so a scale-only fast-forward after one would leave the clock frozen and hang the next wait; no caller relied on it NOT clearing pause (verified per call site). Deliberate mid-test pauses (`sim.spec.ts` / `panes.spec.ts` freeze tests, marker-click freezes) are untouched
|
||||||
|
- Verified: `tsc --noEmit` clean; unit suite green locally (291); `npx playwright test --list` = 42 tests in 12 files; e2e not run locally by design — CI verifies against an isolated stack
|
||||||
|
|
||||||
## v0.23.2 — 2026-09-20 09:15
|
## 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
|
- **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
|
- 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
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
@@ -6,8 +6,9 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|||||||
import * as schema from '@/lib/db/schema';
|
import * as schema from '@/lib/db/schema';
|
||||||
import { getGameDb } from '@/lib/db/client';
|
import { getGameDb } from '@/lib/db/client';
|
||||||
import { loadAirportsGeoJson } from '@/lib/airports';
|
import { loadAirportsGeoJson } from '@/lib/airports';
|
||||||
import { haversineKm } from '@/lib/geo';
|
import { haversineKm, initialBearingDeg } from '@/lib/geo';
|
||||||
import { requiredFuelFor } from '@/lib/sim/movement';
|
import { weatherFor } from '@/lib/weather';
|
||||||
|
import { requiredFuelFor, windBurnFactor } from '@/lib/sim/movement';
|
||||||
import { FUEL_PRICE_PER_L, PLANE_TYPES } from '@/lib/game/catalog';
|
import { FUEL_PRICE_PER_L, PLANE_TYPES } from '@/lib/game/catalog';
|
||||||
import { HUB_ICAO } from '@/lib/game/seed';
|
import { HUB_ICAO } from '@/lib/game/seed';
|
||||||
import { POST } from './route';
|
import { POST } from './route';
|
||||||
@@ -124,7 +125,7 @@ describe('POST /api/planes/:planeId/refuel', () => {
|
|||||||
expect(res.status).toBe(400);
|
expect(res.status).toBe(400);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('forFlight tops up exactly the liters needed for the loaded package leg', async () => {
|
it('forFlight buys 150% of the weather-adjusted leg requirement', async () => {
|
||||||
const { db } = await getGameDb();
|
const { db } = await getGameDb();
|
||||||
await db.update(schema.account).set({ cash: 50_000 });
|
await db.update(schema.account).set({ cash: 50_000 });
|
||||||
// A loaded EDDF → LEMD package on the starter (direct row — loadPackage is covered in actions tests).
|
// A loaded EDDF → LEMD package on the starter (direct row — loadPackage is covered in actions tests).
|
||||||
@@ -138,7 +139,14 @@ describe('POST /api/planes/:planeId/refuel', () => {
|
|||||||
const to = fc.features.find((f) => f.properties.icao === 'LEMD')!.properties;
|
const to = fc.features.find((f) => f.properties.icao === 'LEMD')!.properties;
|
||||||
const km = haversineKm(from.lat, from.lon, to.lat, to.lon);
|
const km = haversineKm(from.lat, from.lon, to.lat, to.lon);
|
||||||
const type = PLANE_TYPES.atr72;
|
const type = PLANE_TYPES.atr72;
|
||||||
const required = requiredFuelFor(type.burnPerKm, type.emptyWeightKg, type.maxFuel, km);
|
// Same wind-adjusted requirement the server computes (v0.36.0).
|
||||||
|
const w = weatherFor(HUB_ICAO);
|
||||||
|
const required = requiredFuelFor(
|
||||||
|
type.burnPerKm * windBurnFactor(w.windKt, w.windDirDeg, initialBearingDeg(from.lat, from.lon, to.lat, to.lon)),
|
||||||
|
type.emptyWeightKg,
|
||||||
|
type.maxFuel,
|
||||||
|
km,
|
||||||
|
);
|
||||||
|
|
||||||
const startFuel = Math.round(required / 2); // partial tank so L > 0
|
const startFuel = Math.round(required / 2); // partial tank so L > 0
|
||||||
await db.update(schema.planes).set({ fuel: startFuel }).where(eq(schema.planes.id, 'plane-starter'));
|
await db.update(schema.planes).set({ fuel: startFuel }).where(eq(schema.planes.id, 'plane-starter'));
|
||||||
@@ -151,9 +159,11 @@ describe('POST /api/planes/:planeId/refuel', () => {
|
|||||||
account: { cash: number };
|
account: { cash: number };
|
||||||
planes: Array<{ id: string; fuel: number }>;
|
planes: Array<{ id: string; fuel: number }>;
|
||||||
};
|
};
|
||||||
const cost = Math.round((required - startFuel) * FUEL_PRICE_PER_L * 100) / 100;
|
// ISS-071: target is 150% of the requirement, capped at tank capacity.
|
||||||
|
const targetFuel = Math.min(type.maxFuel, Math.max(startFuel, required * 1.5));
|
||||||
|
const cost = Math.round((targetFuel - startFuel) * FUEL_PRICE_PER_L * 100) / 100;
|
||||||
expect(body.account.cash).toBeCloseTo(cashBefore - cost, 6);
|
expect(body.account.cash).toBeCloseTo(cashBefore - cost, 6);
|
||||||
expect(body.planes.find((p) => p.id === 'plane-starter')!.fuel).toBeCloseTo(required, 6);
|
expect(body.planes.find((p) => p.id === 'plane-starter')!.fuel).toBeCloseTo(targetFuel, 6);
|
||||||
|
|
||||||
// Clean up the test package.
|
// Clean up the test package.
|
||||||
await db.delete(schema.packages).where(eq(schema.packages.id, 'p-ff-api'));
|
await db.delete(schema.packages).where(eq(schema.packages.id, 'p-ff-api'));
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { performAction, repairPlane } from '@/lib/game/actions';
|
||||||
|
|
||||||
|
// POST /api/planes/:planeId/repair (v0.39.0, ISS-069) — no body. Restores a worn-out plane to
|
||||||
|
// full health for the flat REPAIR_COST: it is grounded in "repairing" status until 3 sim days
|
||||||
|
// pass, when the engine's tick flips it back to "available". A full-health repair is a free
|
||||||
|
// no-op (cost 0). 404 unknown id; 409 plane not grounded or insufficient funds. On success
|
||||||
|
// returns the full state snapshot.
|
||||||
|
|
||||||
|
export async function POST(_req: Request, ctx: { params: Promise<{ planeId: string }> }) {
|
||||||
|
const { planeId } = await ctx.params;
|
||||||
|
return performAction((inst) => repairPlane(inst, planeId));
|
||||||
|
}
|
||||||
@@ -2,6 +2,8 @@ import fs from 'node:fs/promises';
|
|||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||||
|
import { getGameDb } from '@/lib/db/client';
|
||||||
|
import * as schema from '@/lib/db/schema';
|
||||||
import { POST } from './route';
|
import { POST } from './route';
|
||||||
|
|
||||||
let dir: string;
|
let dir: string;
|
||||||
@@ -84,6 +86,21 @@ describe('POST /api/sim', () => {
|
|||||||
await post({ timeScale: 1, paused: false }); // restore
|
await post({ timeScale: 1, paused: false }); // restore
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('records lastSelectedTimeScale when timeScale is posted; a paused-only post leaves it untouched (ISS-057)', async () => {
|
||||||
|
const inst = await getGameDb(); // same singleton the route's applyTimeControls writes through
|
||||||
|
const row = async () => (await inst.db.select().from(schema.simState))[0];
|
||||||
|
|
||||||
|
await post({ timeScale: 250 });
|
||||||
|
expect((await row()).lastSelectedTimeScale).toBe(250); // manual intent recorded…
|
||||||
|
|
||||||
|
await post({ paused: true });
|
||||||
|
expect((await row()).paused).toBe(true);
|
||||||
|
expect((await row()).lastSelectedTimeScale).toBe(250); // …and a {paused}-only post does not touch it
|
||||||
|
|
||||||
|
await post({ timeScale: 1, paused: false }); // restore
|
||||||
|
expect((await row()).lastSelectedTimeScale).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
it('rejects invalid input with 400', async () => {
|
it('rejects invalid input with 400', async () => {
|
||||||
for (const body of [{ timeScale: 'fast' }, { timeScale: Infinity }, { paused: 'yes' }, { bogus: 1 }]) {
|
for (const body of [{ timeScale: 'fast' }, { timeScale: Infinity }, { paused: 'yes' }, { bogus: 1 }]) {
|
||||||
const res = await post(body);
|
const res = await post(body);
|
||||||
|
|||||||
@@ -58,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.
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { getGameDb, persist } from '@/lib/db/client';
|
||||||
|
import * as schema from '@/lib/db/schema';
|
||||||
|
import { getStateSnapshot } from '@/lib/game/state';
|
||||||
|
import { tickNow } from '@/lib/sim/engine';
|
||||||
|
|
||||||
|
// TEST-ONLY hook (v0.31.0, ISS-055) — grants cash so the e2e suite can afford a second
|
||||||
|
// plane (ATR €200k vs the €20k starting balance). Disabled unless FLIGHT_TEST_HOOKS=1
|
||||||
|
// (set in docker-compose.yml for the e2e instance only); every other deployment 404s here.
|
||||||
|
// Sibling of /api/test/create-package — same gate, same conventions.
|
||||||
|
//
|
||||||
|
// POST /api/test/add-cash
|
||||||
|
// body { amount?: number } — a positive finite number when provided; defaults to €1M.
|
||||||
|
// Adds to account.cash (rounded to cents) and returns the full state snapshot.
|
||||||
|
|
||||||
|
const DEFAULT_AMOUNT = 1_000_000;
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
if (process.env.FLIGHT_TEST_HOOKS !== '1') {
|
||||||
|
return Response.json({ error: 'test hooks disabled' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: unknown = {};
|
||||||
|
try {
|
||||||
|
const text = await req.text();
|
||||||
|
body = text ? JSON.parse(text) : {};
|
||||||
|
} catch {
|
||||||
|
return Response.json({ error: 'invalid JSON body' }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (typeof body !== 'object' || body === null || Array.isArray(body)) {
|
||||||
|
return Response.json({ error: 'body must be a JSON object' }, { status: 400 });
|
||||||
|
}
|
||||||
|
const input = body as Record<string, unknown>;
|
||||||
|
for (const key of Object.keys(input)) {
|
||||||
|
if (key !== 'amount') {
|
||||||
|
return Response.json({ error: `unknown field "${key}"` }, { status: 400 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let amount = DEFAULT_AMOUNT;
|
||||||
|
if (input.amount !== undefined) {
|
||||||
|
if (typeof input.amount !== 'number' || !Number.isFinite(input.amount) || input.amount <= 0) {
|
||||||
|
return Response.json({ error: 'amount must be a positive finite number' }, { status: 400 });
|
||||||
|
}
|
||||||
|
amount = input.amount;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const inst = await getGameDb();
|
||||||
|
await tickNow(); // clock is current before the snapshot we return (same as siblings)
|
||||||
|
const account = (await inst.db.select().from(schema.account))[0];
|
||||||
|
if (!account) throw new Error('account row missing');
|
||||||
|
await inst.db.update(schema.account).set({ cash: Math.round((account.cash + amount) * 100) / 100 });
|
||||||
|
await persist(inst);
|
||||||
|
return Response.json(await getStateSnapshot());
|
||||||
|
} catch (err) {
|
||||||
|
console.error('test hook failed', err);
|
||||||
|
return Response.json({ error: 'test hook failed' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,11 +14,16 @@ 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.
|
||||||
|
// - special (v0.37.0) forces the package's special status ('priority' or null). When the body
|
||||||
|
// OMITS it, this hook forces null instead of letting generatePackage roll one — so existing
|
||||||
|
// e2e specs that assert exact reward/rep values stay deterministic.
|
||||||
// Returns 201 + the created package row.
|
// Returns 201 + the created package row.
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
export async function POST(req: Request) {
|
||||||
@@ -38,7 +43,7 @@ export async function POST(req: Request) {
|
|||||||
}
|
}
|
||||||
const input = body as Record<string, unknown>;
|
const input = body as Record<string, unknown>;
|
||||||
for (const key of Object.keys(input)) {
|
for (const key of Object.keys(input)) {
|
||||||
if (key !== 'originIcao' && key !== 'destIcao' && key !== 'deadlineOffsetMs') {
|
if (key !== 'originIcao' && key !== 'destIcao' && key !== 'deadlineOffsetMs' && key !== 'weightKg' && key !== 'special') {
|
||||||
return Response.json({ error: `unknown field "${key}"` }, { status: 400 });
|
return Response.json({ error: `unknown field "${key}"` }, { status: 400 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -50,6 +55,12 @@ 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 });
|
||||||
|
}
|
||||||
|
if (input.special !== undefined && input.special !== null && typeof input.special !== 'string') {
|
||||||
|
return Response.json({ error: 'special must be a string or null' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const inst = await getGameDb();
|
const inst = await getGameDb();
|
||||||
@@ -71,6 +82,10 @@ export async function POST(req: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const rng = mulberry32(randomInt(0, 2 ** 31));
|
const rng = mulberry32(randomInt(0, 2 ** 31));
|
||||||
|
// v0.38.0 (ISS-072): hook-created packages carry the LIVE account reputation — same job quality
|
||||||
|
// as engine spawns; never force a fixed value here.
|
||||||
|
const account = (await inst.db.select().from(schema.account))[0];
|
||||||
|
if (!account) throw new Error('account row missing');
|
||||||
const created = generatePackage({
|
const created = generatePackage({
|
||||||
id: randomUUID(),
|
id: randomUUID(),
|
||||||
airports,
|
airports,
|
||||||
@@ -79,6 +94,10 @@ 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,
|
||||||
|
// Omitted → force null (no roll) so existing e2e reward/rep assertions stay deterministic.
|
||||||
|
special: input.special === undefined ? null : (input.special as string | null),
|
||||||
|
reputation: account.reputation,
|
||||||
});
|
});
|
||||||
await inst.db.insert(schema.packages).values(created);
|
await inst.db.insert(schema.packages).values(created);
|
||||||
await persist(inst);
|
await persist(inst);
|
||||||
|
|||||||
@@ -64,7 +64,8 @@ export async function POST(req: Request) {
|
|||||||
lon: f.properties.lon,
|
lon: f.properties.lon,
|
||||||
}));
|
}));
|
||||||
const rng = mulberry32(randomInt(0, 2 ** 31));
|
const rng = mulberry32(randomInt(0, 2 ** 31));
|
||||||
const created = generatePackage({ id: randomUUID(), airports, rng, nowMs: sim.clockMs, deadlineOffsetMs: HOOK_DEADLINE_MS });
|
// special: null — the hook never rolls a special status (v0.37.0).
|
||||||
|
const created = generatePackage({ id: randomUUID(), airports, rng, nowMs: sim.clockMs, deadlineOffsetMs: HOOK_DEADLINE_MS, special: null });
|
||||||
await inst.db.insert(schema.packages).values(created);
|
await inst.db.insert(schema.packages).values(created);
|
||||||
await persist(inst);
|
await persist(inst);
|
||||||
return Response.json(created, { status: 201 });
|
return Response.json(created, { status: 201 });
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { getGameDb, persist } from '@/lib/db/client';
|
||||||
|
import * as schema from '@/lib/db/schema';
|
||||||
|
import { getStateSnapshot } from '@/lib/game/state';
|
||||||
|
import { tickNow } from '@/lib/sim/engine';
|
||||||
|
|
||||||
|
// TEST-ONLY hook (v0.39.0, ISS-069) — lets the e2e suite force a plane's health and/or shorten an
|
||||||
|
// active repair gate without flying thousands of km or waiting 3 sim days. Disabled unless
|
||||||
|
// FLIGHT_TEST_HOOKS=1 (set in docker-compose.yml for the e2e instance only); every other
|
||||||
|
// deployment 404s here. Sibling of /api/test/add-cash — same gate, same conventions.
|
||||||
|
//
|
||||||
|
// POST /api/test/set-health
|
||||||
|
// body { planeId: string, health?: number, repairUntilMs?: number }
|
||||||
|
// - planeId is required and must name an existing plane (404 otherwise);
|
||||||
|
// - at least one of health / repairUntilMs must be present;
|
||||||
|
// - health must be a finite number in [0, 100];
|
||||||
|
// - repairUntilMs sets repair_until_sim_time = clock + repairUntilMs (the expire-package
|
||||||
|
// near-term-gate precedent) so the next advancing tick flips a shortened repair back.
|
||||||
|
// Returns the full state snapshot after ticking.
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
if (process.env.FLIGHT_TEST_HOOKS !== '1') {
|
||||||
|
return Response.json({ error: 'test hooks disabled' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: unknown = {};
|
||||||
|
try {
|
||||||
|
const text = await req.text();
|
||||||
|
body = text ? JSON.parse(text) : {};
|
||||||
|
} catch {
|
||||||
|
return Response.json({ error: 'invalid JSON body' }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (typeof body !== 'object' || body === null || Array.isArray(body)) {
|
||||||
|
return Response.json({ error: 'body must be a JSON object' }, { status: 400 });
|
||||||
|
}
|
||||||
|
const input = body as Record<string, unknown>;
|
||||||
|
for (const key of Object.keys(input)) {
|
||||||
|
if (key !== 'planeId' && key !== 'health' && key !== 'repairUntilMs') {
|
||||||
|
return Response.json({ error: `unknown field "${key}"` }, { status: 400 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const planeId = input.planeId;
|
||||||
|
if (typeof planeId !== 'string' || planeId.length === 0) {
|
||||||
|
return Response.json({ error: 'planeId is required and must be a non-empty string' }, { status: 400 });
|
||||||
|
}
|
||||||
|
let health: number | undefined;
|
||||||
|
if (input.health !== undefined) {
|
||||||
|
if (typeof input.health !== 'number' || !Number.isFinite(input.health) || input.health < 0 || input.health > 100) {
|
||||||
|
return Response.json({ error: 'health must be a finite number in [0, 100]' }, { status: 400 });
|
||||||
|
}
|
||||||
|
health = input.health;
|
||||||
|
}
|
||||||
|
let repairUntilMs: number | undefined;
|
||||||
|
if (input.repairUntilMs !== undefined) {
|
||||||
|
if (typeof input.repairUntilMs !== 'number' || !Number.isFinite(input.repairUntilMs)) {
|
||||||
|
return Response.json({ error: 'repairUntilMs must be a finite number' }, { status: 400 });
|
||||||
|
}
|
||||||
|
repairUntilMs = input.repairUntilMs;
|
||||||
|
}
|
||||||
|
if (health === undefined && repairUntilMs === undefined) {
|
||||||
|
return Response.json({ error: 'at least one of health or repairUntilMs is required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const inst = await getGameDb();
|
||||||
|
const sim = await tickNow(); // clock is current before we set the gate (same as siblings)
|
||||||
|
const rows = await inst.db.select().from(schema.planes).where(eq(schema.planes.id, planeId));
|
||||||
|
if (!rows[0]) {
|
||||||
|
return Response.json({ error: `plane ${planeId} not found` }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const sets: Partial<Pick<schema.PlaneRow, 'health' | 'repairUntilSimTime'>> = {};
|
||||||
|
if (health !== undefined) sets.health = health;
|
||||||
|
if (repairUntilMs !== undefined) sets.repairUntilSimTime = Math.round(sim.clockMs + repairUntilMs);
|
||||||
|
await inst.db.update(schema.planes).set(sets).where(eq(schema.planes.id, planeId));
|
||||||
|
await persist(inst);
|
||||||
|
return Response.json(await getStateSnapshot());
|
||||||
|
} catch (err) {
|
||||||
|
console.error('test hook failed', err);
|
||||||
|
return Response.json({ error: 'test hook failed' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
+122
-17
@@ -48,6 +48,16 @@ body {
|
|||||||
color: #94a3b8;
|
color: #94a3b8;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ISS-080 (v0.40.0): the version tag links to the repo's CHANGELOG.md — keep it subtle, like the rest of the header. */
|
||||||
|
.app-header a {
|
||||||
|
color: #7dd3fc;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-header a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
/* Style switcher — kept subtle, like the version tag. */
|
/* Style switcher — kept subtle, like the version tag. */
|
||||||
.style-switch {
|
.style-switch {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
@@ -103,11 +113,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%;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -214,6 +224,26 @@ body {
|
|||||||
color: #bfdbfe;
|
color: #bfdbfe;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* v0.39.0 (ISS-069): maintenance pills on fleet rows — broken = red, in repair = amber. */
|
||||||
|
.status-broken {
|
||||||
|
background: rgba(239, 68, 68, 0.25);
|
||||||
|
color: #fecaca;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-repairing {
|
||||||
|
background: rgba(245, 158, 11, 0.25);
|
||||||
|
color: #fde68a;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* v0.37.0 (ISS-073): special-status pill on package rows — sky-blue accent, modeled on .plane-status. */
|
||||||
|
.special-badge {
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: rgba(56, 189, 248, 0.2);
|
||||||
|
color: #bae6fd;
|
||||||
|
}
|
||||||
|
|
||||||
.fuel-bar {
|
.fuel-bar {
|
||||||
height: 4px;
|
height: 4px;
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
@@ -228,6 +258,26 @@ body {
|
|||||||
background: #34d399;
|
background: #34d399;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* v0.39.0 (ISS-069): health bar — modeled on .fuel-bar, amber fill. */
|
||||||
|
.health-bar {
|
||||||
|
height: 4px;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: rgba(148, 163, 184, 0.25);
|
||||||
|
margin: 2px 0 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.health-bar > div {
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: #f59e0b;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* v0.39.0 (ISS-069): the per-plane Repair button sits under the readout row. */
|
||||||
|
.repair-btn {
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
.plane-sub {
|
.plane-sub {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
@@ -376,17 +426,10 @@ body {
|
|||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* v0.12.0 — on-board blocks (per plane); moved to the left pane in ISS-021 (v0.20.0) */
|
/* ISS-054 (v0.33.0) — per-plane cargo rows nested inside the Fleet list's plane cards: two lines,
|
||||||
.onboard-block {
|
a full-width fly-to button then refuel + abandon side by side below it; long destination names
|
||||||
background: rgba(30, 41, 59, 0.6);
|
truncate with an ellipsis. Transparent sub-rows with a top border under the card header. */
|
||||||
border-radius: 6px;
|
.plane-row .contract-row {
|
||||||
padding: 8px;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ISS-028 (v0.20.0 part 2) — on-board cargo rows are two lines: a full-width fly-to button,
|
|
||||||
then refuel + abandon side by side below it; long destination names truncate with an ellipsis. */
|
|
||||||
.onboard-block .contract-row {
|
|
||||||
background: transparent;
|
background: transparent;
|
||||||
padding: 6px 0 0;
|
padding: 6px 0 0;
|
||||||
border-top: 1px solid rgba(148, 163, 184, 0.2);
|
border-top: 1px solid rgba(148, 163, 184, 0.2);
|
||||||
@@ -395,7 +438,7 @@ body {
|
|||||||
gap: 5px;
|
gap: 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.onboard-block .contract-row .contract-sub {
|
.plane-row .contract-row .contract-sub {
|
||||||
margin-top: 0; /* row spacing comes from the container's gap */
|
margin-top: 0; /* row spacing comes from the container's gap */
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -416,12 +459,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;
|
||||||
/* 280px right pane + 12px inset — sits flush against the map's right edge, clear of the right pane */
|
/* 360px right pane + 12px inset — sits flush against the map's right edge, clear of the right pane */
|
||||||
right: 292px;
|
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;
|
||||||
@@ -483,3 +550,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;
|
||||||
|
}
|
||||||
|
|||||||
+208
-152
@@ -3,13 +3,13 @@
|
|||||||
import { useCallback, useEffect, useRef, useState, type ChangeEvent } from 'react';
|
import { useCallback, useEffect, useRef, useState, type ChangeEvent } from 'react';
|
||||||
import type { GeoJsonFeatureCollection } from '@/lib/airports';
|
import type { GeoJsonFeatureCollection } from '@/lib/airports';
|
||||||
import type { PlaneState, StateSnapshot } from '@/lib/game/state';
|
import type { PlaneState, StateSnapshot } from '@/lib/game/state';
|
||||||
import { PLANE_TYPES } from '@/lib/game/catalog';
|
import { FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT, PLANE_TYPES, repRate } from '@/lib/game/catalog';
|
||||||
import { abandonPenalty } from '@/lib/game/penalty';
|
import { abandonPenalty } from '@/lib/game/penalty';
|
||||||
import { formatCash, formatDeadlineRelative, formatFuel, formatSimClock } from '@/lib/format';
|
import { formatCash, formatDeadlineRelative, formatFuel, formatSimClock, formatSpecialLabel } from '@/lib/format';
|
||||||
import { TIME_SCALES, scaleToSlider, sliderToScale } from '@/lib/timeScale';
|
import { TIME_SCALES, scaleToSlider, sliderToScale } from '@/lib/timeScale';
|
||||||
import { useGameAction } from '@/lib/useGameAction';
|
import { useGameAction } from '@/lib/useGameAction';
|
||||||
import { useSimClock } from '@/lib/useSimClock';
|
import { useSimClock } from '@/lib/useSimClock';
|
||||||
import { slotsUsedFor } from './RightPane';
|
import { cargoWeightFor } from './RightPane';
|
||||||
import type { HoverTarget } from './hoverTypes';
|
import type { HoverTarget } from './hoverTypes';
|
||||||
|
|
||||||
interface LeftPaneProps {
|
interface LeftPaneProps {
|
||||||
@@ -19,7 +19,7 @@ interface LeftPaneProps {
|
|||||||
// The shell's single-poll refresh — called after a sim-control POST so the pane updates immediately.
|
// The shell's single-poll refresh — called after a sim-control POST so the pane updates immediately.
|
||||||
onRefresh: () => void;
|
onRefresh: () => void;
|
||||||
// ISS-021 (v0.20.0): push the full snapshot returned by an action endpoint into the shared shell
|
// ISS-021 (v0.20.0): push the full snapshot returned by an action endpoint into the shared shell
|
||||||
// state — needed now that the On board section's refuel/fly/abandon actions live in this pane.
|
// state — needed now that the per-plane cargo rows' refuel/fly/abandon actions live in this pane.
|
||||||
applySnapshot: (snapshot: StateSnapshot) => void;
|
applySnapshot: (snapshot: StateSnapshot) => void;
|
||||||
// ISS-002 (v0.15.0): report fleet-row hovers to the shell so the map can highlight the plane.
|
// ISS-002 (v0.15.0): report fleet-row hovers to the shell so the map can highlight the plane.
|
||||||
onHover: (target: HoverTarget | null) => void;
|
onHover: (target: HoverTarget | null) => void;
|
||||||
@@ -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
|
||||||
@@ -93,7 +92,7 @@ export async function retrySimPost(
|
|||||||
export default function LeftPane({ state, loading, error, onRefresh, applySnapshot, onHover, onError }: LeftPaneProps) {
|
export default function LeftPane({ state, loading, error, onRefresh, applySnapshot, onHover, onError }: LeftPaneProps) {
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
// ISS-021 (v0.20.0): shared action runner for the On board section (moved here from RightPane).
|
// ISS-021 (v0.20.0): shared action runner for the per-plane cargo/queue actions (moved here from RightPane).
|
||||||
const { runAction, busy: actionsBusy, actionError } = useGameAction(state, applySnapshot, onError);
|
const { runAction, busy: actionsBusy, actionError } = useGameAction(state, applySnapshot, onError);
|
||||||
|
|
||||||
// ISS-023 (v0.21.0): Relocate selections — moved here from the right-pane market row; same
|
// ISS-023 (v0.21.0): Relocate selections — moved here from the right-pane market row; same
|
||||||
@@ -196,29 +195,34 @@ export default function LeftPane({ state, loading, error, onRefresh, applySnapsh
|
|||||||
const paused = state?.simState.paused ?? false;
|
const paused = state?.simState.paused ?? false;
|
||||||
const timeScale = state?.simState.timeScale ?? 1;
|
const timeScale = state?.simState.timeScale ?? 1;
|
||||||
|
|
||||||
// On board (v0.12.0, moved from the right pane in ISS-021 v0.20.0): every plane that carries
|
// Snapshot clock — feeds the deadline/penalty previews in the per-plane cargo rows.
|
||||||
// cargo or is mid-flight gets a block — its loaded/expired packages with fly-to-destination +
|
|
||||||
// abandon controls, and flying planes show their current leg.
|
|
||||||
const clockMs = state?.simState.clockMs ?? 0;
|
const clockMs = state?.simState.clockMs ?? 0;
|
||||||
const onboard = state ? state.planes.filter((p) => p.status === 'flying' || slotsUsedFor(state, p.id) > 0) : [];
|
|
||||||
|
|
||||||
// ISS-023 (v0.21.0): Relocate — any GROUNDED plane can be repositioned to any airport via the
|
// ISS-023 (v0.21.0): Relocate — any plane can be repositioned to any airport via the existing fly
|
||||||
// existing fly endpoint (pure repositioning, no package needed since v0.12.0). Moved here from
|
// endpoint (pure repositioning, no package needed since v0.12.0). Moved here from the right-pane
|
||||||
// the right-pane market row; with state null both fall back to null and the section isn't rendered.
|
// market row; with state null both fall back to null and the section isn't rendered.
|
||||||
const grounded = state ? state.planes.filter((p) => p.status === 'available') : [];
|
// ISS-053 (v0.32.0): widened from grounded-only to ALL planes — a flying plane's relocation is
|
||||||
const relocatePlane = grounded.find((p) => p.id === relocatePlaneId) ?? grounded[0] ?? null;
|
// queued instead of started immediately (see relocate-go below). `grounded` stays for Refuel.
|
||||||
|
const relocatable = state ? state.planes : [];
|
||||||
|
const relocatePlane = relocatable.find((p) => p.id === relocatePlaneId) ?? relocatable[0] ?? null;
|
||||||
const relocateDest =
|
const relocateDest =
|
||||||
airportOptions === null ? null : airportOptions.find((a) => a.icao === relocateDestIcao) ?? airportOptions[0] ?? null;
|
airportOptions === null ? null : airportOptions.find((a) => a.icao === relocateDestIcao) ?? airportOptions[0] ?? null;
|
||||||
|
|
||||||
// ISS-035 (v0.23.0): Refuel-to-full — any GROUNDED plane with fuel below max can be topped up via the
|
// ISS-035 (v0.23.0): Refuel-to-full — any GROUNDED plane with fuel below max can be topped up via the
|
||||||
// existing refuel endpoint (no body → server defaults to mode 'full'). Moved here from the right-pane
|
// existing refuel endpoint (no body → server defaults to mode 'full'). Moved here from the right-pane
|
||||||
// market row; reuses `grounded` above, and with state null falls back to null.
|
// market row; grounded-only, and with state null falls back to null.
|
||||||
|
const grounded = relocatable.filter((p) => p.status === 'available');
|
||||||
const refuelable = grounded.filter((p) => p.fuel < p.maxFuel);
|
const refuelable = grounded.filter((p) => p.fuel < p.maxFuel);
|
||||||
const refuelPlane =
|
const refuelPlane =
|
||||||
(state?.planes.find((p) => p.id === refuelPlaneId && p.status === 'available' && p.fuel < p.maxFuel)) ??
|
(state?.planes.find((p) => p.id === refuelPlaneId && p.status === 'available' && p.fuel < p.maxFuel)) ??
|
||||||
refuelable[0] ??
|
refuelable[0] ??
|
||||||
null;
|
null;
|
||||||
const refuelCost = refuelPlane && state ? (refuelPlane.maxFuel - refuelPlane.fuel) * state.fuelPricePerL : null;
|
// v0.38.0 (ISS-072): rep-scaled preview — same formula as the server's refuelPlane 'full' mode.
|
||||||
|
const fuelDiscountRate = state ? repRate(state.account.reputation, FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT) : 0;
|
||||||
|
const refuelCost =
|
||||||
|
refuelPlane && state
|
||||||
|
? Math.round((refuelPlane.maxFuel - refuelPlane.fuel) * state.fuelPricePerL * (1 - fuelDiscountRate) * 100) / 100
|
||||||
|
: 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
|
||||||
@@ -334,138 +338,183 @@ export default function LeftPane({ state, loading, error, onRefresh, applySnapsh
|
|||||||
<p className="pane-muted">No planes.</p>
|
<p className="pane-muted">No planes.</p>
|
||||||
) : (
|
) : (
|
||||||
<ul data-testid="fleet-list" className="fleet-list">
|
<ul data-testid="fleet-list" className="fleet-list">
|
||||||
{state.planes.map((p) => (
|
{state.planes.map((p) => {
|
||||||
// ISS-002: hovering a fleet row highlights the plane on the map.
|
// ISS-054 (v0.33.0): the merged per-plane block — status/fuel/loadout plus the former
|
||||||
<li
|
// On board section's cargo rows and queue legs, so all plane info lives in one place.
|
||||||
key={p.id}
|
const cargo = state.packages.filter(
|
||||||
data-testid={`plane-${p.id}`}
|
(pkg) => pkg.planeId === p.id && (pkg.status === 'loaded' || pkg.status === 'expired'),
|
||||||
className="plane-row"
|
);
|
||||||
onMouseEnter={() => onHover({ kind: 'plane', id: p.id })}
|
return (
|
||||||
onMouseLeave={() => onHover(null)}
|
// ISS-002: hovering a fleet row highlights the plane on the map. Handlers live only on
|
||||||
>
|
// the outer <li> — React's enter/leave don't fire when moving to a child, so this covers
|
||||||
<div className="plane-main">
|
// the whole block without flicker between sub-elements; cargo rows override with their
|
||||||
<span className="plane-model">{PLANE_TYPES[p.model]?.name ?? p.model}</span>
|
// own package hover (below).
|
||||||
<span className={`plane-status status-${p.status}`}>{p.status}</span>
|
<li
|
||||||
</div>
|
key={p.id}
|
||||||
<div className="fuel-bar" aria-hidden="true">
|
data-testid={`plane-${p.id}`}
|
||||||
<div style={{ width: `${Math.max(0, Math.min(100, (p.fuel / p.maxFuel) * 100))}%` }} />
|
className="plane-row"
|
||||||
</div>
|
onMouseEnter={() => onHover({ kind: 'plane', id: p.id })}
|
||||||
<div className="plane-sub">
|
onMouseLeave={() => onHover(null)}
|
||||||
<span data-testid={`plane-fuel-${p.id}`}>{formatFuel(p.fuel, p.maxFuel)}</span>
|
>
|
||||||
<span data-testid={`plane-loadout-${p.id}`}>{loadoutFor(state, p)}</span>
|
<div className="plane-main">
|
||||||
<span className="plane-loc">{planeLocation(p)}</span>
|
<span className="plane-model">{PLANE_TYPES[p.model]?.name ?? p.model}</span>
|
||||||
</div>
|
{p.status === 'flying' ? (
|
||||||
</li>
|
<>
|
||||||
))}
|
<span className="plane-status status-flying">in flight</span>
|
||||||
|
{p.destName && (
|
||||||
|
<span data-testid={`plane-leg-${p.id}`} className="contract-route">
|
||||||
|
{p.locationName} → {p.destName}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span className="plane-status status-available">{p.locationIcao}</span>
|
||||||
|
{/* v0.39.0 (ISS-069): maintenance pills — "in repair" while the gate runs, "broken" at zero health. */}
|
||||||
|
{p.status === 'repairing' && (
|
||||||
|
<span data-testid={`plane-repairing-${p.id}`} className="plane-status status-repairing">in repair</span>
|
||||||
|
)}
|
||||||
|
{p.health <= 0 && (
|
||||||
|
<span data-testid={`plane-broken-${p.id}`} className="plane-status status-broken">broken</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="fuel-bar" aria-hidden="true">
|
||||||
|
<div style={{ width: `${Math.max(0, Math.min(100, (p.fuel / p.maxFuel) * 100))}%` }} />
|
||||||
|
</div>
|
||||||
|
{/* v0.39.0 (ISS-069): health bar — sibling of the fuel bar, amber fill. */}
|
||||||
|
<div className="health-bar" aria-hidden="true">
|
||||||
|
<div style={{ width: `${Math.max(0, Math.min(100, p.health))}%` }} />
|
||||||
|
</div>
|
||||||
|
<div className="plane-sub">
|
||||||
|
<span data-testid={`plane-fuel-${p.id}`}>{formatFuel(p.fuel, p.maxFuel)}</span>
|
||||||
|
{/* v0.39.0 (ISS-069): health percentage next to the fuel readout. */}
|
||||||
|
<span data-testid={`plane-health-${p.id}`}>{Math.round(p.health)}%</span>
|
||||||
|
<span data-testid={`plane-loadout-${p.id}`}>{loadoutFor(state, p)}</span>
|
||||||
|
<span className="plane-loc">{planeLocation(p)}</span>
|
||||||
|
</div>
|
||||||
|
{/* v0.39.0 (ISS-069): repair — grounded plane with worn health; flat cost is server-side only (no preview). */}
|
||||||
|
{p.status === 'available' && p.health < 100 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid={`repair-${p.id}`}
|
||||||
|
className="pane-btn repair-btn"
|
||||||
|
disabled={actionsBusy}
|
||||||
|
onClick={() => void runAction(`/api/planes/${p.id}/repair`)}
|
||||||
|
>
|
||||||
|
Repair
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{cargo.map((pkg) => {
|
||||||
|
const penalty = abandonPenalty(pkg, clockMs);
|
||||||
|
return (
|
||||||
|
// ISS-025 (v0.22.0): hovering a loaded package draws its full origin→dest trace on the map —
|
||||||
|
// same as job-list rows — and keeps this plane's halo lit via planeId. Leaving the row restores
|
||||||
|
// the block's plain plane highlight; leaving the whole block still ends at null (the child's
|
||||||
|
// mouseleave fires before the parent's).
|
||||||
|
<div
|
||||||
|
key={pkg.id}
|
||||||
|
data-testid={`onboard-package-${pkg.id}`}
|
||||||
|
className="contract-row"
|
||||||
|
onMouseEnter={() => onHover({ kind: 'package', originIcao: pkg.originIcao, destIcao: pkg.destIcao, planeId: p.id })}
|
||||||
|
onMouseLeave={() => onHover({ kind: 'plane', id: p.id })}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid={`fly-to-${pkg.id}`}
|
||||||
|
className="pane-btn onboard-fly-btn"
|
||||||
|
disabled={actionsBusy}
|
||||||
|
// ISS-053 (v0.32.0): a flying plane can't start another leg — the click queues it instead,
|
||||||
|
// so "Fly to …" works from any state and the queue needs no dedicated input.
|
||||||
|
onClick={() => void runAction(`/api/planes/${p.id}/${p.status === 'flying' ? 'queue' : 'fly'}`, { destIcao: pkg.destIcao })}
|
||||||
|
>
|
||||||
|
{/* ISS-081 (v0.40.0): ICAO tags instead of full airport names. */}
|
||||||
|
<span className="onboard-fly-label" title={`Fly to ${pkg.destIcao}`}>
|
||||||
|
Fly to {pkg.destIcao}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<div className="onboard-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid={`refuel-for-${pkg.id}`}
|
||||||
|
className="pane-btn"
|
||||||
|
disabled={actionsBusy || p.status !== 'available'}
|
||||||
|
onClick={() => void runAction(`/api/planes/${p.id}/refuel`, { mode: 'forFlight', packageId: pkg.id })}
|
||||||
|
>
|
||||||
|
Refuel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid={`abandon-${pkg.id}`}
|
||||||
|
className="pane-btn"
|
||||||
|
disabled={actionsBusy || p.status !== 'available'}
|
||||||
|
onClick={() => void runAction(`/api/packages/${pkg.id}/abandon`)}
|
||||||
|
>
|
||||||
|
Abandon{' '}
|
||||||
|
<span data-testid={`abandon-penalty-${pkg.id}`}>−{formatCash(penalty)}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="contract-sub">
|
||||||
|
{/* ISS-081 (v0.40.0): ICAO tags instead of full airport names. */}
|
||||||
|
<span data-testid={`onboard-route-${pkg.id}`} className="contract-route">
|
||||||
|
{pkg.originIcao} → {pkg.destIcao}
|
||||||
|
</span>
|
||||||
|
{/* v0.37.0 (ISS-073): special-status badge — rendered only when the package has one. */}
|
||||||
|
{pkg.special && (
|
||||||
|
<span data-testid={`package-special-${pkg.id}`} className="special-badge">
|
||||||
|
{formatSpecialLabel(pkg.special)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span>{formatCash(pkg.reward)}</span>
|
||||||
|
{pkg.status === 'expired' ? (
|
||||||
|
<span data-testid={`onboard-expired-${pkg.id}`}>expired</span>
|
||||||
|
) : (
|
||||||
|
<span>{formatDeadlineRelative(pkg.deadlineSimTime, clockMs)}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{/* ISS-043 (v0.26.0): flight queue — the not-yet-started legs with cancel.
|
||||||
|
ISS-053 (v0.32.0): the dedicated add input is gone — fly-to and Relocate post to
|
||||||
|
/queue while a plane is flying, so no separate entry point is needed.
|
||||||
|
ISS-054 (v0.33.0): rendered only when non-empty. */}
|
||||||
|
{p.queue.length > 0 && (
|
||||||
|
<div className="queue-subsection">
|
||||||
|
<span className="queue-heading">Queue</span>
|
||||||
|
{/* ISS-055 (v0.31.0): testids scoped by plane id — with two blocks the unscoped ids
|
||||||
|
duplicated in the DOM and broke Playwright strict mode. */}
|
||||||
|
{p.queue.map((icao, i) => (
|
||||||
|
<div key={`${icao}-${i}`} data-testid={`queue-leg-${p.id}-${i}`} className="queue-row">
|
||||||
|
<span className="contract-route">
|
||||||
|
{airportOptions?.find((a) => a.icao === icao)?.name ?? icao} ({icao})
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid={`queue-cancel-${p.id}-${i}`}
|
||||||
|
className="pane-btn"
|
||||||
|
disabled={actionsBusy}
|
||||||
|
onClick={() => void runAction(`/api/planes/${p.id}/queue/cancel`, { index: i })}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* ISS-021 (v0.20.0): moved here from the right pane — between Fleet and Time. */}
|
|
||||||
<section className="pane-section">
|
|
||||||
<h3 className="pane-heading">On board</h3>
|
|
||||||
{onboard.length === 0 ? (
|
|
||||||
<p className="pane-muted">Nothing on board.</p>
|
|
||||||
) : (
|
|
||||||
onboard.map((plane) => {
|
|
||||||
const cargo = state.packages.filter(
|
|
||||||
(pkg) => pkg.planeId === plane.id && (pkg.status === 'loaded' || pkg.status === 'expired'),
|
|
||||||
);
|
|
||||||
return (
|
|
||||||
// ISS-002: hovering the on-board block (including its cargo rows) highlights the plane.
|
|
||||||
// Handlers live only on the outer div — React's enter/leave don't fire when moving to a
|
|
||||||
// child, so this covers the whole block without flicker between sub-elements.
|
|
||||||
<div
|
|
||||||
key={plane.id}
|
|
||||||
data-testid={`onboard-${plane.id}`}
|
|
||||||
className="onboard-block"
|
|
||||||
onMouseEnter={() => onHover({ kind: 'plane', id: plane.id })}
|
|
||||||
onMouseLeave={() => onHover(null)}
|
|
||||||
>
|
|
||||||
<div className="contract-main">
|
|
||||||
<span className="plane-model">{PLANE_TYPES[plane.model]?.name ?? plane.model}</span>
|
|
||||||
{plane.status === 'flying' ? (
|
|
||||||
<>
|
|
||||||
<span className="plane-status status-flying">in flight</span>
|
|
||||||
{plane.destName && (
|
|
||||||
<span data-testid={`plane-leg-${plane.id}`} className="contract-route">
|
|
||||||
{plane.locationName} → {plane.destName}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<span className="plane-status status-available">{plane.locationIcao}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{cargo.map((pkg) => {
|
|
||||||
const penalty = abandonPenalty(pkg, clockMs);
|
|
||||||
return (
|
|
||||||
// ISS-025 (v0.22.0): hovering a loaded package draws its full origin→dest trace on the map —
|
|
||||||
// same as job-list rows — and keeps this plane's halo lit via planeId. Leaving the row restores
|
|
||||||
// the block's plain plane highlight; leaving the whole block still ends at null (the child's
|
|
||||||
// mouseleave fires before the parent's).
|
|
||||||
<div
|
|
||||||
key={pkg.id}
|
|
||||||
data-testid={`onboard-package-${pkg.id}`}
|
|
||||||
className="contract-row"
|
|
||||||
onMouseEnter={() => onHover({ kind: 'package', originIcao: pkg.originIcao, destIcao: pkg.destIcao, planeId: plane.id })}
|
|
||||||
onMouseLeave={() => onHover({ kind: 'plane', id: plane.id })}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
data-testid={`fly-to-${pkg.id}`}
|
|
||||||
className="pane-btn onboard-fly-btn"
|
|
||||||
disabled={actionsBusy || plane.status !== 'available'}
|
|
||||||
onClick={() => void runAction(`/api/planes/${plane.id}/fly`, { destIcao: pkg.destIcao })}
|
|
||||||
>
|
|
||||||
<span className="onboard-fly-label" title={`Fly to ${pkg.destName}`}>
|
|
||||||
Fly to {pkg.destName}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
<div className="onboard-actions">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
data-testid={`refuel-for-${pkg.id}`}
|
|
||||||
className="pane-btn"
|
|
||||||
disabled={actionsBusy || plane.status !== 'available'}
|
|
||||||
onClick={() => void runAction(`/api/planes/${plane.id}/refuel`, { mode: 'forFlight', packageId: pkg.id })}
|
|
||||||
>
|
|
||||||
Refuel
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
data-testid={`abandon-${pkg.id}`}
|
|
||||||
className="pane-btn"
|
|
||||||
disabled={actionsBusy || plane.status !== 'available'}
|
|
||||||
onClick={() => void runAction(`/api/packages/${pkg.id}/abandon`)}
|
|
||||||
>
|
|
||||||
Abandon{' '}
|
|
||||||
<span data-testid={`abandon-penalty-${pkg.id}`}>−{formatCash(penalty)}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="contract-sub">
|
|
||||||
<span className="contract-route">{pkg.originName} → {pkg.destName}</span>
|
|
||||||
<span>{formatCash(pkg.reward)}</span>
|
|
||||||
{pkg.status === 'expired' ? (
|
|
||||||
<span data-testid={`onboard-expired-${pkg.id}`}>expired</span>
|
|
||||||
) : (
|
|
||||||
<span>{formatDeadlineRelative(pkg.deadlineSimTime, clockMs)}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* ISS-023 (v0.21.0): moved here from the right-pane market row — it belongs with
|
{/* ISS-023 (v0.21.0): moved here from the right-pane market row — it belongs with
|
||||||
everything else about our planes, between On board and Time. */}
|
everything else about our planes, between Fleet and Time. */}
|
||||||
{/* ISS-027 (v0.22.0): hovering the section previews the planned relocation on the map —
|
{/* ISS-027 (v0.22.0): hovering the section previews the planned relocation on the map —
|
||||||
dashed trace from the selected plane's airport to the destination + a ring on the target.
|
dashed trace from the selected plane's airport to the destination + a ring on the target.
|
||||||
With no grounded plane or no loaded airport options there is nothing to preview → null. */}
|
With no planes or no loaded airport options there is nothing to preview → null. */}
|
||||||
<section
|
<section
|
||||||
className="pane-section"
|
className="pane-section"
|
||||||
data-testid="relocate-section"
|
data-testid="relocate-section"
|
||||||
@@ -479,8 +528,9 @@ export default function LeftPane({ state, loading, error, onRefresh, applySnapsh
|
|||||||
onMouseLeave={() => onHover(null)}
|
onMouseLeave={() => onHover(null)}
|
||||||
>
|
>
|
||||||
<h3 className="pane-heading">Relocate</h3>
|
<h3 className="pane-heading">Relocate</h3>
|
||||||
{/* ISS-010 (v0.18.0): relocate a grounded plane — the fly endpoint already allows pure
|
{/* ISS-010 (v0.18.0): relocate a plane — the fly endpoint already allows pure
|
||||||
repositioning, this row just exposes it for planes with nothing on board. */}
|
repositioning, this row just exposes it for planes with nothing on board.
|
||||||
|
ISS-053 (v0.32.0): lists ALL planes; a flying one's relocation is queued below. */}
|
||||||
<div className="market-row market-row-relocate">
|
<div className="market-row market-row-relocate">
|
||||||
<select
|
<select
|
||||||
data-testid="relocate-plane"
|
data-testid="relocate-plane"
|
||||||
@@ -488,7 +538,7 @@ export default function LeftPane({ state, loading, error, onRefresh, applySnapsh
|
|||||||
value={relocatePlane?.id ?? ''}
|
value={relocatePlane?.id ?? ''}
|
||||||
onChange={(e) => setRelocatePlaneId(e.target.value || null)}
|
onChange={(e) => setRelocatePlaneId(e.target.value || null)}
|
||||||
>
|
>
|
||||||
{grounded.map((p) => (
|
{relocatable.map((p) => (
|
||||||
<option key={p.id} value={p.id}>
|
<option key={p.id} value={p.id}>
|
||||||
{PLANE_TYPES[p.model]?.name ?? p.model} — {p.locationIcao}
|
{PLANE_TYPES[p.model]?.name ?? p.model} — {p.locationIcao}
|
||||||
</option>
|
</option>
|
||||||
@@ -511,9 +561,11 @@ export default function LeftPane({ state, loading, error, onRefresh, applySnapsh
|
|||||||
data-testid="relocate-go"
|
data-testid="relocate-go"
|
||||||
className="pane-btn"
|
className="pane-btn"
|
||||||
disabled={actionsBusy || relocatePlane === null || relocateDest === null}
|
disabled={actionsBusy || relocatePlane === null || relocateDest === null}
|
||||||
|
// ISS-053 (v0.32.0): a flying plane can't start another leg — queue it instead, so the
|
||||||
|
// relocation takes off when the current one lands.
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (relocatePlane && relocateDest) {
|
if (relocatePlane && relocateDest) {
|
||||||
void runAction(`/api/planes/${relocatePlane.id}/fly`, { destIcao: relocateDest.icao });
|
void runAction(`/api/planes/${relocatePlane.id}/${relocatePlane.status === 'flying' ? 'queue' : 'fly'}`, { destIcao: relocateDest.icao });
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -527,7 +579,7 @@ export default function LeftPane({ state, loading, error, onRefresh, applySnapsh
|
|||||||
<section className="pane-section" data-testid="refuel-section">
|
<section className="pane-section" data-testid="refuel-section">
|
||||||
<h3 className="pane-heading">Refuel</h3>
|
<h3 className="pane-heading">Refuel</h3>
|
||||||
{/* Moved verbatim from the right-pane Market section — same testids/labels; the per-package
|
{/* Moved verbatim from the right-pane Market section — same testids/labels; the per-package
|
||||||
"Refuel" button in On board (mode 'forFlight') is a different feature and stays there. */}
|
"Refuel" button in the fleet rows (mode 'forFlight') is a different feature and stays there. */}
|
||||||
<div className="market-row">
|
<div className="market-row">
|
||||||
{refuelable.length === 0 ? (
|
{refuelable.length === 0 ? (
|
||||||
<span data-testid="market-refuel-none" className="pane-muted">
|
<span data-testid="market-refuel-none" className="pane-muted">
|
||||||
@@ -550,6 +602,10 @@ export default function LeftPane({ state, loading, error, onRefresh, applySnapsh
|
|||||||
<span data-testid="market-refuel-cost" className="market-preview">
|
<span data-testid="market-refuel-cost" className="market-preview">
|
||||||
{refuelCost !== null ? formatCash(refuelCost) : ''}
|
{refuelCost !== null ? formatCash(refuelCost) : ''}
|
||||||
</span>
|
</span>
|
||||||
|
{/* v0.38.0 (ISS-072): rep discount hint — only when the account actually gets one. */}
|
||||||
|
{fuelDiscountRate > 0 && (
|
||||||
|
<span data-testid="refuel-discount" className="special-badge">−{Math.round(fuelDiscountRate * 100)}%</span>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
|
|||||||
+34
-2
@@ -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, hoverTraceGeoJson, 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,6 +99,11 @@ 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(() => {
|
||||||
@@ -357,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) });
|
||||||
}
|
}
|
||||||
@@ -507,7 +537,9 @@ export default function FlightMap({ state, hovered, applySnapshot }: MapProps) {
|
|||||||
<div className="map-wrap">
|
<div className="map-wrap">
|
||||||
<header data-testid="app-header" className="app-header">
|
<header data-testid="app-header" className="app-header">
|
||||||
<h1>Flight Ops</h1>
|
<h1>Flight Ops</h1>
|
||||||
<small>v{pkg.version}</small>
|
<a data-testid="app-version" href="https://git.kkhost.pl/OpenCode/Flight-Ops/src/branch/master/CHANGELOG.md" target="_blank" rel="noreferrer">
|
||||||
|
<small>v{pkg.version}</small>
|
||||||
|
</a>
|
||||||
<select
|
<select
|
||||||
data-testid="style-switch"
|
data-testid="style-switch"
|
||||||
className="style-switch"
|
className="style-switch"
|
||||||
|
|||||||
+117
-19
@@ -2,8 +2,9 @@
|
|||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import type { PackageState, PlaneState, StateSnapshot } from '@/lib/game/state';
|
import type { PackageState, PlaneState, StateSnapshot } from '@/lib/game/state';
|
||||||
import { PLANE_TYPES } from '@/lib/game/catalog';
|
import { MAX_PLANE_DISCOUNT, PLANE_DISCOUNT_PER_REP, PLANE_TYPES, repRate, SELL_FRACTION } from '@/lib/game/catalog';
|
||||||
import { formatCash, formatDeadlineRelative } from '@/lib/format';
|
import { maxRangeKm } from '@/lib/sim/movement';
|
||||||
|
import { formatCargo, formatCash, formatDeadlineRelative, formatSpecialLabel } from '@/lib/format';
|
||||||
import { useGameAction } from '@/lib/useGameAction';
|
import { useGameAction } from '@/lib/useGameAction';
|
||||||
import type { HoverTarget } from './hoverTypes';
|
import type { HoverTarget } from './hoverTypes';
|
||||||
|
|
||||||
@@ -23,31 +24,34 @@ 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;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load controls for one open package: a single Load button when exactly one plane
|
// Load controls for one open package: a single Load button when exactly one plane
|
||||||
// is eligible (it acts on that plane directly), a small plane picker + Load when
|
// is eligible (it acts on that plane directly), a small plane picker + Load when
|
||||||
// several are, and a disabled button with the reason otherwise. Each row owns its
|
// several are, and a disabled button with the reason otherwise. Each row owns its
|
||||||
// selection in local state and defaults to the "Select a plane…" placeholder —
|
// selection in local state; ISS-055 (v0.31.0): the multi-plane path PRESELECTS the
|
||||||
// never preselecting — so rows stay visibly independent (v0.10.1 pattern).
|
// first eligible plane so Load is one-click out of the box (matching the single-plane
|
||||||
|
// UX) while the picker still allows choosing a different plane.
|
||||||
function LoadControls({
|
function LoadControls({
|
||||||
pkg,
|
pkg,
|
||||||
eligible,
|
eligible,
|
||||||
@@ -60,8 +64,9 @@ function LoadControls({
|
|||||||
onLoad: (planeId: string) => void;
|
onLoad: (planeId: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
// The user's pick while it stays eligible, else nothing (placeholder).
|
// ISS-055 (v0.31.0): the user's pick while it stays eligible, else the FIRST eligible plane —
|
||||||
const effective = eligible.find((p) => p.id === selectedId) ?? null;
|
// Load is enabled and one-click whenever ≥1 plane qualifies; the picker still allows a different pick.
|
||||||
|
const effective = eligible.find((p) => p.id === selectedId) ?? eligible[0] ?? null;
|
||||||
|
|
||||||
if (eligible.length === 1) {
|
if (eligible.length === 1) {
|
||||||
return (
|
return (
|
||||||
@@ -117,6 +122,9 @@ export default function RightPane({ state, loading, applySnapshot, onHover, onEr
|
|||||||
const [buyModelId, setBuyModelId] = useState<string | null>(null);
|
const [buyModelId, setBuyModelId] = useState<string | null>(null);
|
||||||
const [sellPlaneId, setSellPlaneId] = useState<string | null>(null);
|
const [sellPlaneId, setSellPlaneId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// v0.28.0 (ISS-047): Jobs / Planes market tabs — the inactive tab's content unmounts.
|
||||||
|
const [activeTab, setActiveTab] = useState<'jobs' | 'planes'>('jobs');
|
||||||
|
|
||||||
// ISS-023 (v0.21.0): the Relocate row moved to the left pane — it belongs with everything else
|
// ISS-023 (v0.21.0): the Relocate row moved to the left pane — it belongs with everything else
|
||||||
// about our planes, not in the market. Its state + /api/airports fetch went with it.
|
// about our planes, not in the market. Its state + /api/airports fetch went with it.
|
||||||
// ISS-035 (v0.23.0): same for the Refuel-to-full row — its state + derived values moved to the
|
// ISS-035 (v0.23.0): same for the Refuel-to-full row — its state + derived values moved to the
|
||||||
@@ -143,11 +151,39 @@ export default function RightPane({ state, loading, applySnapshot, onHover, onEr
|
|||||||
// 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;
|
||||||
|
// v0.38.0 (ISS-072): the buy row shows the EFFECTIVE price — same rep-scaled formula as the server's buyPlane.
|
||||||
|
const planeDiscountRate = repRate(state.account.reputation, PLANE_DISCOUNT_PER_REP, MAX_PLANE_DISCOUNT);
|
||||||
|
const buyPrice = buyEntry ? Math.round(buyEntry.price * (1 - planeDiscountRate) * 100) / 100 : 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 & Market</h2>
|
<h2 className="pane-title">Jobs & 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 ? (
|
||||||
@@ -167,6 +203,12 @@ export default function RightPane({ state, loading, applySnapshot, onHover, onEr
|
|||||||
>
|
>
|
||||||
<div className="contract-main">
|
<div className="contract-main">
|
||||||
<span className="contract-route">{pkg.originName} → {pkg.destName}</span>
|
<span className="contract-route">{pkg.originName} → {pkg.destName}</span>
|
||||||
|
{/* v0.37.0 (ISS-073): special-status badge — rendered only when the package has one. */}
|
||||||
|
{pkg.special && (
|
||||||
|
<span data-testid={`package-special-${pkg.id}`} className="special-badge">
|
||||||
|
{formatSpecialLabel(pkg.special)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{eligible.length > 0 ? (
|
{eligible.length > 0 ? (
|
||||||
<LoadControls
|
<LoadControls
|
||||||
pkg={pkg}
|
pkg={pkg}
|
||||||
@@ -179,10 +221,21 @@ export default function RightPane({ state, loading, applySnapshot, onHover, onEr
|
|||||||
Load
|
Load
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{/* ISS-047 part B (v0.29.0): dismiss the job — it leaves the market permanently, no penalty. */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid={`ignore-${pkg.id}`}
|
||||||
|
className="pane-btn"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => void runAction(`/api/packages/${pkg.id}/ignore`)}
|
||||||
|
>
|
||||||
|
Ignore
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="contract-sub">
|
<div className="contract-sub">
|
||||||
<span data-testid={`open-package-reward-${pkg.id}`}>{formatCash(pkg.reward)}</span>
|
<span data-testid={`open-package-reward-${pkg.id}`}>{formatCash(pkg.reward)}</span>
|
||||||
<span>{pkg.cargoType} × {pkg.cargoCount}</span>
|
{/* ISS-066 (v0.35.0): cargo shows as type + tonnage ("Mail 10.0t"), not "type × count". */}
|
||||||
|
<span data-testid={`open-package-cargo-${pkg.id}`}>{formatCargo(pkg.cargoType, pkg.weightKg)}</span>
|
||||||
<span data-testid={`open-package-deadline-${pkg.id}`}>
|
<span data-testid={`open-package-deadline-${pkg.id}`}>
|
||||||
{formatDeadlineRelative(pkg.deadlineSimTime, clockMs)}
|
{formatDeadlineRelative(pkg.deadlineSimTime, clockMs)}
|
||||||
</span>
|
</span>
|
||||||
@@ -193,9 +246,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>
|
||||||
|
|
||||||
@@ -213,8 +268,12 @@ export default function RightPane({ state, loading, applySnapshot, onHover, onEr
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
<span data-testid="market-buy-price" className="market-preview">
|
<span data-testid="market-buy-price" className="market-preview">
|
||||||
{buyEntry ? formatCash(buyEntry.price) : ''}
|
{buyPrice !== null ? formatCash(buyPrice) : ''}
|
||||||
</span>
|
</span>
|
||||||
|
{/* v0.38.0 (ISS-072): rep discount hint — only when the account actually gets one. */}
|
||||||
|
{planeDiscountRate > 0 && (
|
||||||
|
<span data-testid="market-buy-discount" className="special-badge">−{Math.round(planeDiscountRate * 100)}%</span>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
data-testid="market-buy"
|
data-testid="market-buy"
|
||||||
@@ -259,7 +318,46 @@ export default function RightPane({ state, loading, applySnapshot, onHover, onEr
|
|||||||
|
|
||||||
{/* ISS-023 (v0.21.0): the Relocate row moved to the left pane's "Relocate" section. */}
|
{/* ISS-023 (v0.21.0): the Relocate row moved to the left pane's "Relocate" section. */}
|
||||||
{/* ISS-035 (v0.23.0): the Refuel-to-full row moved to the left pane's "Refuel" section, next to Relocate. */}
|
{/* ISS-035 (v0.23.0): the Refuel-to-full row moved to the left pane's "Refuel" section, next to Relocate. */}
|
||||||
|
|
||||||
|
{/* v0.28.0 (ISS-047): tech-spec panel per catalog type — progression order (price ascending).
|
||||||
|
All values come from the snapshot's catalog; max range is the weighted-model closed form. */}
|
||||||
|
{[...catalog].sort((a, b) => a.price - b.price).map((e) => (
|
||||||
|
<div key={e.model} className="plane-type-card" data-testid={`plane-type-${e.model}`}>
|
||||||
|
<h4 className="plane-type-name">{e.label}</h4>
|
||||||
|
<div className="stat-row">
|
||||||
|
<span>Buy</span>
|
||||||
|
<strong>{formatCash(e.price)}</strong>
|
||||||
|
</div>
|
||||||
|
<div className="stat-row">
|
||||||
|
<span>Sell</span>
|
||||||
|
<strong>{formatCash(Math.round(e.price * SELL_FRACTION * 100) / 100)}</strong>
|
||||||
|
</div>
|
||||||
|
<div className="stat-row">
|
||||||
|
<span>Payload</span>
|
||||||
|
<strong>{(e.maxPayloadKg / 1000).toFixed(1)}t</strong>
|
||||||
|
</div>
|
||||||
|
<div className="stat-row">
|
||||||
|
<span>Cruise speed</span>
|
||||||
|
<strong>{e.speedKmh} km/h</strong>
|
||||||
|
</div>
|
||||||
|
<div className="stat-row">
|
||||||
|
<span>Fuel burn</span>
|
||||||
|
<strong>{e.burnPerKm} L/km</strong>
|
||||||
|
</div>
|
||||||
|
<div className="stat-row">
|
||||||
|
<span>Max fuel</span>
|
||||||
|
<strong>{e.maxFuel} L</strong>
|
||||||
|
</div>
|
||||||
|
<div className="stat-row">
|
||||||
|
<span>Max range</span>
|
||||||
|
<strong data-testid={`plane-type-range-${e.model}`}>
|
||||||
|
{Math.round(maxRangeKm(e.burnPerKm, e.emptyWeightKg, e.maxFuel))} km
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</section>
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
{actionError && <p data-testid="right-error" className="pane-error">{actionError}</p>}
|
{actionError && <p data-testid="right-error" className="pane-error">{actionError}</p>}
|
||||||
</aside>
|
</aside>
|
||||||
|
|||||||
@@ -1,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,9 @@ function plane(over: Partial<PlaneState> & Pick<PlaneState, 'id'>): PlaneState {
|
|||||||
departureSimTime: null,
|
departureSimTime: null,
|
||||||
lat: 50,
|
lat: 50,
|
||||||
lon: 8.5,
|
lon: 8.5,
|
||||||
|
health: 100, // v0.39.0 (ISS-069) — full-health fixture default
|
||||||
|
repairUntilSimTime: null,
|
||||||
|
queue: [],
|
||||||
...over,
|
...over,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -37,6 +40,8 @@ function pkg(over: Partial<PackageState> & Pick<PackageState, 'id'>): PackageSta
|
|||||||
reward: 1000,
|
reward: 1000,
|
||||||
cargoType: 'mail',
|
cargoType: 'mail',
|
||||||
cargoCount: 4,
|
cargoCount: 4,
|
||||||
|
weightKg: 1_000,
|
||||||
|
special: null, // v0.37.0 (ISS-073) — no special status by default in fixtures
|
||||||
deadlineSimTime: 1_000_000,
|
deadlineSimTime: 1_000_000,
|
||||||
status: 'open',
|
status: 'open',
|
||||||
planeId: null,
|
planeId: null,
|
||||||
@@ -57,27 +62,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 +91,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 +123,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([]);
|
||||||
});
|
});
|
||||||
|
|||||||
+56
-23
@@ -1,5 +1,12 @@
|
|||||||
import type { Page, Request } from '@playwright/test';
|
import type { Page, Request } from '@playwright/test';
|
||||||
import { expect, test } from '@playwright/test';
|
import { expect, test } from '@playwright/test';
|
||||||
|
// Real helpers (v0.36.0) — the wind-adjusted fuel math must match lib/ exactly; Playwright resolves
|
||||||
|
// the repo's tsconfig paths, so importing beats mirroring a third copy of the formulas.
|
||||||
|
import { initialBearingDeg } from '../lib/geo';
|
||||||
|
import { weatherFor } from '../lib/weather';
|
||||||
|
import { windBurnFactor } from '../lib/sim/movement';
|
||||||
|
// v0.38.0 (ISS-072) — reputation discounts: expectations computed from the LIVE snapshot's rep with the real helpers.
|
||||||
|
import { FUEL_DISCOUNT_PER_REP, FUEL_PRICE_PER_L, MAX_FUEL_DISCOUNT, repRate } from '../lib/game/catalog';
|
||||||
|
|
||||||
// v0.12.0 package model against the live origin (https://flight.dev.kkhost.pl):
|
// v0.12.0 package model against the live origin (https://flight.dev.kkhost.pl):
|
||||||
// the full core loop through the UI — hook creates a short package at EDDF → Load
|
// the full core loop through the UI — hook creates a short package at EDDF → Load
|
||||||
@@ -16,7 +23,7 @@ const FAR = 'LEZL'; // Seville — a long leg: under the v0.16.0 weighted model
|
|||||||
|
|
||||||
// Mirrors lib/sim/movement.ts (v0.16.0 weight-proportional burn) + lib/game/catalog.ts (atr72).
|
// Mirrors lib/sim/movement.ts (v0.16.0 weight-proportional burn) + lib/game/catalog.ts (atr72).
|
||||||
const FUEL_DENSITY_KG_PER_L = 0.8; // Jet-A1 density — must match FUEL_DENSITY_KG_PER_L in movement.ts
|
const FUEL_DENSITY_KG_PER_L = 0.8; // Jet-A1 density — must match FUEL_DENSITY_KG_PER_L in movement.ts
|
||||||
const FUEL_PRICE_PER_L = 0.8; // must match catalog.ts
|
// FUEL_PRICE_PER_L is imported from ../lib/game/catalog (v0.38.0) instead of mirrored here.
|
||||||
const REP_BASE = 1; // lib/sim/engine.ts
|
const REP_BASE = 1; // lib/sim/engine.ts
|
||||||
const REP_PER_KM = 0.0005;
|
const REP_PER_KM = 0.0005;
|
||||||
|
|
||||||
@@ -53,8 +60,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 +81,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);
|
||||||
@@ -101,7 +118,11 @@ test.describe.configure({ mode: 'serial' }); // shared live DB — order matters
|
|||||||
// (repositioning needs no package — v0.12.0 manual flight), and abandon any leftover cargo so
|
// (repositioning needs no package — v0.12.0 manual flight), and abandon any leftover cargo so
|
||||||
// exact cash/rep assertions start from a clean baseline even after an interrupted previous run.
|
// exact cash/rep assertions start from a clean baseline even after an interrupted previous run.
|
||||||
async function flyHomeViaApi(page: Page): Promise<void> {
|
async function flyHomeViaApi(page: Page): Promise<void> {
|
||||||
let s = await getState(page);
|
// v0.39.0 (ISS-069): wear accumulates across the shared CI DB — restore full health and clear any
|
||||||
|
// active repair gate from an interrupted run before the steps below act on the starter, so the FAR
|
||||||
|
// round-trip feasibility pin never sees a worn or broken plane.
|
||||||
|
expect((await page.request.post('/api/test/set-health', { data: { planeId: 'plane-starter', health: 100, repairUntilMs: 0 } })).status()).toBe(200);
|
||||||
|
let s = await getState(page); // ticks — a shortened gate flips the plane back to available here
|
||||||
let plane = s.planes.find((p) => p.id === 'plane-starter')!;
|
let plane = s.planes.find((p) => p.id === 'plane-starter')!;
|
||||||
if (plane.status === 'flying' || plane.locationIcao !== HUB) {
|
if (plane.status === 'flying' || plane.locationIcao !== HUB) {
|
||||||
// Fast-forward any leftover leg / repositioning flight (~20 s real time worst case at 1000×).
|
// Fast-forward any leftover leg / repositioning flight (~20 s real time worst case at 1000×).
|
||||||
@@ -118,7 +139,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 } });
|
||||||
@@ -171,8 +192,10 @@ test('full core loop via UI: load → fly to destination → exact reward + rep
|
|||||||
// package shows as loaded on the starter in the on-board section.
|
// package shows as loaded on the starter in the on-board section.
|
||||||
const onboardRow = page.getByTestId(`onboard-package-${pkg.id}`);
|
const onboardRow = page.getByTestId(`onboard-package-${pkg.id}`);
|
||||||
await expect(onboardRow, 'loaded package appears in the on-board list').toBeVisible();
|
await expect(onboardRow, 'loaded package appears in the on-board list').toBeVisible();
|
||||||
|
// ISS-081 (v0.40.0): the on-board route shows ICAO tags, not full airport names.
|
||||||
|
await expect(page.getByTestId(`onboard-route-${pkg.id}`)).toContainText(`${HUB} → ${nearest.icao}`);
|
||||||
|
|
||||||
// FLY via the UI: click the destination name button in the on-board list.
|
// FLY via the UI: click the fly-to button in the on-board list (ISS-081: ICAO label).
|
||||||
const flyBtn = page.getByTestId(`fly-to-${pkg.id}`);
|
const flyBtn = page.getByTestId(`fly-to-${pkg.id}`);
|
||||||
await expect(flyBtn).toBeEnabled();
|
await expect(flyBtn).toBeEnabled();
|
||||||
await flyBtn.click();
|
await flyBtn.click();
|
||||||
@@ -209,24 +232,33 @@ test('full core loop via UI: load → fly to destination → exact reward + rep
|
|||||||
const repGain = Math.round((REP_BASE + REP_PER_KM * exactKm) * 100) / 100;
|
const repGain = Math.round((REP_BASE + REP_PER_KM * exactKm) * 100) / 100;
|
||||||
expect(s.account.reputation).toBeCloseTo(Math.round((before.account.reputation + repGain) * 100) / 100, 9);
|
expect(s.account.reputation).toBeCloseTo(Math.round((before.account.reputation + repGain) * 100) / 100, 9);
|
||||||
|
|
||||||
// Fuel burned per the v0.16.0 weight-proportional model (closed form — no rounding drift).
|
// Fuel burned per the v0.16.0 weight-proportional model (closed form — no rounding drift),
|
||||||
|
// wind-adjusted (v0.36.0) by departure weather vs route heading.
|
||||||
const entry = before.catalog.find((e) => e.model === 'atr72')!;
|
const entry = before.catalog.find((e) => e.model === 'atr72')!;
|
||||||
|
const wHub = weatherFor(HUB);
|
||||||
expect(landed.fuel).toBeCloseTo(
|
expect(landed.fuel).toBeCloseTo(
|
||||||
fuelAfter(before.planes.find((p) => p.id === planeId)!.fuel, entry.burnPerKm, entry.emptyWeightKg, entry.maxFuel, exactKm),
|
fuelAfter(
|
||||||
|
before.planes.find((p) => p.id === planeId)!.fuel,
|
||||||
|
entry.burnPerKm * windBurnFactor(wHub.windKt, wHub.windDirDeg, initialBearingDeg(nearest.hubLat, nearest.hubLon, nearest.lat, nearest.lon)),
|
||||||
|
entry.emptyWeightKg,
|
||||||
|
entry.maxFuel,
|
||||||
|
exactKm,
|
||||||
|
),
|
||||||
6,
|
6,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Refuel via the API: cost = (maxFuel − fuel) × FUEL_PRICE_PER_L, deducted exactly.
|
// Refuel via the API: cost = (maxFuel − fuel) × FUEL_PRICE_PER_L, rep-scaled (v0.38.0, ISS-072), deducted exactly.
|
||||||
const refuelRes = await page.request.post(`/api/planes/${planeId}/refuel`);
|
const refuelRes = await page.request.post(`/api/planes/${planeId}/refuel`);
|
||||||
expect(refuelRes.ok()).toBeTruthy();
|
expect(refuelRes.ok()).toBeTruthy();
|
||||||
s = await getState(page);
|
s = await getState(page);
|
||||||
const afterRefuel = s.planes.find((p) => p.id === planeId)!;
|
const afterRefuel = s.planes.find((p) => p.id === planeId)!;
|
||||||
expect(afterRefuel.fuel).toBe(afterRefuel.maxFuel); // back to full
|
expect(afterRefuel.fuel).toBe(afterRefuel.maxFuel); // back to full
|
||||||
const refuelCost = Math.round((afterRefuel.maxFuel - landed.fuel) * FUEL_PRICE_PER_L * 100) / 100;
|
const fuelRate = repRate(s.account.reputation, FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT);
|
||||||
|
const refuelCost = Math.round((afterRefuel.maxFuel - landed.fuel) * FUEL_PRICE_PER_L * (1 - fuelRate) * 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 +270,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 +313,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 +347,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 +388,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 +413,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();
|
||||||
|
|||||||
+5
-1
@@ -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 fourteen 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,9 +26,13 @@ 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/planes/{planeId}/repair', // v0.39.0 (ISS-069)
|
||||||
'/api/sim',
|
'/api/sim',
|
||||||
'/api/state',
|
'/api/state',
|
||||||
'/api/version',
|
'/api/version',
|
||||||
|
|||||||
+9
-8
@@ -55,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);
|
||||||
@@ -95,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 } });
|
||||||
@@ -438,7 +439,7 @@ test('hovering the Relocate section previews the planned route and target airpor
|
|||||||
}).toBe(0);
|
}).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('hovering an on-board block of a flying plane highlights its route', async ({ page }) => {
|
test('hovering the fleet row of a flying plane highlights its route', async ({ page }) => {
|
||||||
test.setTimeout(600_000); // self-heal + one flight at scale 1000 + fly-home cleanup
|
test.setTimeout(600_000); // self-heal + one flight at scale 1000 + fly-home cleanup
|
||||||
await flyHomeViaApi(page);
|
await flyHomeViaApi(page);
|
||||||
|
|
||||||
@@ -457,8 +458,8 @@ test('hovering an on-board block of a flying plane highlights its route', async
|
|||||||
expect(fly.status()).toBe(200);
|
expect(fly.status()).toBe(200);
|
||||||
await setScale(page, SCALE);
|
await setScale(page, SCALE);
|
||||||
|
|
||||||
// The on-board block shows the plane in flight once a snapshot with status 'flying' lands.
|
// The fleet row shows the plane in flight once a snapshot with status 'flying' lands (ISS-054).
|
||||||
const block = page.getByTestId('onboard-plane-starter');
|
const block = page.getByTestId('plane-plane-starter');
|
||||||
await expect(block).toContainText(/in flight/i, { timeout: 15_000 });
|
await expect(block).toContainText(/in flight/i, { timeout: 15_000 });
|
||||||
|
|
||||||
// Freeze mid-flight so the ~10 s leg can't land under us while we assert (same pattern as
|
// Freeze mid-flight so the ~10 s leg can't land under us while we assert (same pattern as
|
||||||
@@ -487,7 +488,7 @@ 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);
|
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);
|
||||||
@@ -579,7 +580,7 @@ test('hovering an on-board package while its carrier is mid-flight traces from c
|
|||||||
}, { 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);
|
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);
|
||||||
|
|||||||
@@ -0,0 +1,250 @@
|
|||||||
|
import type { Page } from '@playwright/test';
|
||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
// Real helpers (v0.39.0) — the broken-state burn math must match lib/ exactly; Playwright resolves
|
||||||
|
// the repo's tsconfig paths, so importing beats mirroring a third copy of the formulas.
|
||||||
|
import { initialBearingDeg } from '../lib/geo';
|
||||||
|
import { weatherFor } from '../lib/weather';
|
||||||
|
import { fuelAtWeighted, windBurnFactor } from '../lib/sim/movement';
|
||||||
|
// v0.39.0 (ISS-069) — maintenance: expectations use the real balance constants + burn helpers.
|
||||||
|
import { BROKEN_BURN_MULTIPLIER, REPAIR_COST } from '../lib/game/catalog';
|
||||||
|
|
||||||
|
// v0.39.0 (ISS-069) — maintenance visible flow against the live origin: health is shown per plane in
|
||||||
|
// the fleet list; forcing it to 0 via the set-health hook shows the "broken" pill and makes a short
|
||||||
|
// full-tank leg burn ×1.5 more fuel (asserted with the real lib helpers); clicking Repair deducts
|
||||||
|
// exactly REPAIR_COST, restores health and grounds the plane ("in repair" pill) until its gate
|
||||||
|
// passes; shortening the gate via the hook brings it back to service on the next tick.
|
||||||
|
|
||||||
|
const SCALE = 1000; // fastest sim speed (v0.14.0 ladder top) — keeps flights to seconds of real time
|
||||||
|
const HUB = 'EDDF'; // must match HUB_ICAO in lib/game/seed.ts
|
||||||
|
const STARTER = 'plane-starter';
|
||||||
|
|
||||||
|
interface PlaneState {
|
||||||
|
id: string;
|
||||||
|
model: string;
|
||||||
|
status: 'available' | 'flying' | 'repairing';
|
||||||
|
fuel: number;
|
||||||
|
maxFuel: number;
|
||||||
|
locationIcao: string;
|
||||||
|
destIcao: string | null;
|
||||||
|
health: number; // v0.39.0 (ISS-069) — structural health in [0, 100]
|
||||||
|
repairUntilSimTime: number | null; // v0.39.0 (ISS-069) — active repair gate
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CatalogEntry {
|
||||||
|
model: string;
|
||||||
|
burnPerKm: number;
|
||||||
|
emptyWeightKg: number;
|
||||||
|
maxFuel: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StateBody {
|
||||||
|
account: { cash: number };
|
||||||
|
simState: { clockMs: number; timeScale: number; paused: boolean };
|
||||||
|
planes: PlaneState[];
|
||||||
|
catalog: CatalogEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set the time scale and VERIFY from the response that it stuck — a silent reset would otherwise
|
||||||
|
// surface as an unexplained wait timeout. Also clears pause (ISS-041: landings slow to 1×).
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test hook (FLIGHT_TEST_HOOKS=1): force a plane's health and/or shorten an active repair gate.
|
||||||
|
async function setHealth(
|
||||||
|
page: Page,
|
||||||
|
body: { planeId: string; health?: number; repairUntilMs?: number },
|
||||||
|
): Promise<StateBody> {
|
||||||
|
const res = await page.request.post('/api/test/set-health', { data: body });
|
||||||
|
expect(res.status()).toBe(200);
|
||||||
|
return (await res.json()) as StateBody;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mirrors haversineKm in lib/geo.ts (same formula → identical floats).
|
||||||
|
function haversineKm(lat1: number, lon1: number, lat2: number, lon2: number): number {
|
||||||
|
const toRad = (d: number) => (d * Math.PI) / 180;
|
||||||
|
const dLat = toRad(lat2 - lat1);
|
||||||
|
const dLon = toRad(lon2 - lon1);
|
||||||
|
const a =
|
||||||
|
Math.sin(dLat / 2) ** 2 +
|
||||||
|
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2;
|
||||||
|
return 2 * 6371 * Math.asin(Math.min(1, Math.sqrt(a)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Closest airport to EDDF from /api/airports — the short fixed leg for the broken-burn flight.
|
||||||
|
// Returns both endpoints' coordinates so the exact-km fuel assertion can mirror lib/geo.ts.
|
||||||
|
async function nearestToHub(page: Page): Promise<{ icao: string; lat: number; lon: number; hubLat: number; hubLon: number }> {
|
||||||
|
const fc = (await (await page.request.get('/api/airports')).json()) as {
|
||||||
|
features: Array<{ properties: { icao: string; lat: number; lon: number } }>;
|
||||||
|
};
|
||||||
|
const hub = fc.features.find((f) => f.properties.icao === HUB)!.properties;
|
||||||
|
let nearest = fc.features.filter((f) => f.properties.icao !== HUB)[0].properties;
|
||||||
|
for (const f of fc.features) {
|
||||||
|
if (f.properties.icao === HUB) continue;
|
||||||
|
if (haversineKm(hub.lat, hub.lon, f.properties.lat, f.properties.lon) < haversineKm(hub.lat, hub.lon, nearest.lat, nearest.lon)) {
|
||||||
|
nearest = f.properties;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { icao: nearest.icao, lat: nearest.lat, lon: nearest.lon, hubLat: hub.lat, hubLon: hub.lon };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Self-heal: let any in-flight leg finish and reposition plane-starter to the hub (shared DB — an
|
||||||
|
// interrupted earlier run may have left it elsewhere or airborne). Same pattern as flyHomeViaApi in
|
||||||
|
// right-pane.spec.ts; cargo cleanup is skipped because this flow asserts cash DELTAS, not absolutes.
|
||||||
|
async function healStarterHome(page: Page): Promise<void> {
|
||||||
|
let s = await getState(page);
|
||||||
|
let starter = s.planes.find((p) => p.id === STARTER)!;
|
||||||
|
if (starter.status === 'repairing') {
|
||||||
|
// An interrupted run may have left it mid-repair — shorten the gate so the next tick flips it back.
|
||||||
|
await setHealth(page, { planeId: STARTER, health: 100, repairUntilMs: 0 });
|
||||||
|
await expect
|
||||||
|
.poll(async () => ((await getState(page)).planes.find((p) => p.id === STARTER)?.status ?? ''), {
|
||||||
|
timeout: 30_000,
|
||||||
|
message: 'waiting for the shortened repair gate to flip plane-starter back',
|
||||||
|
})
|
||||||
|
.toBe('available');
|
||||||
|
s = await getState(page);
|
||||||
|
starter = s.planes.find((p) => p.id === STARTER)!;
|
||||||
|
}
|
||||||
|
if (starter.status === 'flying' || starter.locationIcao !== HUB) {
|
||||||
|
// Fast-forward any leftover leg / repositioning flight (~20 s real time worst case at 1000×).
|
||||||
|
await setScale(page, SCALE);
|
||||||
|
}
|
||||||
|
if (starter.status === 'flying') {
|
||||||
|
await expect
|
||||||
|
.poll(async () => ((await getState(page)).planes.find((p) => p.id === STARTER)?.status ?? ''), {
|
||||||
|
timeout: 240_000,
|
||||||
|
message: 'waiting for an in-flight plane-starter to land',
|
||||||
|
})
|
||||||
|
.toBe('available');
|
||||||
|
s = await getState(page);
|
||||||
|
starter = s.planes.find((p) => p.id === STARTER)!;
|
||||||
|
}
|
||||||
|
if (starter.locationIcao !== HUB) {
|
||||||
|
// ISS-041: the landing above slowed the sim to 1× — setScale restores it for the return leg.
|
||||||
|
await setScale(page, SCALE);
|
||||||
|
let flyHome = await page.request.post(`/api/planes/${STARTER}/fly`, { data: { destIcao: HUB } });
|
||||||
|
if (!flyHome.ok()) {
|
||||||
|
// A long outbound leg can leave the tank too empty for the return — top up and retry.
|
||||||
|
expect((await page.request.post(`/api/planes/${STARTER}/refuel`)).status()).toBe(200);
|
||||||
|
flyHome = await page.request.post(`/api/planes/${STARTER}/fly`, { data: { destIcao: HUB } });
|
||||||
|
}
|
||||||
|
expect(flyHome.status()).toBe(200); // repositioning needs no package (v0.12.0 manual flight)
|
||||||
|
await expect
|
||||||
|
.poll(async () => ((await getState(page)).planes.find((p) => p.id === STARTER)?.locationIcao ?? ''), {
|
||||||
|
timeout: 240_000,
|
||||||
|
message: 'waiting for plane-starter to land back at the hub',
|
||||||
|
})
|
||||||
|
.toBe(HUB);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Leave a clean starter for later spec files (shared DB, workers:1): full health and no active
|
||||||
|
// repair gate — an interrupted run may have left the plane mid-repair or worn down.
|
||||||
|
async function restoreStarter(page: Page): Promise<void> {
|
||||||
|
const s = await getState(page);
|
||||||
|
const starter = s.planes.find((p) => p.id === STARTER)!;
|
||||||
|
const body: { planeId: string; health?: number; repairUntilMs?: number } = { planeId: STARTER, health: 100 };
|
||||||
|
if (starter.status === 'repairing') body.repairUntilMs = 0; // shorten the active gate → flips on the next tick
|
||||||
|
await setHealth(page, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe.configure({ mode: 'serial' }); // shared live DB — order matters within this file
|
||||||
|
|
||||||
|
// On exit (success or failure): restore scale=1 / unpaused for the other specs (house convention)
|
||||||
|
// and leave a clean starter — full health, no active repair gate.
|
||||||
|
test.afterEach(async ({ page }) => {
|
||||||
|
const res = await page.request.post('/api/sim', { data: { timeScale: 1, paused: false } });
|
||||||
|
expect(res.status()).toBe(200);
|
||||||
|
await restoreStarter(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('health shown → broken burn ×1.5 → repair grounds for 3 sim days → back to service', async ({ page }) => {
|
||||||
|
test.setTimeout(300_000); // self-heal + one short leg at 1000× + the shortened repair gate wait
|
||||||
|
|
||||||
|
await healStarterHome(page); // deterministic: the starter sits grounded at EDDF
|
||||||
|
|
||||||
|
// (a) Force health to 42 — snapshot AND UI show it.
|
||||||
|
let s = await setHealth(page, { planeId: STARTER, health: 42 });
|
||||||
|
expect(s.planes.find((p) => p.id === STARTER)!.health).toBe(42);
|
||||||
|
|
||||||
|
await page.goto('/');
|
||||||
|
const row = page.getByTestId(`plane-${STARTER}`);
|
||||||
|
await expect(row, 'the starter fleet row renders').toBeVisible({ timeout: 15_000 });
|
||||||
|
await expect(page.getByTestId(`plane-health-${STARTER}`), 'health readout shows the forced value')
|
||||||
|
.toContainText('42%');
|
||||||
|
|
||||||
|
// (b) Force broken — the pill shows, and a short full-tank leg burns ×1.5 more fuel.
|
||||||
|
s = await setHealth(page, { planeId: STARTER, health: 0 });
|
||||||
|
expect(s.planes.find((p) => p.id === STARTER)!.health).toBe(0);
|
||||||
|
await expect(page.getByTestId(`plane-broken-${STARTER}`), 'the broken pill appears')
|
||||||
|
.toHaveText('broken', { timeout: 15_000 });
|
||||||
|
|
||||||
|
// Full tank for the flight (free no-op if already full)…
|
||||||
|
expect((await page.request.post(`/api/planes/${STARTER}/refuel`)).status()).toBe(200);
|
||||||
|
s = await getState(page);
|
||||||
|
const starter = s.planes.find((p) => p.id === STARTER)!;
|
||||||
|
expect(starter.fuel).toBe(starter.maxFuel);
|
||||||
|
|
||||||
|
// …then fly the short fixed leg HUB → nearest via the API (the UI under test is maintenance, not dispatch).
|
||||||
|
const nearest = await nearestToHub(page);
|
||||||
|
expect((await page.request.post(`/api/planes/${STARTER}/fly`, { data: { destIcao: nearest.icao } })).status()).toBe(200);
|
||||||
|
await setScale(page, SCALE);
|
||||||
|
await expect
|
||||||
|
.poll(async () => ((await getState(page)).planes.find((p) => p.id === STARTER)?.locationIcao ?? ''), {
|
||||||
|
timeout: 120_000,
|
||||||
|
message: 'waiting for the broken plane to land at the nearest airport',
|
||||||
|
})
|
||||||
|
.toBe(nearest.icao);
|
||||||
|
|
||||||
|
// Landed fuel matches the ×1.5-adjusted closed form (real lib helpers — same pattern as right-pane.spec.ts).
|
||||||
|
s = await getState(page);
|
||||||
|
const landed = s.planes.find((p) => p.id === STARTER)!;
|
||||||
|
const entry = s.catalog.find((e) => e.model === starter.model)!;
|
||||||
|
const km = haversineKm(nearest.hubLat, nearest.hubLon, nearest.lat, nearest.lon);
|
||||||
|
const wHub = weatherFor(HUB);
|
||||||
|
const burn = entry.burnPerKm * windBurnFactor(wHub.windKt, wHub.windDirDeg, initialBearingDeg(nearest.hubLat, nearest.hubLon, nearest.lat, nearest.lon));
|
||||||
|
expect(landed.fuel).toBeCloseTo(fuelAtWeighted(starter.maxFuel, burn * BROKEN_BURN_MULTIPLIER, entry.emptyWeightKg, entry.maxFuel, km, 1), 6);
|
||||||
|
// Wear clamped at 0 — the broken plane stays broken.
|
||||||
|
expect(landed.health).toBe(0);
|
||||||
|
|
||||||
|
// (c) Repair through the UI: exact cash drop + "in repair" pill.
|
||||||
|
const cashBefore = s.account.cash;
|
||||||
|
const repairBtn = page.getByTestId(`repair-${STARTER}`);
|
||||||
|
await expect(repairBtn, 'the Repair button shows for a grounded worn plane').toBeVisible({ timeout: 15_000 });
|
||||||
|
// The click fires the POST asynchronously — capture the endpoint's OWN response so the cash drop is
|
||||||
|
// checked against the exact game clock the server used (a later GET would tick further).
|
||||||
|
const [res] = await Promise.all([
|
||||||
|
page.waitForResponse((r) => r.url().includes(`/api/planes/${STARTER}/repair`), { timeout: 15_000 }),
|
||||||
|
repairBtn.click(),
|
||||||
|
]);
|
||||||
|
expect(res.status()).toBe(200);
|
||||||
|
const post = (await res.json()) as StateBody; // full post-action snapshot at the action's clock
|
||||||
|
expect(post.account.cash).toBeCloseTo(cashBefore - REPAIR_COST, 6); // exactly REPAIR_COST
|
||||||
|
const repaired = post.planes.find((p) => p.id === STARTER)!;
|
||||||
|
expect(repaired.status).toBe('repairing');
|
||||||
|
expect(repaired.health).toBe(100); // restored to full…
|
||||||
|
expect(repaired.repairUntilSimTime).not.toBeNull(); // …with the gate set (3 sim days out)
|
||||||
|
|
||||||
|
await expect(page.getByTestId(`plane-repairing-${STARTER}`), 'the "in repair" pill appears')
|
||||||
|
.toHaveText('in repair', { timeout: 15_000 });
|
||||||
|
|
||||||
|
// (d) Shorten the gate via the hook — the plane comes back to service on the next tick.
|
||||||
|
await setHealth(page, { planeId: STARTER, repairUntilMs: 10_000 });
|
||||||
|
await setScale(page, SCALE); // fast-forward the shortened gate (each tick advances up to 2 s × scale)
|
||||||
|
await expect
|
||||||
|
.poll(async () => {
|
||||||
|
const p = (await getState(page)).planes.find((x) => x.id === STARTER)!;
|
||||||
|
return p.status === 'available' && p.health === 100 ? true : false;
|
||||||
|
}, { timeout: 60_000, message: 'waiting for the shortened repair gate to flip the plane back' })
|
||||||
|
.toBe(true);
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -33,6 +33,11 @@ test('header shows the app version sourced from package.json', async ({ page })
|
|||||||
await page.goto(APP_URL);
|
await page.goto(APP_URL);
|
||||||
const header = page.getByTestId('app-header');
|
const header = page.getByTestId('app-header');
|
||||||
await expect(header).toContainText(`v${pkg.version}`);
|
await expect(header).toContainText(`v${pkg.version}`);
|
||||||
|
// ISS-080 (v0.40.0): the version links to the repo's CHANGELOG.md.
|
||||||
|
await expect(page.getByTestId('app-version')).toHaveAttribute(
|
||||||
|
'href',
|
||||||
|
'https://git.kkhost.pl/OpenCode/Flight-Ops/src/branch/master/CHANGELOG.md',
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('clicking an airport marker opens a popup with name and ICAO', async ({ page }) => {
|
test('clicking an airport marker opens a popup with name and ICAO', async ({ page }) => {
|
||||||
|
|||||||
+10
-41
@@ -62,18 +62,17 @@ 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/);
|
||||||
|
|
||||||
// ISS-021 (v0.20.0): the On board section moved to the left pane (between Fleet and Time) —
|
// ISS-054 (v0.33.0): On board merged into the Fleet list — its heading is gone from both panes,
|
||||||
// its heading renders here…
|
// per-plane cargo rows now render inside the fleet rows.
|
||||||
await expect(page.getByTestId('left-pane').getByRole('heading', { name: 'On board', exact: true })).toBeVisible();
|
await expect(page.getByTestId('left-pane').getByRole('heading', { name: 'On board', exact: true })).toHaveCount(0);
|
||||||
// …and no longer in the right pane.
|
|
||||||
await expect(page.getByTestId('right-pane').getByRole('heading', { name: 'On board', exact: true })).toHaveCount(0);
|
await expect(page.getByTestId('right-pane').getByRole('heading', { name: 'On board', exact: true })).toHaveCount(0);
|
||||||
|
|
||||||
// ISS-035 (v0.23.0): the Refuel-to-full row moved to the left pane next to Relocate — its section
|
// ISS-035 (v0.23.0): the Refuel-to-full row moved to the left pane next to Relocate — its section
|
||||||
@@ -85,41 +84,6 @@ test('left pane renders wallet, reputation and the starter fleet at Frankfurt',
|
|||||||
await expect(page.getByTestId('right-pane').getByTestId('market-refuel')).toHaveCount(0);
|
await expect(page.getByTestId('right-pane').getByTestId('market-refuel')).toHaveCount(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('time controls: pause freezes the sim and the ladder slider sets timeScale', async ({ page }) => {
|
|
||||||
await page.goto('/');
|
|
||||||
const pauseBtn = page.getByTestId('pause-toggle');
|
|
||||||
await expect(pauseBtn).toBeVisible({ timeout: 15_000 });
|
|
||||||
|
|
||||||
// Pause via the UI button — assert against the authoritative /api/state…
|
|
||||||
await pauseBtn.click();
|
|
||||||
await expect.poll(async () => (await getState(page)).simState.paused, { timeout: 10_000 }).toBe(true);
|
|
||||||
// …and in the pane itself (the refetch after POST flips the button to Resume).
|
|
||||||
await expect(pauseBtn).toHaveText('Resume');
|
|
||||||
|
|
||||||
// Scale via the ladder slider. End jumps to max (t=1 → 1000×): the label updates
|
|
||||||
// optimistically on input, and release flushes the debounced POST immediately.
|
|
||||||
const slider = page.getByTestId('time-scale-slider');
|
|
||||||
await slider.focus();
|
|
||||||
await page.keyboard.press('End');
|
|
||||||
await expect(page.getByTestId('time-scale-value')).toHaveText('1000×', { timeout: 10_000 });
|
|
||||||
await expect.poll(async () => (await getState(page)).simState.timeScale, { timeout: 10_000 }).toBe(1000);
|
|
||||||
|
|
||||||
// Home jumps to min (t=0 → 1×).
|
|
||||||
await page.keyboard.press('Home');
|
|
||||||
await expect(page.getByTestId('time-scale-value')).toHaveText('1×', { timeout: 10_000 });
|
|
||||||
await expect.poll(async () => (await getState(page)).simState.timeScale, { timeout: 10_000 }).toBe(1);
|
|
||||||
|
|
||||||
// Game clock shows seconds: YYYY-MM-DD HH:MM:SS UTC.
|
|
||||||
await expect(page.getByTestId('sim-clock')).toContainText(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} UTC/);
|
|
||||||
|
|
||||||
// Restore the live instance to scale 1 + unpaused (afterEach enforces this too).
|
|
||||||
const restore = await page.request.post('/api/sim', { data: { timeScale: 1, paused: false } });
|
|
||||||
expect(restore.status()).toBe(200);
|
|
||||||
const after = await getState(page);
|
|
||||||
expect(after.simState.timeScale).toBe(1);
|
|
||||||
expect(after.simState.paused).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('sim clock ticks smoothly between polls — seconds advance without waiting for a snapshot (ISS-007)', async ({ page }) => {
|
test('sim clock ticks smoothly between polls — seconds advance without waiting for a snapshot (ISS-007)', async ({ page }) => {
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
const clock = page.getByTestId('sim-clock');
|
const clock = page.getByTestId('sim-clock');
|
||||||
@@ -190,4 +154,9 @@ test('right pane placeholder exists with the Jobs & Market heading', async ({ pa
|
|||||||
const right = page.getByTestId('right-pane');
|
const right = page.getByTestId('right-pane');
|
||||||
await expect(right).toBeVisible();
|
await expect(right).toBeVisible();
|
||||||
await expect(right).toContainText('Jobs & Market');
|
await expect(right).toContainText('Jobs & Market');
|
||||||
|
|
||||||
|
// v0.28.0 (ISS-047): the Jobs / Planes market tab bar renders once a snapshot lands; Jobs is default-active.
|
||||||
|
await expect(page.getByTestId('tab-jobs'), 'the Jobs tab button appears').toBeVisible({ timeout: 15_000 });
|
||||||
|
await expect(page.getByTestId('tab-planes-market')).toBeVisible();
|
||||||
|
await expect(page.getByTestId('tab-jobs')).toHaveAttribute('aria-selected', 'true');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,388 @@
|
|||||||
|
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 (cancel per leg; adding happens via fly-to / Relocate
|
||||||
|
// while flying, ISS-053), and
|
||||||
|
// a flying plane with queued legs draws them as a DOTTED line (`queue-routes` layer) chaining
|
||||||
|
// 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 Relocate destination select — wait for it.
|
||||||
|
const destSelect = page.getByTestId('relocate-dest');
|
||||||
|
await expect(destSelect.locator(`option[value="${QUEUE}"]`)).toHaveCount(1, { timeout: 15_000 });
|
||||||
|
|
||||||
|
// START leg 1 via the UI (fly-to button in the on-board block).
|
||||||
|
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
|
||||||
|
|
||||||
|
// ISS-065: re-queuing the CURRENT leg's destination while flying must be rejected with 409.
|
||||||
|
const dupQueue = await page.request.post('/api/planes/plane-starter/queue', { data: { destIcao: DEST } });
|
||||||
|
expect(dupQueue.status()).toBe(409);
|
||||||
|
|
||||||
|
// ADD a leg to the queue via the Relocate section (ISS-053): the starter is flying, so relocate-go
|
||||||
|
// posts to /queue and the leg starts when it lands.
|
||||||
|
await page.getByTestId('relocate-plane').selectOption('plane-starter');
|
||||||
|
await destSelect.selectOption(QUEUE);
|
||||||
|
await page.getByTestId('relocate-go').click();
|
||||||
|
|
||||||
|
// …and the new leg appears as queue-leg-0 with its destination.
|
||||||
|
const legRow = page.getByTestId('queue-leg-plane-starter-0');
|
||||||
|
await expect(legRow, 'the queued leg appears in the Queue sub-section').toContainText(QUEUE, { timeout: 15_000 });
|
||||||
|
|
||||||
|
// CANCEL it via the UI and assert it's gone.
|
||||||
|
await page.getByTestId('queue-cancel-plane-starter-0').click();
|
||||||
|
await expect(page.getByTestId('queue-leg-plane-starter-0'), 'the cancelled leg is removed from the queue').toHaveCount(0, { timeout: 15_000 });
|
||||||
|
|
||||||
|
// Cleanup: resume + fast-forward to arrival at DEST (empty queue → plain landing), then fly home.
|
||||||
|
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);
|
||||||
|
});
|
||||||
@@ -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 } });
|
||||||
|
|||||||
+168
-31
@@ -1,5 +1,13 @@
|
|||||||
import type { Page } from '@playwright/test';
|
import type { Page } from '@playwright/test';
|
||||||
import { expect, test } from '@playwright/test';
|
import { expect, test } from '@playwright/test';
|
||||||
|
// Real helpers (v0.36.0) — the wind-adjusted fuel math must match lib/ exactly; Playwright resolves
|
||||||
|
// the repo's tsconfig paths, so importing beats mirroring a third copy of the formulas.
|
||||||
|
import { initialBearingDeg } from '../lib/geo';
|
||||||
|
import { weatherFor } from '../lib/weather';
|
||||||
|
import { windBurnFactor } from '../lib/sim/movement';
|
||||||
|
// v0.38.0 (ISS-072) — reputation discounts: expectations computed from the LIVE snapshot's rep with the real helpers.
|
||||||
|
import { FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT, MAX_PLANE_DISCOUNT, PLANE_DISCOUNT_PER_REP, repRate } from '../lib/game/catalog';
|
||||||
|
import { STARTING_REPUTATION } from '../lib/game/seed';
|
||||||
|
|
||||||
// v0.12.0 — right Jobs & Market pane against the live origin: open packages render with
|
// v0.12.0 — right Jobs & Market pane against the live origin: open packages render with
|
||||||
// names/reward/deadline and Load (UI click) binds one to the starter; the on-board list
|
// names/reward/deadline and Load (UI click) binds one to the starter; the on-board list
|
||||||
@@ -28,10 +36,11 @@ interface PackageState {
|
|||||||
originName: string;
|
originName: string;
|
||||||
destName: string;
|
destName: string;
|
||||||
reward: number;
|
reward: number;
|
||||||
status: 'open' | 'loaded' | 'delivered' | 'expired' | 'abandoned';
|
status: 'open' | 'loaded' | 'delivered' | 'expired' | 'abandoned' | 'ignored'; // v0.29.0 (ISS-047 part B)
|
||||||
planeId: string | null;
|
planeId: string | null;
|
||||||
createdSimTime: number;
|
createdSimTime: number;
|
||||||
deadlineSimTime: number;
|
deadlineSimTime: number;
|
||||||
|
special: string | null; // v0.37.0 (ISS-073) — special status from the specials registry
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CatalogEntry {
|
interface CatalogEntry {
|
||||||
@@ -42,7 +51,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 {
|
||||||
@@ -69,9 +78,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);
|
||||||
@@ -118,22 +128,28 @@ async function nearestToHub(page: Page): Promise<{ icao: string; lat: number; lo
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Farthest airport from EDDF whose leg still FITS A FULL TANK — the longest flyable leg (v0.16.0
|
// Farthest airport from EDDF whose leg still FITS A FULL TANK — the longest flyable leg (v0.16.0
|
||||||
// refuel-for-flight test). The dataset's absolute farthest can exceed the aircraft's range (UNNT is
|
// refuel-for-flight test). The dataset's absolute farthest can exceed the aircraft's range, so a
|
||||||
// ~4803 km out and needs ~6955 L > the 6300 L tank), so a full-tank fly there would rightly 409;
|
// full-tank fly there would rightly 409; this test wants a long-but-flyable leg instead. Wind-
|
||||||
// this test wants a long-but-flyable leg instead.
|
// adjusted (v0.36.0): BOTH directions must fit — the outbound is flown from a full tank and the
|
||||||
|
// return leg is what the forFlight refuel below buys, which also 409s if it doesn't fit.
|
||||||
async function farthestFlyableToHub(
|
async function farthestFlyableToHub(
|
||||||
page: Page,
|
page: Page,
|
||||||
entry: CatalogEntry,
|
entry: CatalogEntry,
|
||||||
): Promise<{ icao: string; lat: number; lon: number; hubLat: number; hubLon: number }> {
|
): Promise<{ icao: string; lat: number; lon: number; d: number; hubLat: number; hubLon: number }> {
|
||||||
const fc = (await (await page.request.get('/api/airports')).json()) as {
|
const fc = (await (await page.request.get('/api/airports')).json()) as {
|
||||||
features: Array<{ properties: { icao: string; lat: number; lon: number } }>;
|
features: Array<{ properties: { icao: string; lat: number; lon: number } }>;
|
||||||
};
|
};
|
||||||
const hub = fc.features.find((f) => f.properties.icao === HUB)!.properties;
|
const hub = fc.features.find((f) => f.properties.icao === HUB)!.properties;
|
||||||
|
const wHub = weatherFor(HUB);
|
||||||
let farthest: { icao: string; lat: number; lon: number; d: number } | null = null;
|
let farthest: { icao: string; lat: number; lon: number; d: number } | null = null;
|
||||||
for (const f of fc.features) {
|
for (const f of fc.features) {
|
||||||
if (f.properties.icao === HUB) continue;
|
if (f.properties.icao === HUB) continue;
|
||||||
const d = haversineKm(hub.lat, hub.lon, f.properties.lat, f.properties.lon);
|
const d = haversineKm(hub.lat, hub.lon, f.properties.lat, f.properties.lon);
|
||||||
if (requiredFuelFor(entry.burnPerKm, entry.emptyWeightKg, entry.maxFuel, d) > entry.maxFuel) continue; // beyond range
|
const wDest = weatherFor(f.properties.icao);
|
||||||
|
const outBurn = entry.burnPerKm * windBurnFactor(wHub.windKt, wHub.windDirDeg, initialBearingDeg(hub.lat, hub.lon, f.properties.lat, f.properties.lon));
|
||||||
|
if (requiredFuelFor(outBurn, entry.emptyWeightKg, entry.maxFuel, d) > entry.maxFuel) continue; // beyond range
|
||||||
|
const backBurn = entry.burnPerKm * windBurnFactor(wDest.windKt, wDest.windDirDeg, initialBearingDeg(f.properties.lat, f.properties.lon, hub.lat, hub.lon));
|
||||||
|
if (requiredFuelFor(backBurn, entry.emptyWeightKg, entry.maxFuel, d) > entry.maxFuel) continue; // return leg wouldn't fit a full tank either
|
||||||
if (!farthest || d > farthest.d) {
|
if (!farthest || d > farthest.d) {
|
||||||
farthest = { icao: f.properties.icao, lat: f.properties.lat, lon: f.properties.lon, d };
|
farthest = { icao: f.properties.icao, lat: f.properties.lat, lon: f.properties.lon, d };
|
||||||
}
|
}
|
||||||
@@ -150,10 +166,17 @@ function requiredFuelFor(burnPerKm: number, emptyWeightKg: number, maxFuel: numb
|
|||||||
return (emptyWeightKg / FUEL_DENSITY_KG_PER_L) * (Math.exp(k * FUEL_DENSITY_KG_PER_L * distanceKm) - 1);
|
return (emptyWeightKg / FUEL_DENSITY_KG_PER_L) * (Math.exp(k * FUEL_DENSITY_KG_PER_L * distanceKm) - 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test hook (FLIGHT_TEST_HOOKS=1): create an open package on a known route.
|
// Mirrors maxRangeKm in lib/sim/movement.ts (v0.28.0): full-tank range under the weighted model.
|
||||||
async function createPackage(page: Page, fromIcao: string, toIcao: string, deadlineOffsetMs?: number): Promise<PackageState> {
|
function maxRangeKm(burnPerKm: number, emptyWeightKg: number, maxFuel: number): number {
|
||||||
|
const rho = FUEL_DENSITY_KG_PER_L;
|
||||||
|
return Math.log(1 + (rho * maxFuel) / emptyWeightKg) * ((emptyWeightKg + rho * maxFuel) / (rho * burnPerKm));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test hook (FLIGHT_TEST_HOOKS=1): create an open package on a known route. Omitting `special`
|
||||||
|
// makes the hook force null (no roll), so existing specs' exact reward/rep assertions stay deterministic.
|
||||||
|
async function createPackage(page: Page, fromIcao: string, toIcao: string, deadlineOffsetMs?: number, weightKg?: number, special?: string | null): Promise<PackageState> {
|
||||||
const created = await page.request.post('/api/test/create-package', {
|
const created = await page.request.post('/api/test/create-package', {
|
||||||
data: { originIcao: fromIcao, destIcao: toIcao, deadlineOffsetMs },
|
data: { originIcao: fromIcao, destIcao: toIcao, deadlineOffsetMs, weightKg, special },
|
||||||
});
|
});
|
||||||
expect(created.status()).toBe(201);
|
expect(created.status()).toBe(201);
|
||||||
return (await created.json()) as PackageState;
|
return (await created.json()) as PackageState;
|
||||||
@@ -161,7 +184,11 @@ async function createPackage(page: Page, fromIcao: string, toIcao: string, deadl
|
|||||||
|
|
||||||
// Self-heal: let any in-flight leg finish, then reposition plane-starter home via the API.
|
// Self-heal: let any in-flight leg finish, then reposition plane-starter home via the API.
|
||||||
async function flyHomeViaApi(page: Page): Promise<void> {
|
async function flyHomeViaApi(page: Page): Promise<void> {
|
||||||
const s0 = await getState(page);
|
// v0.39.0 (ISS-069): wear accumulates across the shared CI DB — restore full health and clear any
|
||||||
|
// active repair gate from an interrupted run before the steps below act on the starter, so the
|
||||||
|
// farthest-flyable picker and fuel pins never see a worn or broken plane.
|
||||||
|
expect((await page.request.post('/api/test/set-health', { data: { planeId: 'plane-starter', health: 100, repairUntilMs: 0 } })).status()).toBe(200);
|
||||||
|
const s0 = await getState(page); // ticks — a shortened gate flips the plane back to available here
|
||||||
const starter = s0.planes.find((p) => p.id === 'plane-starter')!;
|
const starter = s0.planes.find((p) => p.id === 'plane-starter')!;
|
||||||
if (starter.status === 'flying' || starter.locationIcao !== HUB) {
|
if (starter.status === 'flying' || starter.locationIcao !== HUB) {
|
||||||
// Fast-forward any leftover leg / repositioning flight (~20 s real time worst case at 1000×).
|
// Fast-forward any leftover leg / repositioning flight (~20 s real time worst case at 1000×).
|
||||||
@@ -176,11 +203,16 @@ 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 } });
|
let flyHome = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
|
||||||
expect(res.status()).toBe(200); // repositioning needs no package (v0.12.0 manual flight)
|
if (!flyHome.ok()) {
|
||||||
|
// A long outbound leg can leave the tank too empty for the return — top up and retry.
|
||||||
|
expect((await page.request.post('/api/planes/plane-starter/refuel')).status()).toBe(200);
|
||||||
|
flyHome = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
|
||||||
|
}
|
||||||
|
expect(flyHome.status()).toBe(200); // repositioning needs no package (v0.12.0 manual flight)
|
||||||
await expect
|
await expect
|
||||||
.poll(async () => (await getState(page)).planes.find((p) => p.id === 'plane-starter')?.locationIcao === HUB, {
|
.poll(async () => (await getState(page)).planes.find((p) => p.id === 'plane-starter')?.locationIcao === HUB, {
|
||||||
timeout: 240_000,
|
timeout: 240_000,
|
||||||
@@ -188,6 +220,14 @@ async function flyHomeViaApi(page: Page): Promise<void> {
|
|||||||
})
|
})
|
||||||
.toBe(true);
|
.toBe(true);
|
||||||
}
|
}
|
||||||
|
// Abandon leftover loaded/expired packages (the baseline is captured AFTER this cleanup).
|
||||||
|
const s1 = await getState(page);
|
||||||
|
for (const pkg of s1.packages) {
|
||||||
|
if (pkg.planeId === 'plane-starter' && (pkg.status === 'loaded' || pkg.status === 'expired')) {
|
||||||
|
const res = await page.request.post(`/api/packages/${pkg.id}/abandon`);
|
||||||
|
expect(res.ok()).toBeTruthy();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
test('open packages render with names, reward and deadline; Load is enabled at a package origin', async ({ page }) => {
|
test('open packages render with names, reward and deadline; Load is enabled at a package origin', async ({ page }) => {
|
||||||
@@ -206,9 +246,12 @@ test('open packages render with names, reward and deadline; Load is enabled at a
|
|||||||
|
|
||||||
// Deterministic target: a hook-created EDDF package — Load is enabled (starter at origin, free slot).
|
// Deterministic target: a hook-created EDDF package — Load is enabled (starter at origin, free slot).
|
||||||
const nearest = await nearestToHub(page);
|
const nearest = await nearestToHub(page);
|
||||||
const pkg = await createPackage(page, HUB, nearest.icao);
|
const pkg = await createPackage(page, HUB, nearest.icao, undefined, 4_000);
|
||||||
const row = page.locator(`li[data-testid="open-package-${pkg.id}"]`);
|
const row = page.locator(`li[data-testid="open-package-${pkg.id}"]`);
|
||||||
await expect(row, 'the hook-created package appears in the list').toBeVisible({ timeout: 15_000 });
|
await expect(row, 'the hook-created package appears in the list').toBeVisible({ timeout: 15_000 });
|
||||||
|
// ISS-066 (v0.35.0): cargo shows as type + tonnage ("… 4.0t"), not "type × count".
|
||||||
|
// 4 t is within the starter ATR's 4.8 t maxPayloadKg, so Load stays enabled below.
|
||||||
|
await expect(page.getByTestId(`open-package-cargo-${pkg.id}`)).toContainText('4.0t');
|
||||||
await expect(page.getByTestId(`open-package-reward-${pkg.id}`)).toContainText(eur(pkg.reward));
|
await expect(page.getByTestId(`open-package-reward-${pkg.id}`)).toContainText(eur(pkg.reward));
|
||||||
const loadBtn = page.getByTestId(`load-${pkg.id}`);
|
const loadBtn = page.getByTestId(`load-${pkg.id}`);
|
||||||
await expect(loadBtn).toBeEnabled();
|
await expect(loadBtn).toBeEnabled();
|
||||||
@@ -224,6 +267,23 @@ test('open packages render with names, reward and deadline; Load is enabled at a
|
|||||||
await expect(page.getByTestId(`onboard-package-${pkg.id}`)).toBeVisible({ timeout: 10_000 });
|
await expect(page.getByTestId(`onboard-package-${pkg.id}`)).toBeVisible({ timeout: 10_000 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('a priority package shows its special badge on the open-package row', async ({ page }) => {
|
||||||
|
test.setTimeout(300_000); // self-heal can take minutes after an interrupted run
|
||||||
|
await flyHomeViaApi(page); // deterministic: the starter sits at EDDF
|
||||||
|
|
||||||
|
const nearest = await nearestToHub(page);
|
||||||
|
const pkg = await createPackage(page, HUB, nearest.icao, undefined, 4_000, 'priority');
|
||||||
|
expect(pkg.special).toBe('priority'); // hook passthrough — no roll
|
||||||
|
|
||||||
|
await page.goto('/');
|
||||||
|
const row = page.locator(`li[data-testid="open-package-${pkg.id}"]`);
|
||||||
|
await expect(row, 'the hook-created package appears in the list').toBeVisible({ timeout: 15_000 });
|
||||||
|
// v0.37.0 (ISS-073): the row carries a "Priority" badge next to the route.
|
||||||
|
const badge = page.getByTestId(`package-special-${pkg.id}`);
|
||||||
|
await expect(badge).toBeVisible();
|
||||||
|
await expect(badge).toHaveText('Priority');
|
||||||
|
});
|
||||||
|
|
||||||
test('Load is disabled when no plane sits at the package origin', async ({ page }) => {
|
test('Load is disabled when no plane sits at the package origin', async ({ page }) => {
|
||||||
test.setTimeout(300_000); // self-heal can take minutes after an interrupted run
|
test.setTimeout(300_000); // self-heal can take minutes after an interrupted run
|
||||||
await flyHomeViaApi(page); // starter at EDDF — a Madrid-origin package cannot be loaded here
|
await flyHomeViaApi(page); // starter at EDDF — a Madrid-origin package cannot be loaded here
|
||||||
@@ -261,8 +321,8 @@ test('on-board list: fly-to-destination + abandon with penalty preview; flying p
|
|||||||
await expect(flyBtn).toBeEnabled();
|
await expect(flyBtn).toBeEnabled();
|
||||||
await flyBtn.click();
|
await flyBtn.click();
|
||||||
|
|
||||||
// The on-board block now shows the plane in flight with its current leg…
|
// The fleet row now shows the plane in flight with its current leg (ISS-054)…
|
||||||
await expect(page.getByTestId('onboard-plane-starter')).toContainText(/in flight/i, { timeout: 10_000 });
|
await expect(page.getByTestId('plane-plane-starter')).toContainText(/in flight/i, { timeout: 10_000 });
|
||||||
const leg = page.getByTestId('plane-leg-plane-starter');
|
const leg = page.getByTestId('plane-leg-plane-starter');
|
||||||
await expect(leg).toBeVisible();
|
await expect(leg).toBeVisible();
|
||||||
await expect(leg).toContainText('→');
|
await expect(leg).toContainText('→');
|
||||||
@@ -289,7 +349,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);
|
||||||
@@ -308,14 +368,26 @@ test('market buy/sell render with previews from the catalog in /api/state; left-
|
|||||||
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('/');
|
||||||
// Buy: the model picker lists the catalog entry and the price preview is its full price.
|
// 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 EFFECTIVE price —
|
||||||
|
// rep-scaled (v0.38.0) from the live snapshot's reputation; at the starting rep this equals list 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 });
|
||||||
await expect(buyModel.locator(`option[value="${entry.model}"]`)).toHaveText(entry.label);
|
await expect(buyModel.locator(`option[value="${entry.model}"]`)).toHaveText(entry.label);
|
||||||
await expect(page.getByTestId('market-buy-price')).toContainText(eur(entry.price));
|
const planeRate = repRate(body.account.reputation, PLANE_DISCOUNT_PER_REP, MAX_PLANE_DISCOUNT);
|
||||||
|
const buyPrice = Math.round(entry.price * (1 - planeRate) * 100) / 100;
|
||||||
|
await expect(page.getByTestId('market-buy-price')).toContainText(eur(buyPrice));
|
||||||
|
// The discount pill is present iff the live rep earns one, and shows round(rate × 100).
|
||||||
|
if (body.account.reputation > STARTING_REPUTATION) {
|
||||||
|
await expect(page.getByTestId('market-buy-discount')).toHaveText(`−${Math.round(planeRate * 100)}%`);
|
||||||
|
} else {
|
||||||
|
await expect(page.getByTestId('market-buy-discount')).toHaveCount(0);
|
||||||
|
}
|
||||||
await expect(page.getByTestId('market-buy')).toBeVisible();
|
await expect(page.getByTestId('market-buy')).toBeVisible();
|
||||||
|
|
||||||
// Sell: the grounded-plane picker lists plane-starter and the payout preview is
|
// Sell: the grounded-plane picker lists plane-starter and the payout preview is
|
||||||
@@ -331,7 +403,9 @@ test('market buy/sell render with previews from the catalog in /api/state; left-
|
|||||||
|
|
||||||
// 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;
|
// v0.38.0 (ISS-072): rep-scaled cost — same formula as the server's refuelPlane 'full'.
|
||||||
|
const fuelRate = repRate(body.account.reputation, FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT);
|
||||||
|
const expectedCost = Math.round((starter.maxFuel - starter.fuel) * body.fuelPricePerL * (1 - fuelRate) * 100) / 100;
|
||||||
const cashBefore = body.account.cash;
|
const cashBefore = body.account.cash;
|
||||||
|
|
||||||
// …the refuel picker lists it, the preview matches the exact cost, and the click deducts it.
|
// …the refuel picker lists it, the preview matches the exact cost, and the click deducts it.
|
||||||
@@ -352,7 +426,7 @@ test('market buy/sell render with previews from the catalog in /api/state; left-
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('on-board row Refuel tops up just enough for the package leg; left-pane button says "Refuel to full"', async ({ page }) => {
|
test('on-board row Refuel buys 150% of the weather-adjusted leg requirement; left-pane button says "Refuel to full"', async ({ page }) => {
|
||||||
test.setTimeout(300_000); // self-heal + one long flight each way at scale 1000
|
test.setTimeout(300_000); // self-heal + one long flight each way at scale 1000
|
||||||
await flyHomeViaApi(page);
|
await flyHomeViaApi(page);
|
||||||
|
|
||||||
@@ -362,7 +436,7 @@ test('on-board row Refuel tops up just enough for the package leg; left-pane but
|
|||||||
// Deterministic baseline: a full tank.
|
// Deterministic baseline: a full tank.
|
||||||
expect((await page.request.post('/api/planes/plane-starter/refuel')).status()).toBe(200);
|
expect((await page.request.post('/api/planes/plane-starter/refuel')).status()).toBe(200);
|
||||||
|
|
||||||
// Longest FLYABLE leg from the hub (fits a full tank) — long enough that "just enough" for it
|
// Longest FLYABLE leg from the hub (fits a full tank) — long enough that 150% of its requirement
|
||||||
// exceeds what's left on arrival.
|
// exceeds what's left on arrival.
|
||||||
const farthest = await farthestFlyableToHub(page, entry);
|
const farthest = await farthestFlyableToHub(page, entry);
|
||||||
const pkg1 = await createPackage(page, HUB, farthest.icao, 24 * 3_600_000);
|
const pkg1 = await createPackage(page, HUB, farthest.icao, 24 * 3_600_000);
|
||||||
@@ -383,14 +457,25 @@ test('on-board row Refuel tops up just enough for the package leg; left-pane but
|
|||||||
const pkg2 = await createPackage(page, farthest.icao, HUB, 24 * 3_600_000);
|
const pkg2 = await createPackage(page, farthest.icao, HUB, 24 * 3_600_000);
|
||||||
expect((await page.request.post(`/api/packages/${pkg2.id}/load`, { data: { planeId: 'plane-starter' } })).status()).toBe(200);
|
expect((await page.request.post(`/api/packages/${pkg2.id}/load`, { data: { planeId: 'plane-starter' } })).status()).toBe(200);
|
||||||
|
|
||||||
// Expected "just enough" from the snapshot constants (mirrors requiredFuelFor in lib/sim/movement.ts).
|
// Expected "150% of the requirement" from the snapshot constants (mirrors requiredFuelFor in
|
||||||
|
// lib/sim/movement.ts, wind-adjusted per v0.36.0 — departure weather at the far end vs heading home).
|
||||||
const pre = await getState(page);
|
const pre = await getState(page);
|
||||||
const fuelBefore = pre.planes.find((p) => p.id === 'plane-starter')!.fuel;
|
const fuelBefore = pre.planes.find((p) => p.id === 'plane-starter')!.fuel;
|
||||||
const cashBefore = pre.account.cash;
|
const cashBefore = pre.account.cash;
|
||||||
const km = haversineKm(farthest.hubLat, farthest.hubLon, farthest.lat, farthest.lon); // symmetric: hub ↔ far end
|
const km = haversineKm(farthest.hubLat, farthest.hubLon, farthest.lat, farthest.lon); // symmetric: hub ↔ far end
|
||||||
const liters = Math.max(0, requiredFuelFor(entry.burnPerKm, entry.emptyWeightKg, entry.maxFuel, km) - fuelBefore);
|
const wFar = weatherFor(farthest.icao);
|
||||||
|
const required = requiredFuelFor(
|
||||||
|
entry.burnPerKm * windBurnFactor(wFar.windKt, wFar.windDirDeg, initialBearingDeg(farthest.lat, farthest.lon, farthest.hubLat, farthest.hubLon)),
|
||||||
|
entry.emptyWeightKg,
|
||||||
|
entry.maxFuel,
|
||||||
|
km,
|
||||||
|
);
|
||||||
|
const target = Math.min(entry.maxFuel, required * 1.5); // ISS-071: 150% margin, capped at tank capacity
|
||||||
|
const liters = Math.max(0, target - fuelBefore);
|
||||||
expect(liters).toBeGreaterThan(0); // the long leg genuinely needs more than what's left
|
expect(liters).toBeGreaterThan(0); // the long leg genuinely needs more than what's left
|
||||||
const expectedCost = Math.round(liters * pre.fuelPricePerL * 100) / 100;
|
// v0.38.0 (ISS-072): rep-scaled cost — same formula as the server's refuelPlane 'forFlight'.
|
||||||
|
const fuelRate = repRate(pre.account.reputation, FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT);
|
||||||
|
const expectedCost = Math.round(liters * pre.fuelPricePerL * (1 - fuelRate) * 100) / 100;
|
||||||
|
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
const refuelBtn = page.getByTestId(`refuel-for-${pkg2.id}`);
|
const refuelBtn = page.getByTestId(`refuel-for-${pkg2.id}`);
|
||||||
@@ -411,8 +496,8 @@ test('on-board row Refuel tops up just enough for the package leg; left-pane but
|
|||||||
await expect(page.getByTestId('market-refuel')).toHaveText('Refuel to full');
|
await expect(page.getByTestId('market-refuel')).toHaveText('Refuel to full');
|
||||||
|
|
||||||
// Restore: fly home — pkg2's destination IS the hub, so it delivers on arrival. The tank holds
|
// Restore: fly home — pkg2's destination IS the hub, so it delivers on arrival. The tank holds
|
||||||
// exactly the leg requirement, so this also proves "just enough" is enough. ISS-011: the far-end
|
// 150% of the leg requirement (or a full tank), so the return is comfortably feasible. ISS-041:
|
||||||
// arrival above auto-slown the sim to 1×, so re-set SCALE for this leg.
|
// the far-end arrival above paused the sim, so re-set SCALE for this leg (setScale clears pause).
|
||||||
await setScale(page, SCALE);
|
await setScale(page, SCALE);
|
||||||
expect((await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } })).status()).toBe(200);
|
expect((await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } })).status()).toBe(200);
|
||||||
await expect
|
await expect
|
||||||
@@ -424,3 +509,55 @@ test('on-board row Refuel tops up just enough for the package leg; left-pane but
|
|||||||
// Top the tank back up so later specs start with a full tank.
|
// Top the tank back up so later specs start with a full tank.
|
||||||
expect((await page.request.post('/api/planes/plane-starter/refuel')).status()).toBe(200);
|
expect((await page.request.post('/api/planes/plane-starter/refuel')).status()).toBe(200);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('Ignore removes an open job from the list permanently (ISS-047 part B)', async ({ page }) => {
|
||||||
|
test.setTimeout(300_000); // self-heal can take minutes after an interrupted run
|
||||||
|
await flyHomeViaApi(page); // deterministic: the starter sits at EDDF
|
||||||
|
|
||||||
|
const nearest = await nearestToHub(page);
|
||||||
|
const pkg = await createPackage(page, HUB, nearest.icao, 24 * 3_600_000);
|
||||||
|
|
||||||
|
await page.goto('/');
|
||||||
|
const row = page.locator(`li[data-testid="open-package-${pkg.id}"]`);
|
||||||
|
await expect(row, 'the hook-created package appears in the list').toBeVisible({ timeout: 15_000 });
|
||||||
|
|
||||||
|
// Ignore through the UI — the post-action snapshot lands immediately (no poll wait).
|
||||||
|
const ignoreBtn = page.getByTestId(`ignore-${pkg.id}`);
|
||||||
|
await expect(ignoreBtn, 'the row has an Ignore button').toBeEnabled();
|
||||||
|
await ignoreBtn.click();
|
||||||
|
|
||||||
|
// Server truth: the package is 'ignored'…
|
||||||
|
await expect
|
||||||
|
.poll(async () => (await getState(page)).packages.find((p) => p.id === pkg.id)?.status, {
|
||||||
|
timeout: 15_000,
|
||||||
|
message: 'waiting for the ignored status in /api/state',
|
||||||
|
})
|
||||||
|
.toBe('ignored');
|
||||||
|
|
||||||
|
// …and the row is gone from the open list (the section filters status==='open').
|
||||||
|
await expect(row).not.toBeVisible({ timeout: 15_000 });
|
||||||
|
|
||||||
|
// A second ignore attempt on the same id is a 409 — 'ignored' is terminal.
|
||||||
|
const again = await page.request.post(`/api/packages/${pkg.id}/ignore`);
|
||||||
|
expect(again.status()).toBe(409);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Planes market tab lists all plane types with tech specs', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
const body = await getState(page); // authoritative catalog — v0.28.0 ships six types
|
||||||
|
expect(body.catalog.length).toBe(6);
|
||||||
|
|
||||||
|
await page.getByTestId('tab-planes-market').click();
|
||||||
|
|
||||||
|
// ATR card: name + full-tank range from the same closed form as lib/sim/movement.ts (v0.28.0).
|
||||||
|
const atr = body.catalog.find((e) => e.model === 'atr72')!;
|
||||||
|
await expect(page.getByTestId('plane-type-atr72'), 'the ATR card appears').toBeVisible({ timeout: 15_000 });
|
||||||
|
await expect(page.getByTestId('plane-type-atr72')).toContainText(atr.label);
|
||||||
|
const atrRange = Math.round(maxRangeKm(atr.burnPerKm, atr.emptyWeightKg, atr.maxFuel));
|
||||||
|
await expect(page.getByTestId('plane-type-range-atr72')).toContainText(`${atrRange} km`);
|
||||||
|
|
||||||
|
// Ladder sanity: the most expensive type (Boeing 777-300ER) is present with its €9M price.
|
||||||
|
const b777 = page.getByTestId('plane-type-b777');
|
||||||
|
await expect(b777).toBeVisible();
|
||||||
|
await expect(b777).toContainText(eur(9_000_000)); // lib/game/catalog.ts
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,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,11 +77,16 @@ async function flyHomeViaApi(page: Page): Promise<void> {
|
|||||||
plane = s.planes.find((p) => p.id === 'plane-starter')!;
|
plane = s.planes.find((p) => p.id === 'plane-starter')!;
|
||||||
}
|
}
|
||||||
if (plane.locationIcao !== HUB) {
|
if (plane.locationIcao !== HUB) {
|
||||||
// ISS-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 } });
|
let flyHome = await page.request.post(`/api/planes/${plane.id}/fly`, { data: { destIcao: HUB } });
|
||||||
expect(res.ok()).toBeTruthy(); // repositioning needs no package (v0.12.0 manual flight)
|
if (!flyHome.ok()) {
|
||||||
|
// A long outbound leg can leave the tank too empty for the return — top up and retry.
|
||||||
|
expect((await page.request.post(`/api/planes/${plane.id}/refuel`)).status()).toBe(200);
|
||||||
|
flyHome = await page.request.post(`/api/planes/${plane.id}/fly`, { data: { destIcao: HUB } });
|
||||||
|
}
|
||||||
|
expect(flyHome.ok()).toBeTruthy(); // repositioning needs no package (v0.12.0 manual flight)
|
||||||
await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')!.locationIcao), { timeout: 240_000 }).toBe(HUB);
|
await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')!.locationIcao), { timeout: 240_000 }).toBe(HUB);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -93,13 +100,13 @@ test.afterEach(async ({ page }) => {
|
|||||||
|
|
||||||
test.describe.configure({ mode: 'serial' }); // shared live DB — order matters within this file
|
test.describe.configure({ mode: 'serial' }); // shared live DB — order matters within this file
|
||||||
|
|
||||||
test('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 +124,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
@@ -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);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -25,6 +25,9 @@ async function getState(page: Page): Promise<StateBody> {
|
|||||||
|
|
||||||
test('failed actions toast at the map right edge, clear of the right pane (inline error stays), and the Time section offers a notifications opt-in', async ({ page }) => {
|
test('failed actions toast at the map right edge, clear of the right pane (inline error stays), and the Time section offers a notifications opt-in', async ({ page }) => {
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
|
// v0.28.0 (ISS-047): the market rows live in the Planes market tab — switch to it first.
|
||||||
|
await page.getByTestId('tab-planes-market').click();
|
||||||
|
|
||||||
const buyModel = page.getByTestId('market-buy-model');
|
const buyModel = page.getByTestId('market-buy-model');
|
||||||
await expect(buyModel, 'the market section renders once the first snapshot lands').toBeVisible({ timeout: 15_000 });
|
await expect(buyModel, 'the market section renders once the first snapshot lands').toBeVisible({ timeout: 15_000 });
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,609 @@
|
|||||||
|
import type { Page } from '@playwright/test';
|
||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
// Real helpers (v0.36.0) — the wind-adjusted fuel math must match lib/ exactly; Playwright resolves
|
||||||
|
// the repo's tsconfig paths, so importing beats mirroring a third copy of the formulas.
|
||||||
|
import { initialBearingDeg } from '../lib/geo';
|
||||||
|
import { weatherFor } from '../lib/weather';
|
||||||
|
import { windBurnFactor } from '../lib/sim/movement';
|
||||||
|
// v0.38.0 (ISS-072) — reputation discounts: expectations computed from the LIVE snapshot's rep with the real helpers.
|
||||||
|
import { FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT, repRate } from '../lib/game/catalog';
|
||||||
|
|
||||||
|
// v0.31.0 — ISS-055: gameplay with TWO planes in the fleet. The reported bug ("can't load cargo
|
||||||
|
// if both planes are on the same airport") was UX-only: LoadControls' multi-plane path rendered a
|
||||||
|
// greyed-out Load button until a plane was picked from the dropdown, while the single-plane path
|
||||||
|
// is one-click. v0.31.0 preselects the first eligible plane (components/RightPane.tsx), and this
|
||||||
|
// spec covers every two-plane flow: load (preselect + picker), parallel flights with the ISS-057
|
||||||
|
// slow/resume semantics, refuel, relocate, per-plane queues (scoped testids) and sell.
|
||||||
|
//
|
||||||
|
// Each test buys its own second plane via the UI (after a cash grant from the new
|
||||||
|
// POST /api/test/add-cash hook — ATR €200k vs the €20k starting balance); beforeEach/afterEach
|
||||||
|
// guarantee the fleet is exactly one plane (the starter, grounded at EDDF) before and after.
|
||||||
|
|
||||||
|
const SCALE = 1000; // fastest sim speed (v0.14.0 ladder top) — keeps flights to seconds of real time
|
||||||
|
const HUB = 'EDDF'; // must match HUB_ICAO in lib/game/seed.ts
|
||||||
|
const STARTER = 'plane-starter'; // the seeded plane's id (lib/game/seed.ts)
|
||||||
|
const BUY_MODEL = 'atr72'; // cheapest catalog entry — the second plane every test buys
|
||||||
|
|
||||||
|
interface PlaneState {
|
||||||
|
id: string;
|
||||||
|
model: string;
|
||||||
|
status: 'available' | 'flying';
|
||||||
|
fuel: number;
|
||||||
|
maxFuel: number;
|
||||||
|
locationIcao: string;
|
||||||
|
destIcao: string | null;
|
||||||
|
queue: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PackageState {
|
||||||
|
id: string;
|
||||||
|
originIcao: string;
|
||||||
|
destIcao: string;
|
||||||
|
status: 'open' | 'loaded' | 'delivered' | 'expired' | 'abandoned' | 'ignored';
|
||||||
|
planeId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CatalogEntry {
|
||||||
|
model: string;
|
||||||
|
label: string;
|
||||||
|
price: number;
|
||||||
|
maxFuel: number;
|
||||||
|
burnPerKm: number;
|
||||||
|
emptyWeightKg: number;
|
||||||
|
speedKmh: number;
|
||||||
|
maxPayloadKg: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StateBody {
|
||||||
|
account: { cash: number; reputation: number };
|
||||||
|
simState: { clockMs: number; timeScale: number; paused: boolean };
|
||||||
|
planes: PlaneState[];
|
||||||
|
packages: PackageState[];
|
||||||
|
catalog: CatalogEntry[];
|
||||||
|
fuelPricePerL: number;
|
||||||
|
sellFraction: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mirrors formatCash in lib/format.ts (same Intl settings → identical strings).
|
||||||
|
const eur = (v: number) => new Intl.NumberFormat('en-US', { style: 'currency', currency: 'EUR' }).format(v);
|
||||||
|
|
||||||
|
async function getState(page: Page): Promise<StateBody> {
|
||||||
|
const res = await page.request.get('/api/state');
|
||||||
|
expect(res.status()).toBe(200);
|
||||||
|
return (await res.json()) as StateBody;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function postSim(page: Page, body: unknown) {
|
||||||
|
return page.request.post('/api/sim', { data: body });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set the time scale and VERIFY from the response that it stuck. Also records lastSelectedTimeScale
|
||||||
|
// (the ISS-057 resume target) and clears pause — so any fast-forward after a slowed-to-1× landing
|
||||||
|
// proceeds at full speed.
|
||||||
|
async function setScale(page: Page, scale: number): Promise<void> {
|
||||||
|
const res = await page.request.post('/api/sim', { data: { timeScale: scale, paused: false } });
|
||||||
|
expect(res.status()).toBe(200);
|
||||||
|
const body = (await res.json()) as { timeScale: number };
|
||||||
|
expect(body.timeScale, `timeScale should be ${scale} after POST /api/sim`).toBe(scale);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mirrors haversineKm in lib/geo.ts (same formula → identical floats).
|
||||||
|
function haversineKm(lat1: number, lon1: number, lat2: number, lon2: number): number {
|
||||||
|
const toRad = (d: number) => (d * Math.PI) / 180;
|
||||||
|
const dLat = toRad(lat2 - lat1);
|
||||||
|
const dLon = toRad(lon2 - lon1);
|
||||||
|
const a =
|
||||||
|
Math.sin(dLat / 2) ** 2 +
|
||||||
|
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2;
|
||||||
|
return 2 * 6371 * Math.asin(Math.min(1, Math.sqrt(a)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mirrors lib/sim/movement.ts (v0.16.0): fuel required to cover distanceKm arriving empty —
|
||||||
|
// closed form of the weight-proportional burn model, k calibrated so a full tank burns burnPerKm.
|
||||||
|
const FUEL_DENSITY_KG_PER_L = 0.8; // must match FUEL_DENSITY_KG_PER_L in lib/sim/movement.ts
|
||||||
|
function requiredFuelFor(burnPerKm: number, emptyWeightKg: number, maxFuel: number, distanceKm: number): number {
|
||||||
|
const k = burnPerKm / (emptyWeightKg + FUEL_DENSITY_KG_PER_L * maxFuel);
|
||||||
|
return (emptyWeightKg / FUEL_DENSITY_KG_PER_L) * (Math.exp(k * FUEL_DENSITY_KG_PER_L * distanceKm) - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Two CLOSEST airports to the hub — short, full-tank-flyable legs on distinct destinations.
|
||||||
|
async function twoNearestToHub(page: Page): Promise<Array<{ icao: string; lat: number; lon: number }>> {
|
||||||
|
const fc = (await (await page.request.get('/api/airports')).json()) as {
|
||||||
|
features: Array<{ properties: { icao: string; lat: number; lon: number } }>;
|
||||||
|
};
|
||||||
|
const hub = fc.features.find((f) => f.properties.icao === HUB)!.properties;
|
||||||
|
return fc.features
|
||||||
|
.filter((f) => f.properties.icao !== HUB)
|
||||||
|
.map((f) => ({ ...f.properties, d: haversineKm(hub.lat, hub.lon, f.properties.lat, f.properties.lon) }))
|
||||||
|
.sort((x, y) => x.d - y.d)
|
||||||
|
.slice(0, 2)
|
||||||
|
.map(({ icao, lat, lon }) => ({ icao, lat, lon }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Farthest airport from EDDF whose leg still fits a FULL ATR tank — the longest flyable leg. The
|
||||||
|
// dataset's absolute farthest can exceed range (a full-tank fly there would rightly 409). Wind-
|
||||||
|
// adjusted (v0.36.0): BOTH directions must fit — the outbound is flown from a full tank and the
|
||||||
|
// return leg is flown after a top-up, which also 409s if it doesn't fit.
|
||||||
|
async function farthestFlyableToHub(page: Page): Promise<{ icao: string; lat: number; lon: number }> {
|
||||||
|
const entry = (await getState(page)).catalog.find((e) => e.model === BUY_MODEL)!;
|
||||||
|
const fc = (await (await page.request.get('/api/airports')).json()) as {
|
||||||
|
features: Array<{ properties: { icao: string; lat: number; lon: number } }>;
|
||||||
|
};
|
||||||
|
const hub = fc.features.find((f) => f.properties.icao === HUB)!.properties;
|
||||||
|
const wHub = weatherFor(HUB);
|
||||||
|
let farthest: { icao: string; lat: number; lon: number; d: number } | null = null;
|
||||||
|
for (const f of fc.features) {
|
||||||
|
if (f.properties.icao === HUB) continue;
|
||||||
|
const d = haversineKm(hub.lat, hub.lon, f.properties.lat, f.properties.lon);
|
||||||
|
const wDest = weatherFor(f.properties.icao);
|
||||||
|
const outBurn = entry.burnPerKm * windBurnFactor(wHub.windKt, wHub.windDirDeg, initialBearingDeg(hub.lat, hub.lon, f.properties.lat, f.properties.lon));
|
||||||
|
if (requiredFuelFor(outBurn, entry.emptyWeightKg, entry.maxFuel, d) > entry.maxFuel) continue; // beyond range
|
||||||
|
const backBurn = entry.burnPerKm * windBurnFactor(wDest.windKt, wDest.windDirDeg, initialBearingDeg(f.properties.lat, f.properties.lon, hub.lat, hub.lon));
|
||||||
|
if (requiredFuelFor(backBurn, entry.emptyWeightKg, entry.maxFuel, d) > entry.maxFuel) continue; // return leg wouldn't fit a full tank either
|
||||||
|
if (!farthest || d > farthest.d) {
|
||||||
|
farthest = { icao: f.properties.icao, lat: f.properties.lat, lon: f.properties.lon, d };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!farthest) throw new Error('no full-tank-flyable airport found in the dataset');
|
||||||
|
return { icao: farthest.icao, lat: farthest.lat, lon: farthest.lon };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test hook (FLIGHT_TEST_HOOKS=1): create an open package on a known route with a forced weight.
|
||||||
|
async function createPackage(
|
||||||
|
page: Page,
|
||||||
|
fromIcao: string,
|
||||||
|
toIcao: string,
|
||||||
|
deadlineOffsetMs?: number,
|
||||||
|
weightKg?: number,
|
||||||
|
): Promise<PackageState> {
|
||||||
|
const created = await page.request.post('/api/test/create-package', {
|
||||||
|
data: { originIcao: fromIcao, destIcao: toIcao, deadlineOffsetMs, weightKg },
|
||||||
|
});
|
||||||
|
expect(created.status()).toBe(201);
|
||||||
|
return (await created.json()) as PackageState;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test hook (FLIGHT_TEST_HOOKS=1): grant cash so a second plane is affordable. Returns the full
|
||||||
|
// post-grant snapshot (the route's response).
|
||||||
|
async function addCash(page: Page, amount?: number): Promise<StateBody> {
|
||||||
|
const res = await page.request.post('/api/test/add-cash', { data: amount === undefined ? {} : { amount } });
|
||||||
|
expect(res.status()).toBe(200);
|
||||||
|
return (await res.json()) as StateBody;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Buy a second ATR through the UI (Planes market tab) and return its id — discovered in /api/state
|
||||||
|
// as the plane that isn't plane-starter (bought planes get random UUIDs, no stable name).
|
||||||
|
async function buySecondPlaneViaUi(page: Page): Promise<string> {
|
||||||
|
await addCash(page); // default €1M grant — covers the ATR's €200k price regardless of prior cash
|
||||||
|
|
||||||
|
await page.goto('/');
|
||||||
|
const right = page.getByTestId('right-pane');
|
||||||
|
await expect(right).toBeVisible({ timeout: 15_000 });
|
||||||
|
await page.getByTestId('tab-planes-market').click();
|
||||||
|
|
||||||
|
const buyModel = page.getByTestId('market-buy-model');
|
||||||
|
await expect(buyModel, 'the market model picker appears').toBeVisible({ timeout: 15_000 });
|
||||||
|
await buyModel.selectOption(BUY_MODEL);
|
||||||
|
await page.getByTestId('market-buy').click();
|
||||||
|
|
||||||
|
let secondId: string | null = null;
|
||||||
|
await expect
|
||||||
|
.poll(async () => {
|
||||||
|
const s = await getState(page);
|
||||||
|
secondId = s.planes.find((p) => p.id !== STARTER)?.id ?? null;
|
||||||
|
return secondId !== null;
|
||||||
|
}, { timeout: 15_000, message: 'waiting for the bought plane to appear in /api/state' })
|
||||||
|
.toBe(true);
|
||||||
|
return secondId!;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cancel every queued leg of a plane via the API (works while flying — the server checks no status).
|
||||||
|
async function clearQueueViaApi(page: Page, planeId: string): Promise<void> {
|
||||||
|
for (;;) {
|
||||||
|
const q = (await getState(page)).planes.find((p) => p.id === planeId)?.queue ?? [];
|
||||||
|
if (q.length === 0) return;
|
||||||
|
expect((await page.request.post(`/api/planes/${planeId}/queue/cancel`, { data: { index: 0 } })).status()).toBe(200);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Abandon leftover loaded/expired cargo on a GROUNDED plane — the sell guard rejects planes
|
||||||
|
// carrying cargo, and clean baselines keep exact-cash assertions honest.
|
||||||
|
async function abandonLeftoverCargo(page: Page, planeId: string): Promise<void> {
|
||||||
|
const s = await getState(page);
|
||||||
|
for (const pkg of s.packages) {
|
||||||
|
if (pkg.planeId === planeId && (pkg.status === 'loaded' || pkg.status === 'expired')) {
|
||||||
|
expect((await page.request.post(`/api/packages/${pkg.id}/abandon`)).ok()).toBeTruthy();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Heal one plane to "grounded at the hub": clear its queue first (no auto-chain on landing),
|
||||||
|
// fast-forward any in-flight leg, then reposition home. Same pattern as flyHomeViaApi in the other
|
||||||
|
// specs, generalized to an arbitrary plane id.
|
||||||
|
async function healPlaneHome(page: Page, planeId: string): Promise<void> {
|
||||||
|
await clearQueueViaApi(page, planeId);
|
||||||
|
let p = (await getState(page)).planes.find((x) => x.id === planeId);
|
||||||
|
if (!p) return; // already sold — nothing to heal
|
||||||
|
// ISS-057: a prior landing may have slowed the sim to 1× — restore 1000× first so any in-flight leg
|
||||||
|
// completes fast. Re-applied inside the poll below, since a sibling's landing mid-wait slows it again.
|
||||||
|
await setScale(page, SCALE);
|
||||||
|
if (p.status === 'flying') {
|
||||||
|
// While flying, locationIcao stays at the DEPARTURE airport — re-read state after landing.
|
||||||
|
await expect
|
||||||
|
.poll(async () => {
|
||||||
|
const s = await getState(page);
|
||||||
|
const plane = s.planes.find((x) => x.id === planeId);
|
||||||
|
if (plane?.status === 'flying' && s.simState.timeScale !== SCALE) await setScale(page, SCALE); // sibling landing re-slowed to 1×
|
||||||
|
return plane?.status ?? '';
|
||||||
|
}, {
|
||||||
|
timeout: 240_000,
|
||||||
|
message: `waiting for an in-flight ${planeId} to land`,
|
||||||
|
})
|
||||||
|
.toBe('available');
|
||||||
|
p = (await getState(page)).planes.find((x) => x.id === planeId)!;
|
||||||
|
}
|
||||||
|
if (p.locationIcao !== HUB) {
|
||||||
|
// ISS-057: the landing above slowed the sim to 1× — setScale restores 1000× for the return leg.
|
||||||
|
await setScale(page, SCALE);
|
||||||
|
let flyHome = await page.request.post(`/api/planes/${planeId}/fly`, { data: { destIcao: HUB } });
|
||||||
|
if (flyHome.status() === 409) {
|
||||||
|
// A long outbound leg can leave the tank too empty for the return — top up and retry.
|
||||||
|
expect((await page.request.post(`/api/planes/${planeId}/refuel`)).status()).toBe(200);
|
||||||
|
flyHome = await page.request.post(`/api/planes/${planeId}/fly`, { data: { destIcao: HUB } });
|
||||||
|
}
|
||||||
|
expect(flyHome.status()).toBe(200);
|
||||||
|
await expect
|
||||||
|
.poll(async () => ((await getState(page)).planes.find((x) => x.id === planeId)?.locationIcao ?? ''), {
|
||||||
|
timeout: 240_000,
|
||||||
|
message: `waiting for ${planeId} to land back at the hub`,
|
||||||
|
})
|
||||||
|
.toBe(HUB);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sell every non-starter plane (healing it home first if an interrupted run left it elsewhere or
|
||||||
|
// in flight) — leaves the fleet at exactly one plane.
|
||||||
|
async function sellNonStarters(page: Page): Promise<void> {
|
||||||
|
const s = await getState(page);
|
||||||
|
for (const p of s.planes.filter((x) => x.id !== STARTER)) {
|
||||||
|
await healPlaneHome(page, p.id); // grounded + at the hub before cargo can be abandoned / it sold
|
||||||
|
await abandonLeftoverCargo(page, p.id);
|
||||||
|
expect((await page.request.post('/api/market/sell-plane', { data: { planeId: p.id } })).status()).toBe(200);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Canonical start state for every test: fleet of exactly one plane (the starter), grounded at EDDF,
|
||||||
|
// no cargo on board — even after an interrupted previous run.
|
||||||
|
async function resetFleet(page: Page): Promise<void> {
|
||||||
|
// v0.39.0 (ISS-069): wear accumulates across the shared CI DB — restore full health and clear any
|
||||||
|
// active repair gate before healing, so the farthest-flyable picker never sees a worn/broken starter.
|
||||||
|
expect((await page.request.post('/api/test/set-health', { data: { planeId: STARTER, health: 100, repairUntilMs: 0 } })).status()).toBe(200);
|
||||||
|
await sellNonStarters(page);
|
||||||
|
await healPlaneHome(page, STARTER); // the tick inside its getState flips a shortened gate back to available
|
||||||
|
await abandonLeftoverCargo(page, STARTER);
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe.configure({ mode: 'serial' }); // shared live DB — order matters within this file
|
||||||
|
|
||||||
|
// Leave the fleet at exactly one plane and restore sane sim controls for the other specs.
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await resetFleet(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterEach(async ({ page }) => {
|
||||||
|
await sellNonStarters(page); // a failure mid-test may have left the second plane behind (even in flight)
|
||||||
|
const res = await postSim(page, { timeScale: 1, paused: false });
|
||||||
|
expect(res.status()).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('two grounded planes: Load is one-click with a preselected plane; the picker routes to the other', async ({ page }) => {
|
||||||
|
test.setTimeout(300_000); // self-heal can take minutes after an interrupted run
|
||||||
|
const secondId = await buySecondPlaneViaUi(page); // both grounded at EDDF — bought planes spawn at HUB
|
||||||
|
await page.getByTestId('tab-jobs').click(); // purchase left us on Planes-market; package rows live in Jobs (tabs unmount inactive content)
|
||||||
|
|
||||||
|
const [nearest] = await twoNearestToHub(page);
|
||||||
|
// Forced light weight (500 kg) so the package fits BOTH ATRs' payload deterministically.
|
||||||
|
const pkg1 = await createPackage(page, HUB, nearest.icao, 24 * 3_600_000, 500);
|
||||||
|
|
||||||
|
// The multi-plane picker lists BOTH planes…
|
||||||
|
const picker = page.getByTestId(`load-plane-${pkg1.id}`);
|
||||||
|
await expect(picker).toBeVisible({ timeout: 15_000 });
|
||||||
|
await expect(picker.locator(`option[value="${STARTER}"]`)).toHaveCount(1, { timeout: 15_000 });
|
||||||
|
await expect(picker.locator(`option[value="${secondId}"]`)).toHaveCount(1);
|
||||||
|
|
||||||
|
// …and PRESELECTS the first eligible plane — Load is enabled without touching the select (ISS-055).
|
||||||
|
const s0 = await getState(page);
|
||||||
|
const firstEligible = s0.planes.find((p) => p.status === 'available' && p.locationIcao === HUB)!;
|
||||||
|
await expect(picker, 'the picker preselects the first eligible plane').toHaveValue(firstEligible.id);
|
||||||
|
const loadBtn1 = page.getByTestId(`load-${pkg1.id}`);
|
||||||
|
await expect(loadBtn1, 'Load is enabled without a manual pick').toBeEnabled();
|
||||||
|
|
||||||
|
// One-click Load binds the package to the preselected (first eligible) plane.
|
||||||
|
await loadBtn1.click();
|
||||||
|
await expect
|
||||||
|
.poll(async () => {
|
||||||
|
const p = (await getState(page)).packages.find((x) => x.id === pkg1.id);
|
||||||
|
return !!p && p.status === 'loaded' ? p.planeId : null;
|
||||||
|
}, { timeout: 15_000, message: 'waiting for the package to load onto a plane' })
|
||||||
|
.toBe(firstEligible.id);
|
||||||
|
|
||||||
|
// Second package — pick the OTHER plane explicitly via the dropdown.
|
||||||
|
const pkg2 = await createPackage(page, HUB, nearest.icao, 24 * 3_600_000, 500);
|
||||||
|
const picker2 = page.getByTestId(`load-plane-${pkg2.id}`);
|
||||||
|
await expect(picker2).toBeVisible({ timeout: 15_000 });
|
||||||
|
await picker2.selectOption(secondId);
|
||||||
|
await page.getByTestId(`load-${pkg2.id}`).click();
|
||||||
|
await expect
|
||||||
|
.poll(async () => {
|
||||||
|
const p = (await getState(page)).packages.find((x) => x.id === pkg2.id);
|
||||||
|
return !!p && p.status === 'loaded' ? p.planeId : null;
|
||||||
|
}, { timeout: 15_000, message: 'waiting for the second package to load onto a plane' })
|
||||||
|
.toBe(secondId);
|
||||||
|
|
||||||
|
// Both planes carry cargo simultaneously — both fleet rows render their cargo (ISS-054).
|
||||||
|
await expect(page.getByTestId(`plane-${STARTER}`), 'the starter row renders').toBeVisible({ timeout: 15_000 });
|
||||||
|
await expect(page.getByTestId(`plane-${secondId}`), 'the second plane row renders').toBeVisible({ timeout: 15_000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('two planes fly in parallel; the last landing slows to 1× and the next dispatch resumes', async ({ page }) => {
|
||||||
|
test.setTimeout(300_000); // self-heal + one long leg each way at scale 1000
|
||||||
|
const secondId = await buySecondPlaneViaUi(page);
|
||||||
|
|
||||||
|
// Full tanks for both — the starter's may be partial from an earlier run.
|
||||||
|
expect((await page.request.post(`/api/planes/${STARTER}/refuel`)).status()).toBe(200);
|
||||||
|
expect((await page.request.post(`/api/planes/${secondId}/refuel`)).status()).toBe(200);
|
||||||
|
|
||||||
|
// Two very different leg lengths: the starter takes the SHORTEST hop, the second plane the longest
|
||||||
|
// full-tank-flyable one — their landings are separated by hours of game time and can never fall on
|
||||||
|
// the same tick (ISS-057 slows to 1× on ANY landing, so a shared-tick arrival would race the asserts).
|
||||||
|
const [nearest] = await twoNearestToHub(page);
|
||||||
|
const farthest = await farthestFlyableToHub(page);
|
||||||
|
|
||||||
|
const pkgA = await createPackage(page, HUB, nearest.icao, 24 * 3_600_000, 500);
|
||||||
|
const pkgB = await createPackage(page, HUB, farthest.icao, 24 * 3_600_000, 500);
|
||||||
|
expect((await page.request.post(`/api/packages/${pkgA.id}/load`, { data: { planeId: STARTER } })).status()).toBe(200);
|
||||||
|
expect((await page.request.post(`/api/packages/${pkgB.id}/load`, { data: { planeId: secondId } })).status()).toBe(200);
|
||||||
|
|
||||||
|
const flyA = await page.request.post(`/api/planes/${STARTER}/fly`, { data: { destIcao: nearest.icao } });
|
||||||
|
expect(flyA.status()).toBe(200);
|
||||||
|
const flyB = await page.request.post(`/api/planes/${secondId}/fly`, { data: { destIcao: farthest.icao } });
|
||||||
|
expect(flyB.status()).toBe(200);
|
||||||
|
|
||||||
|
// Both are flying to their destinations (the second /fly response snapshot is authoritative).
|
||||||
|
const s1 = (await flyB.json()) as StateBody;
|
||||||
|
expect(s1.planes.find((p) => p.id === STARTER)?.status).toBe('flying');
|
||||||
|
expect(s1.planes.find((p) => p.id === STARTER)?.destIcao).toBe(nearest.icao);
|
||||||
|
expect(s1.planes.find((p) => p.id === secondId)?.status).toBe('flying');
|
||||||
|
expect(s1.planes.find((p) => p.id === secondId)?.destIcao).toBe(farthest.icao);
|
||||||
|
|
||||||
|
// Fast-forward to the FIRST landing (the short hop)…
|
||||||
|
await setScale(page, SCALE);
|
||||||
|
await expect
|
||||||
|
.poll(async () => ((await getState(page)).planes.some((p) => p.status === 'available')), {
|
||||||
|
timeout: 240_000,
|
||||||
|
message: 'waiting for the first plane to land',
|
||||||
|
})
|
||||||
|
.toBe(true);
|
||||||
|
|
||||||
|
// …restore speed (that landing slowed the sim to 1× — ISS-057) so the long leg fast-forwards too.
|
||||||
|
await setScale(page, SCALE);
|
||||||
|
await expect
|
||||||
|
.poll(async () => ((await getState(page)).planes.every((p) => p.status === 'available')), {
|
||||||
|
timeout: 240_000,
|
||||||
|
message: 'waiting for both planes to land',
|
||||||
|
})
|
||||||
|
.toBe(true);
|
||||||
|
|
||||||
|
// ISS-057: the LAST landing left a grounded plane → the sim slowed to 1× WITHOUT pausing.
|
||||||
|
const landed = await getState(page);
|
||||||
|
expect(landed.simState.paused).toBe(false);
|
||||||
|
expect(landed.simState.timeScale).toBe(1);
|
||||||
|
// Both packages delivered on arrival (in deadline — 24h window vs hours of flight time).
|
||||||
|
expect(landed.packages.find((p) => p.id === pkgA.id)?.status).toBe('delivered');
|
||||||
|
expect(landed.packages.find((p) => p.id === pkgB.id)?.status).toBe('delivered');
|
||||||
|
|
||||||
|
// The outbound to the range edge left the tank nearly empty — top up for the return leg.
|
||||||
|
expect((await page.request.post(`/api/planes/${secondId}/refuel`)).status()).toBe(200);
|
||||||
|
|
||||||
|
// Resume semantics: with the starter still grounded, dispatching the second plane does NOT resume…
|
||||||
|
const flyB2 = await page.request.post(`/api/planes/${secondId}/fly`, { data: { destIcao: HUB } });
|
||||||
|
expect(flyB2.status()).toBe(200);
|
||||||
|
let body = (await flyB2.json()) as StateBody;
|
||||||
|
expect(body.simState.timeScale, 'another plane still grounded → stays at 1×').toBe(1);
|
||||||
|
|
||||||
|
// …but dispatching the LAST grounded plane restores the last manually selected speed.
|
||||||
|
const flyA2 = await page.request.post(`/api/planes/${STARTER}/fly`, { data: { destIcao: HUB } });
|
||||||
|
expect(flyA2.status()).toBe(200);
|
||||||
|
body = (await flyA2.json()) as StateBody;
|
||||||
|
expect(body.simState.timeScale, 'the last grounded plane resumes lastSelectedTimeScale').toBe(SCALE); // setScale posted 1000
|
||||||
|
});
|
||||||
|
|
||||||
|
test('refuel section lists both planes; refueling one tops only its tank', async ({ page }) => {
|
||||||
|
test.setTimeout(300_000); // self-heal + two short hops at scale 1000
|
||||||
|
const secondId = await buySecondPlaneViaUi(page);
|
||||||
|
|
||||||
|
// Partial tanks: a short hop each, to different destinations.
|
||||||
|
const [a, b] = await twoNearestToHub(page);
|
||||||
|
expect((await page.request.post(`/api/planes/${STARTER}/refuel`)).status()).toBe(200);
|
||||||
|
expect((await page.request.post(`/api/planes/${secondId}/refuel`)).status()).toBe(200);
|
||||||
|
expect((await page.request.post(`/api/planes/${STARTER}/fly`, { data: { destIcao: a.icao } })).status()).toBe(200);
|
||||||
|
expect((await page.request.post(`/api/planes/${secondId}/fly`, { data: { destIcao: b.icao } })).status()).toBe(200);
|
||||||
|
|
||||||
|
// Fast-forward to the first landing, restore speed (ISS-057 slows on any landing), then wait for both.
|
||||||
|
await setScale(page, SCALE);
|
||||||
|
await expect
|
||||||
|
.poll(async () => ((await getState(page)).planes.some((p) => p.status === 'available')), {
|
||||||
|
timeout: 240_000,
|
||||||
|
message: 'waiting for the first plane to land',
|
||||||
|
})
|
||||||
|
.toBe(true);
|
||||||
|
await setScale(page, SCALE);
|
||||||
|
await expect
|
||||||
|
.poll(async () => ((await getState(page)).planes.every((p) => p.status === 'available')), {
|
||||||
|
timeout: 240_000,
|
||||||
|
message: 'waiting for both planes to land',
|
||||||
|
})
|
||||||
|
.toBe(true);
|
||||||
|
|
||||||
|
const pre = await getState(page);
|
||||||
|
const starter = pre.planes.find((p) => p.id === STARTER)!;
|
||||||
|
const second = pre.planes.find((p) => p.id === secondId)!;
|
||||||
|
expect(starter.fuel, 'the hop burned fuel').toBeLessThan(starter.maxFuel);
|
||||||
|
expect(second.fuel, 'the hop burned fuel').toBeLessThan(second.maxFuel);
|
||||||
|
|
||||||
|
// The left-pane Refuel section lists BOTH grounded planes with partial tanks…
|
||||||
|
const refuelSelect = page.getByTestId('market-refuel-plane');
|
||||||
|
await expect(refuelSelect).toBeVisible({ timeout: 15_000 });
|
||||||
|
await expect(refuelSelect.locator(`option[value="${STARTER}"]`)).toHaveCount(1);
|
||||||
|
await expect(refuelSelect.locator(`option[value="${secondId}"]`)).toHaveCount(1);
|
||||||
|
|
||||||
|
// …select the SECOND plane — the preview is its exact fill-up cost (mirrors refuelPlane 'full'),
|
||||||
|
// rep-scaled (v0.38.0, ISS-072) from the live snapshot's reputation.
|
||||||
|
const fuelRate = repRate(pre.account.reputation, FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT);
|
||||||
|
const expectedCost = Math.round((second.maxFuel - second.fuel) * pre.fuelPricePerL * (1 - fuelRate) * 100) / 100;
|
||||||
|
await refuelSelect.selectOption(secondId);
|
||||||
|
await expect(page.getByTestId('market-refuel-cost')).toContainText(eur(expectedCost));
|
||||||
|
|
||||||
|
await page.getByTestId('market-refuel').click();
|
||||||
|
await expect
|
||||||
|
.poll(async () => {
|
||||||
|
const s = await getState(page);
|
||||||
|
return Math.abs(s.account.cash - (pre.account.cash - expectedCost)) < 0.001;
|
||||||
|
}, { timeout: 15_000, message: 'waiting for the refuel cost to land in /api/state' })
|
||||||
|
.toBe(true);
|
||||||
|
|
||||||
|
const post = await getState(page);
|
||||||
|
expect(post.planes.find((p) => p.id === secondId)!.fuel, 'that tank is full').toBe(second.maxFuel);
|
||||||
|
expect(post.planes.find((p) => p.id === STARTER)!.fuel, 'the other tank is untouched').toBe(starter.fuel);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('relocate section moves one plane without touching the other', async ({ page }) => {
|
||||||
|
test.setTimeout(300_000); // self-heal + one short hop at scale 1000
|
||||||
|
const secondId = await buySecondPlaneViaUi(page); // both grounded at EDDF
|
||||||
|
|
||||||
|
const [a] = await twoNearestToHub(page); // relocation destination for the second plane
|
||||||
|
|
||||||
|
// The Relocate section lists BOTH grounded planes…
|
||||||
|
const relocateSelect = page.getByTestId('relocate-plane');
|
||||||
|
await expect(relocateSelect).toBeVisible({ timeout: 15_000 });
|
||||||
|
await expect(relocateSelect.locator(`option[value="${STARTER}"]`)).toHaveCount(1);
|
||||||
|
await expect(relocateSelect.locator(`option[value="${secondId}"]`)).toHaveCount(1);
|
||||||
|
|
||||||
|
// LeftPane's own /api/airports fetch populates the destination select — wait for it.
|
||||||
|
const destSelect = page.getByTestId('relocate-dest');
|
||||||
|
await expect(destSelect.locator(`option[value="${a.icao}"]`)).toHaveCount(1, { timeout: 15_000 });
|
||||||
|
|
||||||
|
// Relocate ONLY the second plane…
|
||||||
|
await relocateSelect.selectOption(secondId);
|
||||||
|
await destSelect.selectOption(a.icao);
|
||||||
|
await page.getByTestId('relocate-go').click();
|
||||||
|
|
||||||
|
// …it is flying to the new airport while the starter stays put at EDDF.
|
||||||
|
await expect
|
||||||
|
.poll(async () => {
|
||||||
|
const s = await getState(page);
|
||||||
|
return (s.planes.find((p) => p.id === secondId)?.status ?? '') === 'flying' &&
|
||||||
|
(s.planes.find((p) => p.id === STARTER)?.locationIcao ?? '') === HUB;
|
||||||
|
}, { timeout: 15_000, message: 'waiting for the relocation to take effect' })
|
||||||
|
.toBe(true);
|
||||||
|
|
||||||
|
// Let it land — locationIcao flips to the destination on arrival.
|
||||||
|
await setScale(page, SCALE);
|
||||||
|
await expect
|
||||||
|
.poll(async () => ((await getState(page)).planes.find((p) => p.id === secondId)?.locationIcao ?? ''), {
|
||||||
|
timeout: 240_000,
|
||||||
|
message: `waiting for the relocated plane to land at ${a.icao}`,
|
||||||
|
})
|
||||||
|
.toBe(a.icao);
|
||||||
|
|
||||||
|
const s = await getState(page);
|
||||||
|
expect(s.planes.find((p) => p.id === secondId)!.status).toBe('available');
|
||||||
|
expect(s.planes.find((p) => p.id === STARTER)!.locationIcao, 'the starter is untouched').toBe(HUB);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('per-plane queues are independent; landing auto-chains the next queued leg', async ({ page }) => {
|
||||||
|
test.setTimeout(300_000); // self-heal + two chained legs at scale 1000
|
||||||
|
const secondId = await buySecondPlaneViaUi(page);
|
||||||
|
const [a, b] = await twoNearestToHub(page);
|
||||||
|
|
||||||
|
// Full tank for the starter — it flies both legs (EDDF→a then the auto-chained a→b).
|
||||||
|
expect((await page.request.post(`/api/planes/${STARTER}/refuel`)).status()).toBe(200);
|
||||||
|
|
||||||
|
// Seed one queued leg per plane via the API so BOTH on-board blocks render…
|
||||||
|
expect((await page.request.post(`/api/planes/${STARTER}/queue`, { data: { destIcao: a.icao } })).status()).toBe(200);
|
||||||
|
expect((await page.request.post(`/api/planes/${secondId}/queue`, { data: { destIcao: b.icao } })).status()).toBe(200);
|
||||||
|
|
||||||
|
// …each block shows its own seeded leg row under plane-scoped testids (ISS-055)…
|
||||||
|
const starterLeg0 = page.getByTestId(`queue-leg-${STARTER}-0`);
|
||||||
|
await expect(starterLeg0, 'the starter block renders its queued leg').toContainText(a.icao, { timeout: 15_000 });
|
||||||
|
await expect(page.getByTestId(`queue-leg-${secondId}-0`), 'the second block renders its queued leg').toContainText(b.icao);
|
||||||
|
|
||||||
|
// …and ADD a second leg to the STARTER via the API — the dedicated queue-add UI was removed in
|
||||||
|
// ISS-053 (fly-to / Relocate cover it), and the starter is grounded here so fly-to would start, not queue.
|
||||||
|
expect((await page.request.post(`/api/planes/${STARTER}/queue`, { data: { destIcao: b.icao } })).status()).toBe(200);
|
||||||
|
await expect(page.getByTestId(`queue-leg-${STARTER}-1`), 'the second leg is queued on the STARTER').toContainText(b.icao, { timeout: 15_000 });
|
||||||
|
|
||||||
|
// CANCEL the only leg on the SECOND plane — its queue empties, the starter's stays intact.
|
||||||
|
await page.getByTestId(`queue-cancel-${secondId}-0`).click();
|
||||||
|
await expect(page.getByTestId(`queue-leg-${secondId}-0`), 'the cancelled leg is gone').toHaveCount(0, { timeout: 15_000 });
|
||||||
|
await expect(starterLeg0, 'the starter queue is untouched by the other plane cancel').toContainText(a.icao);
|
||||||
|
|
||||||
|
// Fly the STARTER to its first queued destination…
|
||||||
|
const flyRes = await page.request.post(`/api/planes/${STARTER}/fly`, { data: { destIcao: a.icao } });
|
||||||
|
expect(flyRes.status()).toBe(200);
|
||||||
|
|
||||||
|
// …fast-forward; on landing at a the server AUTO-CHAINS queue[0] (b) in the same tick — but with
|
||||||
|
// the second plane still grounded, ISS-057 slows the sim to 1× on that landing. Wait for the chain…
|
||||||
|
await setScale(page, SCALE);
|
||||||
|
await expect
|
||||||
|
.poll(async () => {
|
||||||
|
const p = (await getState(page)).planes.find((x) => x.id === STARTER);
|
||||||
|
return !!p && p.status === 'flying' && p.destIcao === b.icao;
|
||||||
|
}, { timeout: 240_000, message: `waiting for the auto-chained leg ${a} → ${b} to start` })
|
||||||
|
.toBe(true);
|
||||||
|
|
||||||
|
// …then restore speed so the chained leg fast-forwards too.
|
||||||
|
await setScale(page, SCALE);
|
||||||
|
await expect
|
||||||
|
.poll(async () => {
|
||||||
|
const p = (await getState(page)).planes.find((x) => x.id === STARTER);
|
||||||
|
return !!p && p.status === 'available' && p.locationIcao === b.icao;
|
||||||
|
}, { timeout: 240_000, message: `waiting for the chained leg to land at ${b}` })
|
||||||
|
.toBe(true);
|
||||||
|
|
||||||
|
// The second plane never moved — still grounded at EDDF with an empty queue.
|
||||||
|
const s = await getState(page);
|
||||||
|
expect(s.planes.find((p) => p.id === secondId)!.status).toBe('available');
|
||||||
|
expect(s.planes.find((p) => p.id === secondId)!.locationIcao).toBe(HUB);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sell section lists both planes; selling one pays out and the last plane is protected', async ({ page }) => {
|
||||||
|
test.setTimeout(300_000); // self-heal can take minutes after an interrupted run
|
||||||
|
const secondId = await buySecondPlaneViaUi(page); // both grounded at EDDF
|
||||||
|
|
||||||
|
const pre = await getState(page); // authoritative catalog + economy constants (v0.10.0)
|
||||||
|
const entry = pre.catalog.find((e) => e.model === BUY_MODEL)!;
|
||||||
|
const payout = Math.round(entry.price * pre.sellFraction * 100) / 100;
|
||||||
|
|
||||||
|
// The Sell row lists BOTH grounded planes…
|
||||||
|
const sellSelect = page.getByTestId('market-sell-plane');
|
||||||
|
await expect(sellSelect).toBeVisible({ timeout: 15_000 });
|
||||||
|
await expect(sellSelect.locator(`option[value="${STARTER}"]`)).toHaveCount(1);
|
||||||
|
await expect(sellSelect.locator(`option[value="${secondId}"]`)).toHaveCount(1);
|
||||||
|
|
||||||
|
// …sell the SECOND one — payout is SELL_FRACTION × price, fleet back to one plane.
|
||||||
|
await sellSelect.selectOption(secondId);
|
||||||
|
await expect(page.getByTestId('market-sell-payout')).toContainText(eur(payout));
|
||||||
|
await page.getByTestId('market-sell').click();
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(async () => {
|
||||||
|
const s = await getState(page);
|
||||||
|
return Math.abs(s.account.cash - (pre.account.cash + payout)) < 0.001 && s.planes.length === 1;
|
||||||
|
}, { timeout: 15_000, message: 'waiting for the sell to land in /api/state' })
|
||||||
|
.toBe(true);
|
||||||
|
|
||||||
|
// The last remaining plane can never be sold — even via the API.
|
||||||
|
const res = await page.request.post('/api/market/sell-plane', { data: { planeId: STARTER } });
|
||||||
|
expect(res.status()).toBe(409);
|
||||||
|
});
|
||||||
@@ -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 () => {
|
||||||
|
|||||||
+37
-3
@@ -30,7 +30,10 @@ 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 '[]',
|
||||||
|
health REAL NOT NULL DEFAULT 100,
|
||||||
|
repair_until_sim_time INTEGER
|
||||||
);
|
);
|
||||||
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 +42,8 @@ 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,
|
||||||
|
special TEXT,
|
||||||
deadline_sim_time INTEGER NOT NULL,
|
deadline_sim_time INTEGER NOT NULL,
|
||||||
status TEXT NOT NULL,
|
status TEXT NOT NULL,
|
||||||
plane_id TEXT,
|
plane_id TEXT,
|
||||||
@@ -52,7 +57,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 +78,32 @@ 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.37.0 (ISS-073): package special status — nullable, no default; pre-existing rows stay NULL = none.
|
||||||
|
if (!cols('packages').has('special')) {
|
||||||
|
raw.exec('ALTER TABLE packages ADD COLUMN special TEXT');
|
||||||
|
}
|
||||||
|
// v0.39.0 (ISS-069): plane health — pre-existing rows backfill to full health.
|
||||||
|
if (!cols('planes').has('health')) {
|
||||||
|
raw.exec('ALTER TABLE planes ADD COLUMN health REAL NOT NULL DEFAULT 100');
|
||||||
|
}
|
||||||
|
// v0.39.0 (ISS-069): repair gate — nullable, no default; pre-existing rows stay NULL = not in repair.
|
||||||
|
if (!cols('planes').has('repair_until_sim_time')) {
|
||||||
|
raw.exec('ALTER TABLE planes ADD COLUMN repair_until_sim_time INTEGER');
|
||||||
|
}
|
||||||
|
// 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 +191,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);
|
||||||
|
|||||||
+24
-2
@@ -7,7 +7,9 @@ import { integer, real, sqliteTable, text } from 'drizzle-orm/sqlite-core';
|
|||||||
export const planes = sqliteTable('planes', {
|
export const planes = sqliteTable('planes', {
|
||||||
id: text('id').primaryKey().notNull(),
|
id: text('id').primaryKey().notNull(),
|
||||||
model: text('model').notNull(),
|
model: text('model').notNull(),
|
||||||
status: text('status', { enum: ['available', 'flying'] }).notNull(),
|
// v0.39.0 (ISS-069): 'repairing' = grounded in the shop until repairUntilSimTime passes; the
|
||||||
|
// engine flips it back to 'available' on a tick. All status guards key off 'available'.
|
||||||
|
status: text('status', { enum: ['available', 'flying', 'repairing'] }).notNull(),
|
||||||
fuel: real('fuel').notNull(),
|
fuel: real('fuel').notNull(),
|
||||||
maxFuel: real('max_fuel').notNull(),
|
maxFuel: real('max_fuel').notNull(),
|
||||||
locationIcao: text('location_icao').notNull(),
|
locationIcao: text('location_icao').notNull(),
|
||||||
@@ -20,6 +22,16 @@ 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('[]'),
|
||||||
|
// Structural health in [0, 100] (v0.39.0, ISS-069) — wears down by WEAR_PER_KM per km of
|
||||||
|
// landed legs; at 0 the plane is broken and burns BROKEN_BURN_MULTIPLIER × more fuel.
|
||||||
|
health: real('health').notNull().default(100),
|
||||||
|
// Sim-clock epoch ms when an active repair finishes (v0.39.0, ISS-069) — set with status
|
||||||
|
// 'repairing', cleared by the engine's flip-back pass; NULL = not in repair.
|
||||||
|
repairUntilSimTime: integer('repair_until_sim_time'),
|
||||||
});
|
});
|
||||||
|
|
||||||
// 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 +45,16 @@ 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),
|
||||||
|
// Special status (v0.37.0, ISS-073) — key into lib/game/specials.ts SPECIALS; NULL = none.
|
||||||
|
special: text('special'),
|
||||||
deadlineSimTime: integer('deadline_sim_time').notNull(),
|
deadlineSimTime: integer('deadline_sim_time').notNull(),
|
||||||
// 'expired' = an open OR loaded package whose deadline passed on a tick. Loaded
|
// 'expired' = an open OR loaded package whose deadline passed on a tick. Loaded
|
||||||
// expired packages keep occupying their slot, deliver no reward, and abandon free.
|
// expired packages keep occupying their slot, deliver no reward, and abandon free.
|
||||||
status: text('status', { enum: ['open', 'loaded', 'delivered', 'expired', 'abandoned'] }).notNull(),
|
// 'ignored' (v0.29.0, ISS-047 part B) = a dismissed open job — terminal, leaves the market.
|
||||||
|
status: text('status', { enum: ['open', 'loaded', 'delivered', 'expired', 'abandoned', 'ignored'] }).notNull(),
|
||||||
planeId: text('plane_id'),
|
planeId: text('plane_id'),
|
||||||
createdSimTime: integer('created_sim_time').notNull(),
|
createdSimTime: integer('created_sim_time').notNull(),
|
||||||
});
|
});
|
||||||
@@ -53,6 +71,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;
|
||||||
|
|||||||
@@ -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,9 @@ const basePlane = (id: string): PlaneState => ({
|
|||||||
departureSimTime: null,
|
departureSimTime: null,
|
||||||
lat: 50.03,
|
lat: 50.03,
|
||||||
lon: 8.56,
|
lon: 8.56,
|
||||||
|
health: 100, // v0.39.0 (ISS-069) — full-health fixture default
|
||||||
|
repairUntilSimTime: null,
|
||||||
|
queue: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
const basePackage = (id: string): PackageState => ({
|
const basePackage = (id: string): PackageState => ({
|
||||||
@@ -33,6 +36,8 @@ const basePackage = (id: string): PackageState => ({
|
|||||||
reward: 1234.5,
|
reward: 1234.5,
|
||||||
cargoType: 'mail',
|
cargoType: 'mail',
|
||||||
cargoCount: 2,
|
cargoCount: 2,
|
||||||
|
weightKg: 600,
|
||||||
|
special: null, // v0.37.0 (ISS-073) — no special status by default in fixtures
|
||||||
deadlineSimTime: 0,
|
deadlineSimTime: 0,
|
||||||
status: 'open',
|
status: 'open',
|
||||||
planeId: null,
|
planeId: null,
|
||||||
|
|||||||
+10
-1
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { formatCash, formatDeadlineRelative, formatDuration, formatFuel, formatSimClock } from './format';
|
import { formatCargo, formatCash, formatDeadlineRelative, formatDuration, formatFuel, formatSimClock } from './format';
|
||||||
|
|
||||||
describe('formatCash', () => {
|
describe('formatCash', () => {
|
||||||
it('formats EUR with thousands separators and two decimals', () => {
|
it('formats EUR with thousands separators and two decimals', () => {
|
||||||
@@ -21,6 +21,15 @@ describe('formatFuel', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('formatCargo', () => {
|
||||||
|
it('shows cargo type + tonnage with one decimal, like the loadout display', () => {
|
||||||
|
expect(formatCargo('Mail', 10_000)).toBe('Mail 10.0t');
|
||||||
|
// (250 / 1000).toFixed(1) === '0.3' — half rounds up at this value.
|
||||||
|
expect(formatCargo('Electronics', 250)).toBe('Electronics 0.3t');
|
||||||
|
expect(formatCargo('Mail', 40)).toBe('Mail 0.0t');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('formatSimClock', () => {
|
describe('formatSimClock', () => {
|
||||||
it('formats epoch ms as a UTC wall-clock string with seconds (deterministic across timezones)', () => {
|
it('formats epoch ms as a UTC wall-clock string with seconds (deterministic across timezones)', () => {
|
||||||
expect(formatSimClock(Date.UTC(2026, 8, 17, 14, 5))).toBe('2026-09-17 14:05:00 UTC');
|
expect(formatSimClock(Date.UTC(2026, 8, 17, 14, 5))).toBe('2026-09-17 14:05:00 UTC');
|
||||||
|
|||||||
@@ -11,6 +11,16 @@ export function formatFuel(fuel: number, maxFuel: number): string {
|
|||||||
return `${Math.round(fuel)} / ${maxFuel} L`;
|
return `${Math.round(fuel)} / ${maxFuel} L`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Job-list cargo label: "Mail 10.0t" — type + tonnage, one decimal like the loadout display.
|
||||||
|
export function formatCargo(cargoType: string, weightKg: number): string {
|
||||||
|
return `${cargoType} ${(weightKg / 1000).toFixed(1)}t`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Special-status badge label (v0.37.0, ISS-073): "priority" → "Priority".
|
||||||
|
export function formatSpecialLabel(special: string): string {
|
||||||
|
return special.charAt(0).toUpperCase() + special.slice(1);
|
||||||
|
}
|
||||||
|
|
||||||
// The sim clock is epoch ms (seeded from real time) — shown as a UTC wall-clock with
|
// The sim clock is epoch ms (seeded from real time) — shown as a UTC wall-clock with
|
||||||
// seconds so the display is deterministic regardless of the viewer's timezone.
|
// seconds so the display is deterministic regardless of the viewer's timezone.
|
||||||
export function formatSimClock(clockMs: number): string {
|
export function formatSimClock(clockMs: number): string {
|
||||||
|
|||||||
+399
-33
@@ -6,17 +6,34 @@ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
|||||||
import { openGameDb, type GameDb } from '@/lib/db/client';
|
import { openGameDb, type GameDb } from '@/lib/db/client';
|
||||||
import * as schema from '@/lib/db/schema';
|
import * as schema from '@/lib/db/schema';
|
||||||
import { loadAirportsGeoJson } from '@/lib/airports';
|
import { loadAirportsGeoJson } from '@/lib/airports';
|
||||||
import { haversineKm } from '@/lib/geo';
|
import { haversineKm, initialBearingDeg } from '@/lib/geo';
|
||||||
import { requiredFuelFor } from '@/lib/sim/movement';
|
import { weatherFor } from '@/lib/weather';
|
||||||
import { FUEL_PRICE_PER_L, PLANE_TYPES, SELL_FRACTION } from './catalog';
|
import { requiredFuelFor, windBurnFactor } from '@/lib/sim/movement';
|
||||||
|
import {
|
||||||
|
BROKEN_BURN_MULTIPLIER,
|
||||||
|
FUEL_DISCOUNT_PER_REP,
|
||||||
|
FUEL_PRICE_PER_L,
|
||||||
|
MAX_FUEL_DISCOUNT,
|
||||||
|
MAX_PLANE_DISCOUNT,
|
||||||
|
PLANE_DISCOUNT_PER_REP,
|
||||||
|
PLANE_TYPES,
|
||||||
|
REPAIR_COST,
|
||||||
|
REPAIR_DURATION_MS,
|
||||||
|
repRate,
|
||||||
|
SELL_FRACTION,
|
||||||
|
} from './catalog';
|
||||||
import { HUB_ICAO } from './seed';
|
import { HUB_ICAO } from './seed';
|
||||||
import {
|
import {
|
||||||
abandonPackage,
|
abandonPackage,
|
||||||
buyPlane,
|
buyPlane,
|
||||||
|
cancelQueuedLeg,
|
||||||
flyPlane,
|
flyPlane,
|
||||||
GameActionError,
|
GameActionError,
|
||||||
|
ignorePackage,
|
||||||
loadPackage,
|
loadPackage,
|
||||||
|
queueLeg,
|
||||||
refuelPlane,
|
refuelPlane,
|
||||||
|
repairPlane,
|
||||||
sellPlane,
|
sellPlane,
|
||||||
} from './actions';
|
} from './actions';
|
||||||
|
|
||||||
@@ -125,27 +142,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 +184,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)
|
||||||
@@ -192,6 +237,130 @@ describe('flyPlane', () => {
|
|||||||
|
|
||||||
await expectActionError(flyPlane(inst, 'plane-starter', 'LEMD'), 409, /insufficient fuel/i);
|
await expectActionError(flyPlane(inst, 'plane-starter', 'LEMD'), 409, /insufficient fuel/i);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('uses the ×1.5 broken burn for fuel feasibility when health is 0 (v0.39.0, ISS-069)', async () => {
|
||||||
|
const inst = await freshDb('fly-broken.db'); // EDDF → LEMD
|
||||||
|
const fc = await loadAirportsGeoJson();
|
||||||
|
const from = fc.features.find((f) => f.properties.icao === HUB_ICAO)!.properties;
|
||||||
|
const to = fc.features.find((f) => f.properties.icao === 'LEMD')!.properties;
|
||||||
|
const km = haversineKm(from.lat, from.lon, to.lat, to.lon);
|
||||||
|
const type = PLANE_TYPES.atr72;
|
||||||
|
const w = weatherFor(HUB_ICAO);
|
||||||
|
const f = windBurnFactor(w.windKt, w.windDirDeg, initialBearingDeg(from.lat, from.lon, to.lat, to.lon));
|
||||||
|
// The tank holds EXACTLY the healthy requirement — a broken plane needs more than that.
|
||||||
|
await inst.db.update(schema.planes)
|
||||||
|
.set({ health: 0, fuel: requiredFuelFor(type.burnPerKm * f, type.emptyWeightKg, type.maxFuel, km) })
|
||||||
|
.where(eq(schema.planes.id, 'plane-starter'));
|
||||||
|
|
||||||
|
await expectActionError(flyPlane(inst, 'plane-starter', 'LEMD'), 409, /insufficient fuel/i);
|
||||||
|
|
||||||
|
// Same tank at full health is flyable — the broken multiplier is what blocked it.
|
||||||
|
const inst2 = await freshDb('fly-broken-healthy.db');
|
||||||
|
await inst2.db.update(schema.planes)
|
||||||
|
.set({ fuel: requiredFuelFor(type.burnPerKm * f, type.emptyWeightKg, type.maxFuel, km) })
|
||||||
|
.where(eq(schema.planes.id, 'plane-starter'));
|
||||||
|
await flyPlane(inst2, 'plane-starter', 'LEMD'); // must not throw
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a destination already in the queue with 409 (ISS-065)', async () => {
|
||||||
|
const inst = await freshDb('qleg-dup.db');
|
||||||
|
await queueLeg(inst, 'plane-starter', 'LEMD');
|
||||||
|
|
||||||
|
await expectActionError(queueLeg(inst, 'plane-starter', 'LEMD'), 409, /already in the flight queue/);
|
||||||
|
|
||||||
|
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||||
|
expect(JSON.parse(plane.queue)).toEqual(['LEMD']); // rejected leg not appended
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects re-queuing the active leg destination of a flying plane with 409 (ISS-065)', async () => {
|
||||||
|
const inst = await freshDb('qleg-dupflying.db');
|
||||||
|
await inst.db.update(schema.planes)
|
||||||
|
.set({ status: 'flying', departureSimTime: T0, destIcao: 'LEMD' })
|
||||||
|
.where(eq(schema.planes.id, 'plane-starter'));
|
||||||
|
|
||||||
|
await expectActionError(queueLeg(inst, 'plane-starter', 'LEMD'), 409, /already flying to LEMD/);
|
||||||
|
|
||||||
|
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||||
|
expect(JSON.parse(plane.queue)).toEqual([]); // rejected leg not appended
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still appends a different known airport with success (ISS-065 sanity)', async () => {
|
||||||
|
const inst = await freshDb('qleg-dupother.db');
|
||||||
|
await queueLeg(inst, 'plane-starter', 'LEMD');
|
||||||
|
await expect(queueLeg(inst, 'plane-starter', 'EHAM')).resolves.toBeUndefined();
|
||||||
|
|
||||||
|
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||||
|
expect(JSON.parse(plane.queue)).toEqual(['LEMD', 'EHAM']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('cancelQueuedLeg (v0.25.0, ISS-043)', () => {
|
||||||
|
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', () => {
|
||||||
@@ -291,6 +460,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');
|
||||||
@@ -325,6 +524,38 @@ describe('buyPlane', () => {
|
|||||||
const inst = await freshDb('buy-poor.db'); // seeded cash is 20k < 200k
|
const inst = await freshDb('buy-poor.db'); // seeded cash is 20k < 200k
|
||||||
await expectActionError(buyPlane(inst, 'atr72'), 409, /insufficient funds/i);
|
await expectActionError(buyPlane(inst, 'atr72'), 409, /insufficient funds/i);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('deducts the reputation-scaled cost at elevated rep (v0.38.0)', async () => {
|
||||||
|
const inst = await freshDb('buy-rep.db');
|
||||||
|
// rep 30 → rate exactly 0.2 → ATR costs 160k, not 200k.
|
||||||
|
await inst.db.update(schema.account).set({ cash: 500_000, reputation: 30 });
|
||||||
|
|
||||||
|
const before = (await inst.db.select().from(schema.planes)).length;
|
||||||
|
await buyPlane(inst, 'atr72');
|
||||||
|
|
||||||
|
const rate = repRate(30, PLANE_DISCOUNT_PER_REP, MAX_PLANE_DISCOUNT);
|
||||||
|
expect(rate).toBeCloseTo(0.2, 12); // sanity pin on the formula input
|
||||||
|
const expectedCost = Math.round(PLANE_TYPES.atr72.price * (1 - rate) * 100) / 100;
|
||||||
|
expect(expectedCost).toBe(160_000); // 200k × 0.8 — exact value pin
|
||||||
|
const account = (await inst.db.select().from(schema.account))[0];
|
||||||
|
expect(account.cash).toBeCloseTo(500_000 - expectedCost, 6);
|
||||||
|
expect((await inst.db.select().from(schema.planes)).length).toBe(before + 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('buys with cash between the discounted and full price; rejects below the discounted cost (v0.38.0)', async () => {
|
||||||
|
const inst = await freshDb('buy-boundary.db');
|
||||||
|
// rep 30 → discounted cost 160k: 170k is enough now, though it was not before v0.38.0.
|
||||||
|
await inst.db.update(schema.account).set({ cash: 170_000, reputation: 30 });
|
||||||
|
|
||||||
|
const { planeId } = await buyPlane(inst, 'atr72');
|
||||||
|
expect(planeId).toBeTruthy(); // the old full-price check would have rejected this
|
||||||
|
const account = (await inst.db.select().from(schema.account))[0];
|
||||||
|
expect(account.cash).toBeCloseTo(170_000 - 160_000, 6);
|
||||||
|
|
||||||
|
const inst2 = await freshDb('buy-boundary-poor.db'); // just below the discounted cost
|
||||||
|
await inst2.db.update(schema.account).set({ cash: 159_999.99, reputation: 30 });
|
||||||
|
await expectActionError(buyPlane(inst2, 'atr72'), 409, /insufficient funds/i);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('sellPlane', () => {
|
describe('sellPlane', () => {
|
||||||
@@ -357,6 +588,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/);
|
||||||
@@ -410,6 +654,39 @@ describe('refuelPlane', () => {
|
|||||||
await expectActionError(refuelPlane(inst, 'plane-starter'), 409, /insufficient funds/i);
|
await expectActionError(refuelPlane(inst, 'plane-starter'), 409, /insufficient funds/i);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('applies the reputation-scaled discount to the price term only, in both modes (v0.38.0)', async () => {
|
||||||
|
const inst = await freshDb('refuel-rep.db');
|
||||||
|
// Elevated rep → non-zero rate; expectations computed with the SAME helpers refuelPlane uses.
|
||||||
|
await inst.db.update(schema.account).set({ reputation: 30 });
|
||||||
|
const rate = repRate(30, FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT);
|
||||||
|
expect(rate).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// 'full': exact discounted cost, tank math untouched.
|
||||||
|
await inst.db.update(schema.planes).set({ fuel: 1_000 }).where(eq(schema.planes.id, 'plane-starter'));
|
||||||
|
const cashBefore = (await inst.db.select().from(schema.account))[0].cash;
|
||||||
|
const { cost } = await refuelPlane(inst, 'plane-starter');
|
||||||
|
expect(cost).toBe(Math.round((PLANE_TYPES.atr72.maxFuel - 1_000) * FUEL_PRICE_PER_L * (1 - rate) * 100) / 100);
|
||||||
|
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||||
|
expect(plane.fuel).toBe(PLANE_TYPES.atr72.maxFuel); // target unchanged by the discount
|
||||||
|
expect((await inst.db.select().from(schema.account))[0].cash).toBeCloseTo(cashBefore - cost, 6);
|
||||||
|
|
||||||
|
// 'forFlight': same discount on the price term; burn/target math (wind factor, ×1.5 margin) untouched.
|
||||||
|
await insertOpenPackage(inst, 'p-ff-r', { destIcao: 'LEMD' }); // EDDF → LEMD
|
||||||
|
await loadPackage(inst, 'p-ff-r', 'plane-starter');
|
||||||
|
const required = await adjustedRequiredForHubToLemd();
|
||||||
|
const startFuel = Math.round(required / 2);
|
||||||
|
await inst.db.update(schema.planes).set({ fuel: startFuel }).where(eq(schema.planes.id, 'plane-starter'));
|
||||||
|
const cashBefore2 = (await inst.db.select().from(schema.account))[0].cash;
|
||||||
|
|
||||||
|
const { cost: ffCost } = await refuelPlane(inst, 'plane-starter', { mode: 'forFlight', packageId: 'p-ff-r' });
|
||||||
|
|
||||||
|
const targetFuel = Math.min(PLANE_TYPES.atr72.maxFuel, Math.max(startFuel, required * 1.5));
|
||||||
|
expect(ffCost).toBe(Math.round((targetFuel - startFuel) * FUEL_PRICE_PER_L * (1 - rate) * 100) / 100);
|
||||||
|
const plane2 = (await inst.db.select().from(schema.planes))[0];
|
||||||
|
expect(plane2.fuel).toBeCloseTo(targetFuel, 6); // topped up to exactly the 150% target
|
||||||
|
expect((await inst.db.select().from(schema.account))[0].cash).toBeCloseTo(cashBefore2 - ffCost, 6);
|
||||||
|
});
|
||||||
|
|
||||||
it('rejects an unknown id with 404', async () => {
|
it('rejects an unknown id with 404', async () => {
|
||||||
const inst = await freshDb('refuel-unknown.db');
|
const inst = await freshDb('refuel-unknown.db');
|
||||||
await expectActionError(refuelPlane(inst, 'ghost'), 404);
|
await expectActionError(refuelPlane(inst, 'ghost'), 404);
|
||||||
@@ -417,17 +694,29 @@ describe('refuelPlane', () => {
|
|||||||
|
|
||||||
// --- forFlight mode (v0.16.0) ---
|
// --- forFlight mode (v0.16.0) ---
|
||||||
|
|
||||||
it('forFlight adds exactly the liters needed for the package leg and deducts L × price', async () => {
|
// Wind-adjusted requirement for the hub → LEMD leg (v0.36.0) — computed with the SAME helpers
|
||||||
const inst = await freshDb('refuel-forflight-ok.db');
|
// refuelPlane uses, so the expectations stay exact without hardcoded literals.
|
||||||
await insertOpenPackage(inst, 'p-ff', { destIcao: 'LEMD' }); // EDDF → LEMD
|
async function adjustedRequiredForHubToLemd() {
|
||||||
await loadPackage(inst, 'p-ff', 'plane-starter');
|
|
||||||
|
|
||||||
const fc = await loadAirportsGeoJson();
|
const fc = await loadAirportsGeoJson();
|
||||||
const from = fc.features.find((f) => f.properties.icao === HUB_ICAO)!.properties;
|
const from = fc.features.find((f) => f.properties.icao === HUB_ICAO)!.properties;
|
||||||
const to = fc.features.find((f) => f.properties.icao === 'LEMD')!.properties;
|
const to = fc.features.find((f) => f.properties.icao === 'LEMD')!.properties;
|
||||||
const km = haversineKm(from.lat, from.lon, to.lat, to.lon);
|
const km = haversineKm(from.lat, from.lon, to.lat, to.lon);
|
||||||
const type = PLANE_TYPES.atr72;
|
const type = PLANE_TYPES.atr72;
|
||||||
const required = requiredFuelFor(type.burnPerKm, type.emptyWeightKg, type.maxFuel, km);
|
const w = weatherFor(HUB_ICAO);
|
||||||
|
return requiredFuelFor(
|
||||||
|
type.burnPerKm * windBurnFactor(w.windKt, w.windDirDeg, initialBearingDeg(from.lat, from.lon, to.lat, to.lon)),
|
||||||
|
type.emptyWeightKg,
|
||||||
|
type.maxFuel,
|
||||||
|
km,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
it('forFlight buys 150% of the weather-adjusted leg requirement and deducts L × price', async () => {
|
||||||
|
const inst = await freshDb('refuel-forflight-ok.db');
|
||||||
|
await insertOpenPackage(inst, 'p-ff', { destIcao: 'LEMD' }); // EDDF → LEMD
|
||||||
|
await loadPackage(inst, 'p-ff', 'plane-starter');
|
||||||
|
|
||||||
|
const required = await adjustedRequiredForHubToLemd();
|
||||||
|
|
||||||
// Partial tank below the requirement so L > 0.
|
// Partial tank below the requirement so L > 0.
|
||||||
const startFuel = Math.round(required / 2);
|
const startFuel = Math.round(required / 2);
|
||||||
@@ -436,31 +725,28 @@ describe('refuelPlane', () => {
|
|||||||
|
|
||||||
const { cost } = await refuelPlane(inst, 'plane-starter', { mode: 'forFlight', packageId: 'p-ff' });
|
const { cost } = await refuelPlane(inst, 'plane-starter', { mode: 'forFlight', packageId: 'p-ff' });
|
||||||
|
|
||||||
expect(cost).toBe(Math.round((required - startFuel) * FUEL_PRICE_PER_L * 100) / 100);
|
// ISS-071: target is 150% of the requirement (below tank capacity for this leg).
|
||||||
|
const targetFuel = Math.min(PLANE_TYPES.atr72.maxFuel, Math.max(startFuel, required * 1.5));
|
||||||
|
expect(cost).toBe(Math.round((targetFuel - startFuel) * FUEL_PRICE_PER_L * 100) / 100);
|
||||||
const plane = (await inst.db.select().from(schema.planes))[0];
|
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||||
expect(plane.fuel).toBeCloseTo(required, 6); // topped up to exactly the leg requirement
|
expect(plane.fuel).toBeCloseTo(targetFuel, 6); // topped up to exactly the 150% target
|
||||||
const account = (await inst.db.select().from(schema.account))[0];
|
const account = (await inst.db.select().from(schema.account))[0];
|
||||||
expect(account.cash).toBeCloseTo(cashBefore - cost, 6);
|
expect(account.cash).toBeCloseTo(cashBefore - cost, 6);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('forFlight leaves an exactly-feasible tank — flyPlane to the package dest succeeds right after', async () => {
|
it('forFlight leaves a tank with a 50% margin — flyPlane to the package dest succeeds right after', async () => {
|
||||||
const inst = await freshDb('refuel-ff-flyafter.db');
|
const inst = await freshDb('refuel-ff-flyafter.db');
|
||||||
await insertOpenPackage(inst, 'p-fa', { destIcao: 'LEMD' }); // EDDF → LEMD
|
await insertOpenPackage(inst, 'p-fa', { destIcao: 'LEMD' }); // EDDF → LEMD
|
||||||
await loadPackage(inst, 'p-fa', 'plane-starter');
|
await loadPackage(inst, 'p-fa', 'plane-starter');
|
||||||
|
|
||||||
const fc = await loadAirportsGeoJson();
|
const required = await adjustedRequiredForHubToLemd();
|
||||||
const from = fc.features.find((f) => f.properties.icao === HUB_ICAO)!.properties;
|
|
||||||
const to = fc.features.find((f) => f.properties.icao === 'LEMD')!.properties;
|
|
||||||
const km = haversineKm(from.lat, from.lon, to.lat, to.lon);
|
|
||||||
const type = PLANE_TYPES.atr72;
|
|
||||||
const required = requiredFuelFor(type.burnPerKm, type.emptyWeightKg, type.maxFuel, km);
|
|
||||||
|
|
||||||
// Empty-ish tank: well below the leg requirement.
|
// Empty-ish tank: well below the leg requirement.
|
||||||
await inst.db.update(schema.planes).set({ fuel: Math.round(required / 4) }).where(eq(schema.planes.id, 'plane-starter'));
|
await inst.db.update(schema.planes).set({ fuel: Math.round(required / 4) }).where(eq(schema.planes.id, 'plane-starter'));
|
||||||
|
|
||||||
await refuelPlane(inst, 'plane-starter', { mode: 'forFlight', packageId: 'p-fa' });
|
await refuelPlane(inst, 'plane-starter', { mode: 'forFlight', packageId: 'p-fa' });
|
||||||
|
|
||||||
// The leg must now be flyable — no epsilon-short 409 (regression guard for the float-exact target).
|
// The leg must now be flyable — the 150% margin covers flyPlane's strict `>` check.
|
||||||
await flyPlane(inst, 'plane-starter', 'LEMD');
|
await flyPlane(inst, 'plane-starter', 'LEMD');
|
||||||
const plane = (await inst.db.select().from(schema.planes))[0];
|
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||||
expect(plane.status).toBe('flying');
|
expect(plane.status).toBe('flying');
|
||||||
@@ -521,3 +807,83 @@ describe('refuelPlane', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('repairPlane (v0.39.0, ISS-069)', () => {
|
||||||
|
it('charges exactly REPAIR_COST, restores health to 100 and grounds the plane for REPAIR_DURATION_MS', async () => {
|
||||||
|
const inst = await freshDb('repair-ok.db');
|
||||||
|
await inst.db.update(schema.planes).set({ health: 42 }).where(eq(schema.planes.id, 'plane-starter'));
|
||||||
|
const cashBefore = (await inst.db.select().from(schema.account))[0].cash;
|
||||||
|
|
||||||
|
const { cost } = await repairPlane(inst, 'plane-starter');
|
||||||
|
|
||||||
|
expect(cost).toBe(REPAIR_COST);
|
||||||
|
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||||
|
expect(plane.status).toBe('repairing');
|
||||||
|
expect(plane.health).toBe(100); // restored to full…
|
||||||
|
expect(plane.repairUntilSimTime).toBe(T0 + REPAIR_DURATION_MS); // …with the gate set from the seeded clock (no tick in direct calls)
|
||||||
|
const account = (await inst.db.select().from(schema.account))[0];
|
||||||
|
expect(account.cash).toBeCloseTo(cashBefore - REPAIR_COST, 6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is a free no-op when health is already full', async () => {
|
||||||
|
const inst = await freshDb('repair-full.db'); // seeded starter at 100
|
||||||
|
const cashBefore = (await inst.db.select().from(schema.account))[0].cash;
|
||||||
|
|
||||||
|
const { cost } = await repairPlane(inst, 'plane-starter');
|
||||||
|
|
||||||
|
expect(cost).toBe(0);
|
||||||
|
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||||
|
expect(plane.status).toBe('available'); // not grounded…
|
||||||
|
expect(plane.health).toBe(100);
|
||||||
|
expect(plane.repairUntilSimTime ?? null).toBeNull(); // …and no gate set
|
||||||
|
expect((await inst.db.select().from(schema.account))[0].cash).toBe(cashBefore);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a flying plane with 409', async () => {
|
||||||
|
const inst = await freshDb('repair-flying.db');
|
||||||
|
await inst.db.update(schema.planes)
|
||||||
|
.set({ status: 'flying', departureSimTime: T0, destIcao: 'LEMD' })
|
||||||
|
.where(eq(schema.planes.id, 'plane-starter'));
|
||||||
|
|
||||||
|
await expectActionError(repairPlane(inst, 'plane-starter'), 409, /only grounded planes can be repaired/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a plane that is already repairing with 409', async () => {
|
||||||
|
const inst = await freshDb('repair-busy.db');
|
||||||
|
await inst.db.update(schema.planes)
|
||||||
|
.set({ status: 'repairing', repairUntilSimTime: T0 + REPAIR_DURATION_MS })
|
||||||
|
.where(eq(schema.planes.id, 'plane-starter'));
|
||||||
|
|
||||||
|
await expectActionError(repairPlane(inst, 'plane-starter'), 409, /only grounded planes can be repaired/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks load/fly/refuel/sell while repairing (the existing status guards)', async () => {
|
||||||
|
const inst = await freshDb('repair-blocks.db');
|
||||||
|
await inst.db.update(schema.planes).set({ health: 50 }).where(eq(schema.planes.id, 'plane-starter'));
|
||||||
|
await repairPlane(inst, 'plane-starter');
|
||||||
|
|
||||||
|
await insertOpenPackage(inst); // open package at the hub — loadable if the plane were grounded
|
||||||
|
await expectActionError(loadPackage(inst, 'p-open', 'plane-starter'), 409, /repairing/);
|
||||||
|
await expectActionError(flyPlane(inst, 'plane-starter', 'LEMD'), 409, /repairing/);
|
||||||
|
await expectActionError(refuelPlane(inst, 'plane-starter'), 409, /repairing/);
|
||||||
|
// A second plane so the "last remaining" guard cannot fire before the status one.
|
||||||
|
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 expectActionError(sellPlane(inst, 'plane-starter'), 409, /repairing/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects the repair when cash is below REPAIR_COST with 409', async () => {
|
||||||
|
const inst = await freshDb('repair-poor.db');
|
||||||
|
await inst.db.update(schema.planes).set({ health: 50 }).where(eq(schema.planes.id, 'plane-starter'));
|
||||||
|
await inst.db.update(schema.account).set({ cash: REPAIR_COST - 1 });
|
||||||
|
|
||||||
|
await expectActionError(repairPlane(inst, 'plane-starter'), 409, /insufficient funds/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an unknown id with 404', async () => {
|
||||||
|
const inst = await freshDb('repair-unknown.db');
|
||||||
|
await expectActionError(repairPlane(inst, 'ghost'), 404);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+178
-31
@@ -3,11 +3,24 @@ import { and, eq, inArray } from 'drizzle-orm';
|
|||||||
import * as schema from '@/lib/db/schema';
|
import * as schema from '@/lib/db/schema';
|
||||||
import { persist, type GameDb } from '@/lib/db/client';
|
import { persist, type GameDb } from '@/lib/db/client';
|
||||||
import { loadAirportsGeoJson } from '@/lib/airports';
|
import { loadAirportsGeoJson } from '@/lib/airports';
|
||||||
import { haversineKm } from '@/lib/geo';
|
import { haversineKm, initialBearingDeg } from '@/lib/geo';
|
||||||
import { requiredFuelFor } from '@/lib/sim/movement';
|
import { weatherFor } from '@/lib/weather';
|
||||||
|
import { requiredFuelFor, windBurnFactor } from '@/lib/sim/movement';
|
||||||
import { runAction } from '@/lib/sim/engine';
|
import { runAction } from '@/lib/sim/engine';
|
||||||
import { getStateSnapshot } from './state';
|
import { getStateSnapshot, parseIcaoQueue } from './state';
|
||||||
import { FUEL_PRICE_PER_L, PLANE_TYPES, SELL_FRACTION } from './catalog';
|
import {
|
||||||
|
conditionBurnMultiplier,
|
||||||
|
FUEL_DISCOUNT_PER_REP,
|
||||||
|
FUEL_PRICE_PER_L,
|
||||||
|
MAX_FUEL_DISCOUNT,
|
||||||
|
MAX_PLANE_DISCOUNT,
|
||||||
|
PLANE_DISCOUNT_PER_REP,
|
||||||
|
PLANE_TYPES,
|
||||||
|
REPAIR_COST,
|
||||||
|
REPAIR_DURATION_MS,
|
||||||
|
repRate,
|
||||||
|
SELL_FRACTION,
|
||||||
|
} from './catalog';
|
||||||
import { abandonPenalty } from './penalty';
|
import { abandonPenalty } from './penalty';
|
||||||
import { HUB_ICAO } from './seed';
|
import { HUB_ICAO } from './seed';
|
||||||
|
|
||||||
@@ -20,7 +33,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 +56,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 +83,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)),
|
);
|
||||||
)
|
const usedKg = occupied.reduce((sum, r) => sum + (r.weightKg ?? 0), 0);
|
||||||
).length;
|
if (usedKg + pkg.weightKg > type.maxPayloadKg) {
|
||||||
if (occupied >= type.capacity) {
|
throw new GameActionError(409, `no payload room: the ${type.name} carries ${Math.round(usedKg)}/${type.maxPayloadKg} kg`);
|
||||||
throw new GameActionError(409, `no free slots: the ${type.name} carries ${occupied}/${type.capacity} packages`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await inst.db.update(schema.packages)
|
await inst.db.update(schema.packages)
|
||||||
@@ -106,8 +119,12 @@ export async function flyPlane(inst: GameDb, planeId: string, destIcao: string):
|
|||||||
const type = PLANE_TYPES[plane.model];
|
const type = PLANE_TYPES[plane.model];
|
||||||
if (!type) throw new Error(`unknown plane model ${plane.model}`); // → 500, cannot happen for seeded data
|
if (!type) throw new Error(`unknown plane model ${plane.model}`); // → 500, cannot happen for seeded data
|
||||||
const distanceKm = haversineKm(origin.lat, origin.lon, destFeature.properties.lat, destFeature.properties.lon);
|
const distanceKm = haversineKm(origin.lat, origin.lon, destFeature.properties.lat, destFeature.properties.lon);
|
||||||
// Weighted burn model (v0.16.0): the leg needs enough fuel to cover it arriving empty.
|
// Weighted burn model (v0.16.0): the leg needs enough fuel to cover it arriving empty; wind-
|
||||||
const requiredL = requiredFuelFor(type.burnPerKm, type.emptyWeightKg, type.maxFuel, distanceKm);
|
// adjusted (v0.36.0) by departure weather vs route heading — every call site uses this same factor.
|
||||||
|
// v0.39.0 (ISS-069): a broken plane (health ≤ 0) burns BROKEN_BURN_MULTIPLIER × more per km.
|
||||||
|
const w = weatherFor(plane.locationIcao);
|
||||||
|
const burnPerKm = type.burnPerKm * windBurnFactor(w.windKt, w.windDirDeg, initialBearingDeg(origin.lat, origin.lon, destFeature.properties.lat, destFeature.properties.lon)) * conditionBurnMultiplier(plane.health);
|
||||||
|
const requiredL = requiredFuelFor(burnPerKm, type.emptyWeightKg, type.maxFuel, distanceKm);
|
||||||
if (requiredL > plane.fuel) {
|
if (requiredL > plane.fuel) {
|
||||||
throw new GameActionError(409, `insufficient fuel: the leg needs ${Math.round(requiredL)} L but the plane has ${Math.round(plane.fuel)} L`);
|
throw new GameActionError(409, `insufficient fuel: the leg needs ${Math.round(requiredL)} L but the plane has ${Math.round(plane.fuel)} L`);
|
||||||
}
|
}
|
||||||
@@ -125,6 +142,65 @@ 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}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ISS-065: the same route can't be queued twice — re-queuing the active leg's destination or a
|
||||||
|
// leg already in the queue would only produce pointless repeated hops on landing.
|
||||||
|
if (plane.status === 'flying' && plane.destIcao === destIcao) {
|
||||||
|
throw new GameActionError(409, `already flying to ${destIcao}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const queue = parseIcaoQueue(plane.queue);
|
||||||
|
if (queue.includes(destIcao)) {
|
||||||
|
throw new GameActionError(409, `${destIcao} is already in the flight 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,18 +238,38 @@ 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 its reputation-scaled price (v0.38.0, ISS-072 — discount grows with
|
||||||
// the home base (EDDF).
|
// reputation, exactly full price at the starting value); it arrives 'available' with a full tank
|
||||||
|
// at the home base (EDDF).
|
||||||
export async function buyPlane(inst: GameDb, model: string): Promise<{ planeId: string }> {
|
export async function buyPlane(inst: GameDb, model: string): Promise<{ planeId: string }> {
|
||||||
const type = PLANE_TYPES[model];
|
const type = PLANE_TYPES[model];
|
||||||
if (!type) throw new GameActionError(400, `unknown plane model "${model}"`);
|
if (!type) throw new GameActionError(400, `unknown plane model "${model}"`);
|
||||||
|
|
||||||
const account = (await inst.db.select().from(schema.account))[0];
|
const account = (await inst.db.select().from(schema.account))[0];
|
||||||
if (!account) throw new Error('account row missing');
|
if (!account) throw new Error('account row missing');
|
||||||
if (account.cash < type.price) {
|
// v0.38.0 (ISS-072): the EFFECTIVE cost drives both the funds check and the deduction.
|
||||||
throw new GameActionError(409, `insufficient funds: a ${type.name} costs ${type.price}, you have ${Math.round(account.cash)}`);
|
const cost = Math.round(type.price * (1 - repRate(account.reputation, PLANE_DISCOUNT_PER_REP, MAX_PLANE_DISCOUNT)) * 100) / 100;
|
||||||
|
if (account.cash < cost) {
|
||||||
|
throw new GameActionError(409, `insufficient funds: a ${type.name} costs ${cost}, you have ${Math.round(account.cash)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const fc = await loadAirportsGeoJson();
|
const fc = await loadAirportsGeoJson();
|
||||||
@@ -192,8 +288,9 @@ export async function buyPlane(inst: GameDb, model: string): Promise<{ planeId:
|
|||||||
lat: home.lat,
|
lat: home.lat,
|
||||||
lon: home.lon,
|
lon: home.lon,
|
||||||
departureSimTime: null,
|
departureSimTime: null,
|
||||||
|
health: 100, // new planes arrive at full health (v0.39.0, ISS-069)
|
||||||
});
|
});
|
||||||
await inst.db.update(schema.account).set({ cash: account.cash - type.price });
|
await inst.db.update(schema.account).set({ cash: Math.round((account.cash - cost) * 100) / 100 });
|
||||||
await persist(inst);
|
await persist(inst);
|
||||||
return { planeId };
|
return { planeId };
|
||||||
}
|
}
|
||||||
@@ -210,6 +307,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');
|
||||||
|
|
||||||
@@ -227,9 +333,10 @@ export async function sellPlane(inst: GameDb, planeId: string): Promise<{ payout
|
|||||||
|
|
||||||
// --- Refuel (POST /api/planes/:planeId/refuel) ---
|
// --- Refuel (POST /api/planes/:planeId/refuel) ---
|
||||||
|
|
||||||
// Refuel modes (v0.16.0): 'full' fills the tank to maxFuel; 'forFlight' adds just
|
// Refuel modes (v0.16.0): 'full' fills the tank to maxFuel; 'forFlight' buys 150% of
|
||||||
// enough fuel for one loaded package's destination leg — required liters per the
|
// the fuel required for one loaded package's destination leg (ISS-071) — wind-adjusted
|
||||||
// weighted burn model, independent of how much is already in the tank.
|
// requirement per the weighted burn model, capped at tank capacity, independent of how
|
||||||
|
// much is already in the tank.
|
||||||
export interface RefuelOptions {
|
export interface RefuelOptions {
|
||||||
mode?: 'full' | 'forFlight';
|
mode?: 'full' | 'forFlight';
|
||||||
packageId?: string;
|
packageId?: string;
|
||||||
@@ -265,24 +372,32 @@ export async function refuelPlane(inst: GameDb, planeId: string, opts: RefuelOpt
|
|||||||
const origin = await airportByIcao(plane.locationIcao);
|
const origin = await airportByIcao(plane.locationIcao);
|
||||||
const dest = await airportByIcao(pkg.destIcao);
|
const dest = await airportByIcao(pkg.destIcao);
|
||||||
const distanceKm = haversineKm(origin.lat, origin.lon, dest.lat, dest.lon);
|
const distanceKm = haversineKm(origin.lat, origin.lon, dest.lat, dest.lon);
|
||||||
const requiredL = requiredFuelFor(type.burnPerKm, type.emptyWeightKg, type.maxFuel, distanceKm);
|
// Same wind-adjusted requirement flyPlane checks (v0.36.0) — departure weather vs route heading;
|
||||||
|
// v0.39.0 (ISS-069): broken planes burn BROKEN_BURN_MULTIPLIER × more, same factor as flyPlane.
|
||||||
|
const w = weatherFor(plane.locationIcao);
|
||||||
|
const requiredL = requiredFuelFor(
|
||||||
|
type.burnPerKm * windBurnFactor(w.windKt, w.windDirDeg, initialBearingDeg(origin.lat, origin.lon, dest.lat, dest.lon)) * conditionBurnMultiplier(plane.health),
|
||||||
|
type.emptyWeightKg,
|
||||||
|
type.maxFuel,
|
||||||
|
distanceKm,
|
||||||
|
);
|
||||||
if (requiredL > plane.maxFuel) {
|
if (requiredL > plane.maxFuel) {
|
||||||
throw new GameActionError(409, `the leg to ${pkg.destIcao} needs ${Math.round(requiredL)} L even starting empty — it does not fit a full ${plane.maxFuel} L tank`);
|
throw new GameActionError(409, `the leg to ${pkg.destIcao} needs ${Math.round(requiredL)} L even starting empty — it does not fit a full ${plane.maxFuel} L tank`);
|
||||||
}
|
}
|
||||||
// Land EXACTLY on the leg requirement when topping up: flyPlane re-computes this same value and
|
// ISS-071: buy 150% of the requirement (capped at tank capacity) so burn variance doesn't strand
|
||||||
// compares with strict `>`, so fuel := requiredL guarantees the leg is feasible. The alternative
|
// the plane. The margin makes flyPlane's strict `>` check pass with room to spare; already enough
|
||||||
// (fuel + liters) can round a hair BELOW R in IEEE754 — f + fl(R − f) is not guaranteed ≥ R — and
|
// → free no-op, tank untouched.
|
||||||
// would 409 on an otherwise-feasible leg. Already enough → free no-op, tank untouched.
|
targetFuel = Math.min(plane.maxFuel, Math.max(plane.fuel, requiredL * 1.5));
|
||||||
targetFuel = Math.max(plane.fuel, requiredL);
|
|
||||||
} else {
|
} else {
|
||||||
// Exactly maxFuel (as before v0.16.0) — clients/tests compare against it with strict equality.
|
// Exactly maxFuel (as before v0.16.0) — clients/tests compare against it with strict equality.
|
||||||
targetFuel = plane.maxFuel;
|
targetFuel = plane.maxFuel;
|
||||||
}
|
}
|
||||||
|
|
||||||
const liters = Math.max(0, targetFuel - plane.fuel);
|
const liters = Math.max(0, targetFuel - plane.fuel);
|
||||||
const cost = Math.round(liters * FUEL_PRICE_PER_L * 100) / 100;
|
|
||||||
const account = (await inst.db.select().from(schema.account))[0];
|
const account = (await inst.db.select().from(schema.account))[0];
|
||||||
if (!account) throw new Error('account row missing');
|
if (!account) throw new Error('account row missing');
|
||||||
|
// v0.38.0 (ISS-072): rep-scaled discount on the PRICE term only — the burn/target math above is untouched.
|
||||||
|
const cost = Math.round(liters * FUEL_PRICE_PER_L * (1 - repRate(account.reputation, FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT)) * 100) / 100;
|
||||||
if (cost > 0 && account.cash < cost) {
|
if (cost > 0 && account.cash < cost) {
|
||||||
throw new GameActionError(409, `insufficient funds: refueling costs ${cost}, you have ${Math.round(account.cash)}`);
|
throw new GameActionError(409, `insufficient funds: refueling costs ${cost}, you have ${Math.round(account.cash)}`);
|
||||||
}
|
}
|
||||||
@@ -295,6 +410,38 @@ export async function refuelPlane(inst: GameDb, planeId: string, opts: RefuelOpt
|
|||||||
return { cost };
|
return { cost };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Repair (POST /api/planes/:planeId/repair) — v0.39.0 (ISS-069) ---
|
||||||
|
|
||||||
|
// Restore a worn-out plane to full health for the flat REPAIR_COST; it is grounded in 'repairing'
|
||||||
|
// status until sim.clockMs + REPAIR_DURATION_MS, when the engine's tick flips it back to
|
||||||
|
// 'available'. Only GROUNDED planes can be repaired (409 otherwise); an already-full-health
|
||||||
|
// repair is a free no-op (mirrors refuel's). 404 unknown plane; 409 not grounded / insufficient funds.
|
||||||
|
export async function repairPlane(inst: GameDb, planeId: string): Promise<{ cost: number }> {
|
||||||
|
const rows = await inst.db.select().from(schema.planes).where(eq(schema.planes.id, planeId));
|
||||||
|
const plane = rows[0];
|
||||||
|
if (!plane) throw new GameActionError(404, `plane ${planeId} not found`);
|
||||||
|
if (plane.status !== 'available') {
|
||||||
|
throw new GameActionError(409, `plane is ${plane.status}, only grounded planes can be repaired`);
|
||||||
|
}
|
||||||
|
// Free no-op when the plane is already at full health — nothing to repair.
|
||||||
|
if (plane.health >= 100) return { cost: 0 };
|
||||||
|
|
||||||
|
const sim = (await inst.db.select().from(schema.simState))[0];
|
||||||
|
if (!sim) throw new Error('sim_state row missing');
|
||||||
|
const account = (await inst.db.select().from(schema.account))[0];
|
||||||
|
if (!account) throw new Error('account row missing');
|
||||||
|
if (account.cash < REPAIR_COST) {
|
||||||
|
throw new GameActionError(409, `insufficient funds: repair costs ${REPAIR_COST}, you have ${Math.round(account.cash)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await inst.db.update(schema.planes)
|
||||||
|
.set({ health: 100, status: 'repairing', repairUntilSimTime: sim.clockMs + REPAIR_DURATION_MS })
|
||||||
|
.where(eq(schema.planes.id, planeId));
|
||||||
|
await inst.db.update(schema.account).set({ cash: Math.round((account.cash - REPAIR_COST) * 100) / 100 });
|
||||||
|
await persist(inst);
|
||||||
|
return { cost: REPAIR_COST };
|
||||||
|
}
|
||||||
|
|
||||||
// --- Route helper (response convention, v0.8.0) ---
|
// --- Route helper (response convention, v0.8.0) ---
|
||||||
|
|
||||||
// Run an action under the sim lock — tick FIRST so validation/mutation see current
|
// Run an action under the sim lock — tick FIRST so validation/mutation see current
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { BROKEN_BURN_MULTIPLIER, conditionBurnMultiplier, FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT, MAX_PLANE_DISCOUNT, PLANE_DISCOUNT_PER_REP, PLANE_TYPES, repRate, STARTER_MODEL } from './catalog';
|
||||||
|
import { STARTING_REPUTATION } from './seed';
|
||||||
|
|
||||||
|
// 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// v0.38.0 (ISS-072) — the shared rep-discount shape: linear above the starting value, capped.
|
||||||
|
describe('repRate', () => {
|
||||||
|
it('is exactly 0 at and below the starting reputation', () => {
|
||||||
|
expect(repRate(STARTING_REPUTATION, PLANE_DISCOUNT_PER_REP, MAX_PLANE_DISCOUNT)).toBe(0);
|
||||||
|
expect(repRate(0, PLANE_DISCOUNT_PER_REP, MAX_PLANE_DISCOUNT)).toBe(0);
|
||||||
|
expect(repRate(-5, FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT)).toBe(0); // below-start clamps too
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is linear in reputation above the starting value (exact mid-range values)', () => {
|
||||||
|
expect(repRate(STARTING_REPUTATION + 10, PLANE_DISCOUNT_PER_REP, MAX_PLANE_DISCOUNT)).toBeCloseTo(0.1, 12); // 0.01 × 10
|
||||||
|
expect(repRate(STARTING_REPUTATION + 30, PLANE_DISCOUNT_PER_REP, MAX_PLANE_DISCOUNT)).toBeCloseTo(0.3, 12); // 0.01 × 30
|
||||||
|
expect(repRate(STARTING_REPUTATION + 40, FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT)).toBeCloseTo(0.2, 12); // 0.005 × 40 = cap
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clamps at the cap', () => {
|
||||||
|
expect(repRate(STARTING_REPUTATION + 50, PLANE_DISCOUNT_PER_REP, MAX_PLANE_DISCOUNT)).toBe(MAX_PLANE_DISCOUNT); // 0.5 → capped to 0.4
|
||||||
|
expect(repRate(1_000_000, FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT)).toBe(MAX_FUEL_DISCOUNT);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// v0.39.0 (ISS-069) — the broken-state burn factor: ×BROKEN_BURN_MULTIPLIER at/below zero health, exactly 1 above it.
|
||||||
|
describe('conditionBurnMultiplier', () => {
|
||||||
|
it('is exactly 1 for any positive health', () => {
|
||||||
|
expect(conditionBurnMultiplier(100)).toBe(1);
|
||||||
|
expect(conditionBurnMultiplier(42)).toBe(1);
|
||||||
|
expect(conditionBurnMultiplier(0.5)).toBe(1); // worn but not broken
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is exactly BROKEN_BURN_MULTIPLIER at and below zero health', () => {
|
||||||
|
expect(conditionBurnMultiplier(0)).toBe(BROKEN_BURN_MULTIPLIER);
|
||||||
|
expect(conditionBurnMultiplier(-3)).toBe(BROKEN_BURN_MULTIPLIER); // defensive — wear clamps at 0, but the gate is <= 0
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pins the broken multiplier at ×1.5', () => {
|
||||||
|
expect(BROKEN_BURN_MULTIPLIER).toBe(1.5);
|
||||||
|
});
|
||||||
|
});
|
||||||
+103
-5
@@ -1,5 +1,9 @@
|
|||||||
|
import { STARTING_REPUTATION } from './seed';
|
||||||
|
|
||||||
// Aircraft catalog — in-code constants for now (no DB table yet). One type is
|
// Aircraft catalog — in-code constants for now (no DB table yet). One type is
|
||||||
// enough for the MVP; add more entries to PLANE_TYPES as they are designed.
|
// enough for the MVP; add more entries to PLANE_TYPES as they are designed.
|
||||||
|
// ponytail: circular import with seed.ts is safe — neither module touches the other's
|
||||||
|
// bindings at top level, only inside function bodies.
|
||||||
|
|
||||||
export interface PlaneType {
|
export interface PlaneType {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -16,8 +20,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 +37,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,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -44,6 +106,41 @@ export const FUEL_PRICE_PER_L = 0.8;
|
|||||||
// Fraction of the catalog price paid out when selling a plane (v0.8.0).
|
// Fraction of the catalog price paid out when selling a plane (v0.8.0).
|
||||||
export const SELL_FRACTION = 0.5;
|
export const SELL_FRACTION = 0.5;
|
||||||
|
|
||||||
|
// Shared rep-discount shape (v0.38.0, ISS-072): linear in reputation above the starting value,
|
||||||
|
// capped — exactly 0 at/below STARTING_REPUTATION so default-rep behavior is unchanged.
|
||||||
|
export function repRate(reputation: number, perPoint: number, cap: number): number {
|
||||||
|
return Math.min(cap, perPoint * Math.max(0, reputation - STARTING_REPUTATION));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ponytail: balance-tunable — plane purchase discount per rep point above the starting value.
|
||||||
|
export const PLANE_DISCOUNT_PER_REP = 0.01;
|
||||||
|
// ponytail: balance-tunable — max plane purchase discount (reached at rep ≥ 50).
|
||||||
|
export const MAX_PLANE_DISCOUNT = 0.4;
|
||||||
|
|
||||||
|
// ponytail: balance-tunable — refuel price discount per rep point above the starting value.
|
||||||
|
export const FUEL_DISCOUNT_PER_REP = 0.005;
|
||||||
|
// ponytail: balance-tunable — max refuel price discount (reached at rep ≥ 50).
|
||||||
|
export const MAX_FUEL_DISCOUNT = 0.2;
|
||||||
|
|
||||||
|
// --- Maintenance (v0.39.0, ISS-069) ---
|
||||||
|
|
||||||
|
// ponytail: balance-tunable — health lost per km of landed legs (~13 mid-range EDDF↔LEMD hops to break from full).
|
||||||
|
export const WEAR_PER_KM = 0.005;
|
||||||
|
|
||||||
|
// ponytail: balance-tunable — burn multiplier for a broken plane (health ≤ 0): it still flies, just thirsty.
|
||||||
|
export const BROKEN_BURN_MULTIPLIER = 1.5;
|
||||||
|
|
||||||
|
// Broken-state burn factor for one plane's health — the third multiplicative term alongside wind at every
|
||||||
|
// per-plane burn site (flyPlane feasibility, refuel forFlight, engine landed fuel + auto-chain, live display).
|
||||||
|
export function conditionBurnMultiplier(health: number): number {
|
||||||
|
return health <= 0 ? BROKEN_BURN_MULTIPLIER : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ponytail: balance-tunable — flat repair cost in € (per-type if balance demands it).
|
||||||
|
export const REPAIR_COST = 1_500;
|
||||||
|
// ponytail: balance-tunable — how long a repair grounds the plane, sim ms (3 game days).
|
||||||
|
export const REPAIR_DURATION_MS = 3 * 86_400_000;
|
||||||
|
|
||||||
// Snapshot shape of one catalog entry in GET /api/state (v0.10.0): `model` is the
|
// Snapshot shape of one catalog entry in GET /api/state (v0.10.0): `model` is the
|
||||||
// id used by buy/dispatch, `label` the display name — clients compute all market
|
// id used by buy/dispatch, `label` the display name — clients compute all market
|
||||||
// previews (buy price, sell payout, refuel cost) from these + the economy constants.
|
// previews (buy price, sell payout, refuel cost) from these + the economy constants.
|
||||||
@@ -57,7 +154,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 +169,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,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|||||||
+142
-18
@@ -1,16 +1,20 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { haversineKm } from '@/lib/geo';
|
import { haversineKm } from '@/lib/geo';
|
||||||
import { PLANE_TYPES, STARTER_MODEL } from './catalog';
|
import { PLANE_TYPES, repRate, STARTER_MODEL } from './catalog';
|
||||||
|
import { SPECIALS } from './specials';
|
||||||
import {
|
import {
|
||||||
BASE_REWARD,
|
BASE_REWARD,
|
||||||
buildSeed,
|
buildSeed,
|
||||||
|
JOB_BONUS_PER_REP,
|
||||||
|
MAX_JOB_BONUS,
|
||||||
PACKAGE_COUNT_MAX,
|
PACKAGE_COUNT_MAX,
|
||||||
PACKAGE_COUNT_MIN,
|
PACKAGE_COUNT_MIN,
|
||||||
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 +48,8 @@ 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
|
||||||
|
health: 100, // v0.39.0 (ISS-069) — full health on first run
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -69,28 +75,45 @@ 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), × special multiplier', () => {
|
||||||
const seed = seedWith(42);
|
const seed = seedWith(42);
|
||||||
for (const c of seed.packages) {
|
for (const c of seed.packages) {
|
||||||
const o = AIRPORTS.find((a) => a.icao === c.originIcao)!;
|
const o = AIRPORTS.find((a) => a.icao === c.originIcao)!;
|
||||||
const d = AIRPORTS.find((a) => a.icao === c.destIcao)!;
|
const d = AIRPORTS.find((a) => a.icao === c.destIcao)!;
|
||||||
expect(c.reward).toBe(Math.round(BASE_REWARD + REWARD_PER_KM * haversineKm(o.lat, o.lon, d.lat, d.lon)));
|
// v0.37.0 (ISS-073): special packages pay the registry multiplier on top of the base formula.
|
||||||
|
const mult = c.special ? SPECIALS[c.special]?.rewardMultiplier ?? 1 : 1;
|
||||||
|
expect(c.reward).toBe(Math.round(rewardFor(haversineKm(o.lat, o.lon, d.lat, d.lon), c.weightKg) * mult));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('sets deadlines inside the 6–72h game-time window after now', () => {
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets deadlines inside the 6–72h game-time window after now (scaled by any special status)', () => {
|
||||||
const seed = seedWith(42);
|
const seed = seedWith(42);
|
||||||
for (const c of seed.packages) {
|
for (const c of seed.packages) {
|
||||||
expect(c.deadlineSimTime).toBeGreaterThan(NOW + 6 * 3_600_000 - 1);
|
// v0.37.0 (ISS-073): a special's deadlineScale tightens the drawn window (e.g. priority → 3–36h).
|
||||||
expect(c.deadlineSimTime).toBeLessThanOrEqual(NOW + 72 * 3_600_000);
|
const scale = c.special ? SPECIALS[c.special]?.deadlineScale ?? 1 : 1;
|
||||||
|
expect(c.deadlineSimTime).toBeGreaterThan(NOW + 6 * 3_600_000 * scale - 1);
|
||||||
|
expect(c.deadlineSimTime).toBeLessThanOrEqual(NOW + 72 * 3_600_000 * scale);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('seeds the starting account and sim state', () => {
|
it('seeds the starting account and sim state', () => {
|
||||||
const seed = seedWith(42);
|
const seed = seedWith(42);
|
||||||
expect(seed.account).toEqual({ cash: STARTING_CASH, reputation: STARTING_REPUTATION });
|
expect(seed.account).toEqual({ cash: STARTING_CASH, reputation: STARTING_REPUTATION });
|
||||||
// lastRealTickMs starts at the seed moment so the first tick-on-read advances ~0.
|
// lastRealTickMs starts at the seed moment so the first tick-on-read advances ~0;
|
||||||
expect(seed.simState).toEqual({ clockMs: NOW, timeScale: 1, paused: false, lastRealTickMs: NOW });
|
// lastSelectedTimeScale mirrors the seeded speed (v0.30.0, ISS-057).
|
||||||
|
expect(seed.simState).toEqual({ clockMs: NOW, timeScale: 1, paused: false, lastRealTickMs: NOW, lastSelectedTimeScale: 1 });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('is fully deterministic for a fixed RNG and inputs', () => {
|
it('is fully deterministic for a fixed RNG and inputs', () => {
|
||||||
@@ -105,9 +128,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,11 +149,14 @@ describe('generatePackage (shared seed/spawn builder)', () => {
|
|||||||
|
|
||||||
const o = AIRPORTS.find((a) => a.icao === c.originIcao)!;
|
const o = AIRPORTS.find((a) => a.icao === c.originIcao)!;
|
||||||
const d = AIRPORTS.find((a) => a.icao === c.destIcao)!;
|
const d = AIRPORTS.find((a) => a.icao === c.destIcao)!;
|
||||||
expect(c.reward).toBe(Math.round(BASE_REWARD + REWARD_PER_KM * haversineKm(o.lat, o.lon, d.lat, d.lon)));
|
// v0.37.0 (ISS-073): special packages pay the registry multiplier on top of the base formula.
|
||||||
|
const mult = c.special ? SPECIALS[c.special]?.rewardMultiplier ?? 1 : 1;
|
||||||
|
expect(c.reward).toBe(Math.round(rewardFor(haversineKm(o.lat, o.lon, d.lat, d.lon), c.weightKg) * mult));
|
||||||
|
|
||||||
// Same 6–72h game-time deadline window as the seed.
|
// Same 6–72h game-time deadline window as the seed — scaled by any special status.
|
||||||
expect(c.deadlineSimTime).toBeGreaterThan(NOW + 6 * 3_600_000 - 1);
|
const scale = c.special ? SPECIALS[c.special]?.deadlineScale ?? 1 : 1;
|
||||||
expect(c.deadlineSimTime).toBeLessThanOrEqual(NOW + 72 * 3_600_000);
|
expect(c.deadlineSimTime).toBeGreaterThan(NOW + 6 * 3_600_000 * scale - 1);
|
||||||
|
expect(c.deadlineSimTime).toBeLessThanOrEqual(NOW + 72 * 3_600_000 * scale);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('is deterministic for a fixed RNG and honors the id/nowMs inputs', () => {
|
it('is deterministic for a fixed RNG and honors the id/nowMs inputs', () => {
|
||||||
@@ -172,7 +198,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)));
|
// v0.37.0 (ISS-073): the rolled special (if any) multiplies the base reward.
|
||||||
|
const mult = c.special ? SPECIALS[c.special]?.rewardMultiplier ?? 1 : 1;
|
||||||
|
expect(c.reward).toBe(Math.round(rewardFor(haversineKm(o.lat, o.lon, d.lat, d.lon), c.weightKg) * mult));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('destIcao forces the destination; origin stays random but distinct', () => {
|
it('destIcao forces the destination; origin stays random but distinct', () => {
|
||||||
@@ -188,7 +216,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)));
|
// v0.37.0 (ISS-073): the rolled special (if any) multiplies the base reward.
|
||||||
|
const mult = c.special ? SPECIALS[c.special]?.rewardMultiplier ?? 1 : 1;
|
||||||
|
expect(c.reward).toBe(Math.round(rewardFor(haversineKm(o.lat, o.lon, d.lat, d.lon), c.weightKg) * mult));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('is deterministic for a fixed RNG with forced endpoints', () => {
|
it('is deterministic for a fixed RNG with forced endpoints', () => {
|
||||||
@@ -211,4 +241,98 @@ 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)!;
|
||||||
|
// v0.37.0 (ISS-073): the rolled special (if any) multiplies the base reward.
|
||||||
|
const mult = c.special ? SPECIALS[c.special]?.rewardMultiplier ?? 1 : 1;
|
||||||
|
expect(c.reward).toBe(Math.round(rewardFor(haversineKm(o.lat, o.lon, d.lat, d.lon), 1_234) * mult));
|
||||||
|
|
||||||
|
// Deterministic for a fixed RNG…
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('special status (v0.37.0, ISS-073)', () => {
|
||||||
|
it('a forced special passes through; an explicit null forces none; omitted rolls a registry key or null', () => {
|
||||||
|
const base = { id: 'c-sp', airports: AIRPORTS, nowMs: NOW };
|
||||||
|
expect(generatePackage({ ...base, rng: mulberry32(42), special: 'priority' }).special).toBe('priority');
|
||||||
|
expect(generatePackage({ ...base, rng: mulberry32(42), special: null }).special).toBeNull();
|
||||||
|
const rolled = generatePackage({ ...base, rng: mulberry32(42) });
|
||||||
|
expect(rolled.special === null || SPECIALS[rolled.special] !== undefined).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies the registry reward multiplier to the base reward', () => {
|
||||||
|
const c = generatePackage({ id: 'c-sp-r', airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW, originIcao: 'EDDF', destIcao: 'EHAM', weightKg: 1_000, special: 'priority' });
|
||||||
|
const km = haversineKm(AIRPORTS[0].lat, AIRPORTS[0].lon, AIRPORTS[1].lat, AIRPORTS[1].lon); // EDDF → EHAM
|
||||||
|
expect(c.reward).toBe(Math.round(rewardFor(km, 1_000) * SPECIALS.priority.rewardMultiplier));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('scales the drawn deadline window by the registry factor', () => {
|
||||||
|
const c = generatePackage({ id: 'c-sp-d', airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW, special: 'priority' });
|
||||||
|
expect(c.deadlineSimTime).toBeGreaterThan(NOW + 6 * 3_600_000 * SPECIALS.priority.deadlineScale - 1);
|
||||||
|
expect(c.deadlineSimTime).toBeLessThanOrEqual(NOW + 72 * 3_600_000 * SPECIALS.priority.deadlineScale);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a forced deadline offset skips the special scaling', () => {
|
||||||
|
const c = generatePackage({ id: 'c-sp-f', airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW, special: 'priority', deadlineOffsetMs: 10_000 });
|
||||||
|
expect(c.deadlineSimTime).toBe(NOW + 10_000); // forced offset untouched by the scale
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a forced special (including explicit null) skips the appended RNG draw', () => {
|
||||||
|
const countDraws = (special?: string | null): number => {
|
||||||
|
let n = 0;
|
||||||
|
const base = mulberry32(42);
|
||||||
|
generatePackage({ id: 'c-sp-n', airports: AIRPORTS, rng: () => { n += 1; return base(); }, nowMs: NOW, special });
|
||||||
|
return n;
|
||||||
|
};
|
||||||
|
// origin, dest, cargoType, cargoCount, weight jitter, deadline window — the fixed order minus the appended draw.
|
||||||
|
expect(countDraws('priority')).toBe(6);
|
||||||
|
expect(countDraws(null)).toBe(6); // explicit null skips too
|
||||||
|
expect(countDraws()).toBeGreaterThanOrEqual(7); // pickSpecial draws 1–2 extra
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('reputation job bonus (v0.38.0, ISS-072)', () => {
|
||||||
|
it('multiplies the reward by 1 + repRate at elevated reputation', () => {
|
||||||
|
const c = generatePackage({ id: 'c-rep', airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW, originIcao: 'EDDF', destIcao: 'EHAM', weightKg: 1_000, special: null, reputation: 60 });
|
||||||
|
const km = haversineKm(AIRPORTS[0].lat, AIRPORTS[0].lon, AIRPORTS[1].lat, AIRPORTS[1].lon); // EDDF → EHAM
|
||||||
|
expect(repRate(60, JOB_BONUS_PER_REP, MAX_JOB_BONUS)).toBeCloseTo(0.5, 12); // rep 60 → ×1.5
|
||||||
|
expect(c.reward).toBe(Math.round(rewardFor(km, 1_000) * (1 + repRate(60, JOB_BONUS_PER_REP, MAX_JOB_BONUS))));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults to the starting reputation when omitted — byte-identical output', () => {
|
||||||
|
const a = generatePackage({ id: 'c-r0', airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW });
|
||||||
|
const b = generatePackage({ id: 'c-r0', airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW, reputation: STARTING_REPUTATION });
|
||||||
|
expect(a).toEqual(b); // no bonus at the starting value
|
||||||
|
});
|
||||||
|
|
||||||
|
it('composes with the special multiplier (base × jobBonus × special)', () => {
|
||||||
|
const c = generatePackage({ id: 'c-rep-sp', airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW, originIcao: 'EDDF', destIcao: 'EHAM', weightKg: 1_000, special: 'priority', reputation: 60 });
|
||||||
|
const km = haversineKm(AIRPORTS[0].lat, AIRPORTS[0].lon, AIRPORTS[1].lat, AIRPORTS[1].lon); // EDDF → EHAM
|
||||||
|
expect(c.reward).toBe(
|
||||||
|
Math.round(rewardFor(km, 1_000) * (1 + repRate(60, JOB_BONUS_PER_REP, MAX_JOB_BONUS)) * SPECIALS.priority.rewardMultiplier),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('consumes no extra RNG draw', () => {
|
||||||
|
const countDraws = (reputation?: number): number => {
|
||||||
|
let n = 0;
|
||||||
|
const base = mulberry32(42);
|
||||||
|
generatePackage({ id: 'c-rep-n', airports: AIRPORTS, rng: () => { n += 1; return base(); }, nowMs: NOW, special: null, reputation });
|
||||||
|
return n;
|
||||||
|
};
|
||||||
|
expect(countDraws()).toBe(6); // forced-null special → the fixed order minus the appended draw
|
||||||
|
expect(countDraws(60)).toBe(6); // the rep hook consumes no draws
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+74
-14
@@ -1,5 +1,6 @@
|
|||||||
import { haversineKm } from '@/lib/geo';
|
import { haversineKm } from '@/lib/geo';
|
||||||
import { PLANE_TYPES, STARTER_MODEL } from './catalog';
|
import { PLANE_TYPES, repRate, STARTER_MODEL } from './catalog';
|
||||||
|
import { pickSpecial, SPECIALS } from './specials';
|
||||||
|
|
||||||
// First-run seed. Everything here is a pure function of (airports, rng, nowMs)
|
// First-run seed. Everything here is a pure function of (airports, rng, nowMs)
|
||||||
// so it is fully deterministic and unit-testable: the RNG is injected, never
|
// so it is fully deterministic and unit-testable: the RNG is injected, never
|
||||||
@@ -38,6 +39,11 @@ 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[];
|
||||||
|
// Structural health in [0, 100] (v0.39.0, ISS-069) — new planes start at full health.
|
||||||
|
health: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SeedPackage {
|
export interface SeedPackage {
|
||||||
@@ -47,6 +53,10 @@ 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;
|
||||||
|
// Special status (v0.37.0, ISS-073) — key into lib/game/specials.ts SPECIALS; null = none.
|
||||||
|
special: string | null;
|
||||||
deadlineSimTime: number;
|
deadlineSimTime: number;
|
||||||
status: 'open';
|
status: 'open';
|
||||||
planeId: null;
|
planeId: null;
|
||||||
@@ -57,7 +67,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 +87,19 @@ 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;
|
||||||
|
|
||||||
|
// Job reward bonus (v0.38.0, ISS-072): reward ×= 1 + repRate(reputation, …) — better jobs at
|
||||||
|
// higher reputation, exactly ×1 at the starting value.
|
||||||
|
// ponytail: balance-tunable — job reward bonus per rep point above the starting value.
|
||||||
|
export const JOB_BONUS_PER_REP = 0.01;
|
||||||
|
// ponytail: balance-tunable — max job reward bonus (up to ×2 at rep ≥ 110).
|
||||||
|
export const MAX_JOB_BONUS = 1.0;
|
||||||
|
|
||||||
// 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 +107,38 @@ 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], [special].
|
||||||
// v0.8.0: originIcao/destIcao optionally FORCE an endpoint (spawn coverage rule +
|
// v0.8.0: originIcao/destIcao optionally FORCE an endpoint (spawn coverage rule +
|
||||||
// the e2e create-package hook); a forced endpoint consumes no RNG draws, so
|
// the e2e create-package hook); a forced endpoint consumes no RNG draws, so
|
||||||
// callers that omit both get byte-identical behavior to before.
|
// callers that omit both get byte-identical behavior to before.
|
||||||
|
// v0.27.0: weightKg optionally FORCES the package weight (e2e hook) — it skips the
|
||||||
|
// jitter draw, same pattern as forced endpoints.
|
||||||
|
// v0.37.0 (ISS-073): special optionally FORCES the special status (string or explicit null) —
|
||||||
|
// it skips the appended pickSpecial draw; otherwise ~SPECIAL_SPAWN_CHANCE of packages roll one.
|
||||||
|
// v0.38.0 (ISS-072): reputation optionally scales the reward (×(1 + repRate)) at the same spot
|
||||||
|
// as the special multiplier — a pure input, it consumes NO RNG draws and defaults to the
|
||||||
|
// starting value so seed-time behavior is byte-identical.
|
||||||
export function generatePackage(input: {
|
export function generatePackage(input: {
|
||||||
id: string;
|
id: string;
|
||||||
airports: SeedAirport[];
|
airports: SeedAirport[];
|
||||||
@@ -109,6 +147,9 @@ export function generatePackage(input: {
|
|||||||
deadlineOffsetMs?: number;
|
deadlineOffsetMs?: number;
|
||||||
originIcao?: string;
|
originIcao?: string;
|
||||||
destIcao?: string;
|
destIcao?: string;
|
||||||
|
weightKg?: number;
|
||||||
|
special?: string | null;
|
||||||
|
reputation?: 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,16 +181,31 @@ 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);
|
||||||
const deadlineMs =
|
// Weight (v0.27.0): unit weight × count with ±30% jitter, integer kg — drawn AFTER
|
||||||
input.deadlineOffsetMs ?? (DEADLINE_MIN_HOURS + rng() * (DEADLINE_MAX_HOURS - DEADLINE_MIN_HOURS)) * 3_600_000;
|
// 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()));
|
||||||
|
// Drawn deadline window — the existing draw, position unchanged; special scaling is applied
|
||||||
|
// below AFTER the appended special draw so the fixed order above holds.
|
||||||
|
const drawnWindowMs = (DEADLINE_MIN_HOURS + rng() * (DEADLINE_MAX_HOURS - DEADLINE_MIN_HOURS)) * 3_600_000;
|
||||||
|
// v0.37.0 (ISS-073): special status — the appended draw at the END of the fixed order above; a
|
||||||
|
// forced value (including explicit null) skips it, same pattern as the other overrides.
|
||||||
|
const special = input.special !== undefined ? input.special : pickSpecial(rng);
|
||||||
|
const mods = special !== null ? SPECIALS[special] : undefined;
|
||||||
|
// Forced deadlines skip the special scaling — consistent with overrides skipping draws.
|
||||||
|
const deadlineMs = input.deadlineOffsetMs ?? drawnWindowMs * (mods?.deadlineScale ?? 1);
|
||||||
|
|
||||||
|
// v0.38.0 (ISS-072): reputation bonus composed with the special multiplier, single round at the end.
|
||||||
|
const repBonus = 1 + repRate(input.reputation ?? STARTING_REPUTATION, JOB_BONUS_PER_REP, MAX_JOB_BONUS);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: input.id,
|
id: input.id,
|
||||||
originIcao: origin.icao,
|
originIcao: origin.icao,
|
||||||
destIcao: dest.icao,
|
destIcao: dest.icao,
|
||||||
reward: rewardForDistanceKm(km),
|
reward: Math.round(rewardFor(km, weightKg) * repBonus * (mods?.rewardMultiplier ?? 1)),
|
||||||
cargoType,
|
cargoType,
|
||||||
cargoCount,
|
cargoCount,
|
||||||
|
weightKg,
|
||||||
|
special,
|
||||||
deadlineSimTime: Math.round(nowMs + deadlineMs),
|
deadlineSimTime: Math.round(nowMs + deadlineMs),
|
||||||
status: 'open',
|
status: 'open',
|
||||||
planeId: null,
|
planeId: null,
|
||||||
@@ -182,6 +238,8 @@ 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)
|
||||||
|
health: 100, // full health on first run (v0.39.0, ISS-069)
|
||||||
};
|
};
|
||||||
|
|
||||||
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 +256,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 },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { mulberry32, type Rng } from './seed';
|
||||||
|
import { pickSpecial, SPECIALS, SPECIAL_SPAWN_CHANCE } from './specials';
|
||||||
|
|
||||||
|
describe('pickSpecial (v0.37.0, ISS-073)', () => {
|
||||||
|
it('returns null when the chance draw fails and consumes exactly one draw', () => {
|
||||||
|
let draws = 0;
|
||||||
|
const rng: Rng = () => { draws += 1; return 0.9; }; // ≥ SPECIAL_SPAWN_CHANCE → no special
|
||||||
|
expect(pickSpecial(rng)).toBeNull();
|
||||||
|
expect(draws).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('picks among the registry keys when the chance draw passes', () => {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (let i = 0; i < 2_000; i++) {
|
||||||
|
let call = 0;
|
||||||
|
// First call is the chance draw (< chance → roll); later calls sweep [0, 1) as index draws.
|
||||||
|
const rng: Rng = () => (call++ === 0 ? 0.1 : i / 2_000);
|
||||||
|
const s = pickSpecial(rng);
|
||||||
|
if (s !== null) seen.add(s);
|
||||||
|
}
|
||||||
|
expect([...seen].sort()).toEqual(Object.keys(SPECIALS).sort()); // every key reachable
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never returns a status whose entry was removed; an empty registry consumes no draws', () => {
|
||||||
|
const withoutPriority = Object.fromEntries(Object.entries(SPECIALS).filter(([k]) => k !== 'priority'));
|
||||||
|
for (let i = 0; i < 500; i++) {
|
||||||
|
expect(pickSpecial(mulberry32(i), withoutPriority)).not.toBe('priority'); // removing an entry disables it
|
||||||
|
}
|
||||||
|
let draws = 0;
|
||||||
|
const rng: Rng = () => { draws += 1; return 0.0; }; // would pass the chance draw if consulted
|
||||||
|
expect(pickSpecial(rng, {})).toBeNull();
|
||||||
|
expect(draws).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rolls a special for roughly SPECIAL_SPAWN_CHANCE of packages (fixed RNG)', () => {
|
||||||
|
const rng = mulberry32(42);
|
||||||
|
let hits = 0;
|
||||||
|
for (let i = 0; i < 10_000; i++) if (pickSpecial(rng) !== null) hits++;
|
||||||
|
expect(hits / 10_000).toBeGreaterThan(SPECIAL_SPAWN_CHANCE - 0.05);
|
||||||
|
expect(hits / 10_000).toBeLessThan(SPECIAL_SPAWN_CHANCE + 0.05);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
// Special-status registry (v0.37.0, ISS-073) — data-driven package modifiers: one entry per
|
||||||
|
// special status maps to reward/deadline/reputation tweaks applied at generation and delivery.
|
||||||
|
// Adding or removing a special is an edit of ONE entry here — no scattered branches elsewhere.
|
||||||
|
import type { Rng } from './seed';
|
||||||
|
|
||||||
|
export interface SpecialModifiers {
|
||||||
|
// Multiplier on the base reward (applied at package generation).
|
||||||
|
rewardMultiplier: number;
|
||||||
|
// Scale factor on the drawn deadline window (applied at generation; forced offsets skip it).
|
||||||
|
deadlineScale: number;
|
||||||
|
// Flat reputation bonus added to the distance-based gain at delivery.
|
||||||
|
repBonus: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SPECIALS: Record<string, SpecialModifiers> = {
|
||||||
|
priority: {
|
||||||
|
rewardMultiplier: 1.5, // ponytail: balance-tunable — a 50% premium for the tighter deadline below
|
||||||
|
deadlineScale: 0.5, // ponytail: balance-tunable — half the normal 6–72h window (3–36h)
|
||||||
|
repBonus: 0.5, // ponytail: balance-tunable — flat rep on top of the distance-based gain at delivery
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fraction of generated packages that roll a special status (v0.37.0).
|
||||||
|
export const SPECIAL_SPAWN_CHANCE = 0.15;
|
||||||
|
|
||||||
|
// Pure spawn helper: one chance draw, then a uniform pick among the registry's keys. The registry
|
||||||
|
// is a PARAM defaulting to the real one so tests can pass a filtered/empty record — removing an
|
||||||
|
// entry cleanly disables that status (an empty registry consumes no draws at all).
|
||||||
|
export function pickSpecial(rng: Rng, registry: Record<string, SpecialModifiers> = SPECIALS): string | null {
|
||||||
|
const keys = Object.keys(registry);
|
||||||
|
if (keys.length === 0) return null;
|
||||||
|
if (rng() >= SPECIAL_SPAWN_CHANCE) return null;
|
||||||
|
return keys[Math.floor(rng() * keys.length)];
|
||||||
|
}
|
||||||
+50
-6
@@ -1,16 +1,18 @@
|
|||||||
import { loadAirportsGeoJson } from '@/lib/airports';
|
import { loadAirportsGeoJson } from '@/lib/airports';
|
||||||
import { haversineKm } from '@/lib/geo';
|
import { haversineKm, initialBearingDeg } from '@/lib/geo';
|
||||||
|
import { weatherFor } from '@/lib/weather';
|
||||||
import { getGameDb } from '@/lib/db/client';
|
import { getGameDb } from '@/lib/db/client';
|
||||||
import * as schema from '@/lib/db/schema';
|
import * as schema from '@/lib/db/schema';
|
||||||
import { catalogEntries, FUEL_PRICE_PER_L, PLANE_TYPES, SELL_FRACTION, type CatalogEntry } from './catalog';
|
import { conditionBurnMultiplier, catalogEntries, FUEL_PRICE_PER_L, PLANE_TYPES, SELL_FRACTION, type CatalogEntry } from './catalog';
|
||||||
import { fuelAtWeighted, progressAt, positionAt } from '@/lib/sim/movement';
|
import { fuelAtWeighted, progressAt, positionAt, windBurnFactor } from '@/lib/sim/movement';
|
||||||
|
|
||||||
// Read-only game-state snapshot for client polling (GET /api/state).
|
// Read-only game-state snapshot for client polling (GET /api/state).
|
||||||
|
|
||||||
export interface PlaneState {
|
export interface PlaneState {
|
||||||
id: string;
|
id: string;
|
||||||
model: string;
|
model: string;
|
||||||
status: 'available' | 'flying';
|
// v0.39.0 (ISS-069): 'repairing' = grounded in the shop until repairUntilSimTime passes.
|
||||||
|
status: 'available' | 'flying' | 'repairing';
|
||||||
fuel: number;
|
fuel: number;
|
||||||
maxFuel: number;
|
maxFuel: number;
|
||||||
locationIcao: string;
|
locationIcao: string;
|
||||||
@@ -24,6 +26,26 @@ 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[];
|
||||||
|
// Structural health in [0, 100] (v0.39.0, ISS-069) — wears down with distance flown; at 0 the
|
||||||
|
// plane is broken and burns BROKEN_BURN_MULTIPLIER × more fuel until repaired.
|
||||||
|
health: number;
|
||||||
|
// Sim-clock epoch ms when an active repair finishes (v0.39.0, ISS-069); null when not in repair.
|
||||||
|
repairUntilSimTime: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 +57,13 @@ 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;
|
||||||
|
// Special status (v0.37.0, ISS-073) — key into lib/game/specials.ts SPECIALS; null = none.
|
||||||
|
special: string | null;
|
||||||
deadlineSimTime: number;
|
deadlineSimTime: number;
|
||||||
status: 'open' | 'loaded' | 'delivered' | 'expired' | 'abandoned';
|
// 'ignored' (v0.29.0, ISS-047 part B) = a dismissed open job — terminal, leaves the market.
|
||||||
|
status: 'open' | 'loaded' | 'delivered' | 'expired' | 'abandoned' | 'ignored';
|
||||||
planeId: string | null;
|
planeId: string | null;
|
||||||
createdSimTime: number;
|
createdSimTime: number;
|
||||||
}
|
}
|
||||||
@@ -84,7 +111,19 @@ export async function getStateSnapshot(): Promise<StateSnapshot> {
|
|||||||
const distanceKm = haversineKm(from.lat, from.lon, to.lat, to.lon);
|
const distanceKm = haversineKm(from.lat, from.lon, to.lat, to.lon);
|
||||||
const progress = progressAt(p.departureSimTime, distanceKm, type.speedKmh, simRow.clockMs);
|
const progress = progressAt(p.departureSimTime, distanceKm, type.speedKmh, simRow.clockMs);
|
||||||
const pos = positionAt(from.lat, from.lon, to.lat, to.lon, progress);
|
const pos = positionAt(from.lat, from.lon, to.lat, to.lon, progress);
|
||||||
return { ...pos, fuel: fuelAtWeighted(p.fuel, type.burnPerKm, type.emptyWeightKg, type.maxFuel, distanceKm, progress) };
|
// Wind-adjusted burn (v0.36.0) — weather is a pure function of the departure ICAO, so this
|
||||||
|
// recomputes exactly the factor the engine used for the leg; no stored state needed.
|
||||||
|
// v0.39.0 (ISS-069): broken planes burn BROKEN_BURN_MULTIPLIER × more — p.health is frozen at
|
||||||
|
// departure (wear lands only), so this stays bit-consistent with the engine's landed fuel.
|
||||||
|
const w = weatherFor(p.locationIcao);
|
||||||
|
return { ...pos, fuel: fuelAtWeighted(
|
||||||
|
p.fuel,
|
||||||
|
type.burnPerKm * windBurnFactor(w.windKt, w.windDirDeg, initialBearingDeg(from.lat, from.lon, to.lat, to.lon)) * conditionBurnMultiplier(p.health),
|
||||||
|
type.emptyWeightKg,
|
||||||
|
type.maxFuel,
|
||||||
|
distanceKm,
|
||||||
|
progress,
|
||||||
|
) };
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -108,6 +147,9 @@ 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),
|
||||||
|
health: p.health, // v0.39.0 (ISS-069) — stored value; wear accumulates at landing only
|
||||||
|
repairUntilSimTime: p.repairUntilSimTime,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
packages: packageRows.map((p) => ({
|
packages: packageRows.map((p) => ({
|
||||||
@@ -119,6 +161,8 @@ 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,
|
||||||
|
special: p.special,
|
||||||
deadlineSimTime: p.deadlineSimTime,
|
deadlineSimTime: p.deadlineSimTime,
|
||||||
status: p.status,
|
status: p.status,
|
||||||
planeId: p.planeId,
|
planeId: p.planeId,
|
||||||
|
|||||||
+38
-1
@@ -1,5 +1,42 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { haversineKm } from './geo';
|
import { haversineKm, initialBearingDeg } from './geo';
|
||||||
|
|
||||||
|
describe('initialBearingDeg', () => {
|
||||||
|
it('is due north (0) for a point directly above', () => {
|
||||||
|
expect(initialBearingDeg(0, 0, 10, 0)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is due east (90) along the equator', () => {
|
||||||
|
expect(initialBearingDeg(0, -10, 0, 10)).toBeCloseTo(90, 9);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is due south (180) for a point directly below', () => {
|
||||||
|
expect(initialBearingDeg(10, 0, 0, 0)).toBeCloseTo(180, 9);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is due west (270) along the equator — normalized into [0, 360)', () => {
|
||||||
|
expect(initialBearingDeg(0, 10, 0, -10)).toBeCloseTo(270, 9);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('always normalizes to [0, 360) for arbitrary pairs', () => {
|
||||||
|
const pairs: Array<[number, number, number, number]> = [
|
||||||
|
[50.03, 8.57, 40.47, -3.56], // FRA → MAD (southwest-ish)
|
||||||
|
[40.47, -3.56, 50.03, 8.57], // reverse: northeast-ish
|
||||||
|
[65.66, -18.07, 50.03, 8.57], // far north → south-east wrap case
|
||||||
|
];
|
||||||
|
for (const [a1, a2, b1, b2] of pairs) {
|
||||||
|
const brg = initialBearingDeg(a1, a2, b1, b2);
|
||||||
|
expect(brg).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(brg).toBeLessThan(360);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FRA → MAD points roughly south-southwest (between 180 and 270)', () => {
|
||||||
|
const brg = initialBearingDeg(50.03, 8.57, 40.47, -3.56);
|
||||||
|
expect(brg).toBeGreaterThan(180);
|
||||||
|
expect(brg).toBeLessThan(270);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('haversineKm', () => {
|
describe('haversineKm', () => {
|
||||||
it('returns 0 for identical points', () => {
|
it('returns 0 for identical points', () => {
|
||||||
|
|||||||
+13
@@ -1,5 +1,18 @@
|
|||||||
const EARTH_RADIUS_KM = 6371;
|
const EARTH_RADIUS_KM = 6371;
|
||||||
|
|
||||||
|
// Initial great-circle bearing from point 1 to point 2 (v0.36.0), degrees true [0, 360).
|
||||||
|
export function initialBearingDeg(lat1: number, lon1: number, lat2: number, lon2: number): number {
|
||||||
|
const toRad = (d: number) => (d * Math.PI) / 180;
|
||||||
|
const phi1 = toRad(lat1);
|
||||||
|
const phi2 = toRad(lat2);
|
||||||
|
const dLon = toRad(lon2 - lon1);
|
||||||
|
const theta = Math.atan2(
|
||||||
|
Math.sin(dLon) * Math.cos(phi2),
|
||||||
|
Math.cos(phi1) * Math.sin(phi2) - Math.sin(phi1) * Math.cos(phi2) * Math.cos(dLon),
|
||||||
|
);
|
||||||
|
return (theta * 180 / Math.PI + 360) % 360;
|
||||||
|
}
|
||||||
|
|
||||||
// Great-circle distance between two WGS84 points (haversine), in km.
|
// Great-circle distance between two WGS84 points (haversine), in km.
|
||||||
export function haversineKm(lat1: number, lon1: number, lat2: number, lon2: number): number {
|
export function haversineKm(lat1: number, lon1: number, lat2: number, lon2: number): number {
|
||||||
const toRad = (d: number) => (d * Math.PI) / 180;
|
const toRad = (d: number) => (d * Math.PI) / 180;
|
||||||
|
|||||||
+74
-11
@@ -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, hoverTraceGeoJson, 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, health: 100, repairUntilSimTime: null, 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, health: 100, repairUntilSimTime: null, 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, health: 100, repairUntilSimTime: null, 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, health: 100, repairUntilSimTime: null, 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, health: 100, repairUntilSimTime: null, 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, health: 100, repairUntilSimTime: null, 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, health: 100, repairUntilSimTime: null, 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, health: 100, repairUntilSimTime: null, 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, health: 100, repairUntilSimTime: null, 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, health: 100, repairUntilSimTime: null, 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, health: 100, repairUntilSimTime: null, 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, health: 100, repairUntilSimTime: null, 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, health: 100, repairUntilSimTime: null, 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, health: 100, repairUntilSimTime: null, 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, health: 100, repairUntilSimTime: null, 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, health: 100, repairUntilSimTime: null, 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, health: 100, repairUntilSimTime: null, 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: [] });
|
||||||
|
|||||||
+31
-2
@@ -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';
|
||||||
|
|
||||||
@@ -10,7 +10,8 @@ import { positionAt } from './sim/movement';
|
|||||||
export interface PlaneFeatureProps {
|
export interface PlaneFeatureProps {
|
||||||
id: string;
|
id: string;
|
||||||
modelLabel: string;
|
modelLabel: string;
|
||||||
status: 'available' | 'flying';
|
// v0.39.0 (ISS-069): 'repairing' planes render on the grounded layer (flying === false).
|
||||||
|
status: 'available' | 'flying' | 'repairing';
|
||||||
fuelText: string; // "1234 / 6300 L"
|
fuelText: string; // "1234 / 6300 L"
|
||||||
routeText: string; // "Origin → Destination" while flying, '' when grounded
|
routeText: string; // "Origin → Destination" while flying, '' when grounded
|
||||||
flying: boolean;
|
flying: boolean;
|
||||||
@@ -164,3 +165,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 };
|
||||||
|
}
|
||||||
|
|||||||
+37
-6
@@ -3,15 +3,19 @@ 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 fourteen public endpoints (4 read + 10 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/planes/{planeId}/repair', // v0.39.0 (ISS-069)
|
||||||
'/api/sim',
|
'/api/sim',
|
||||||
'/api/state',
|
'/api/state',
|
||||||
'/api/version',
|
'/api/version',
|
||||||
@@ -92,18 +96,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');
|
||||||
|
|
||||||
@@ -113,6 +116,7 @@ describe('OpenAPI spec', () => {
|
|||||||
'destIcao',
|
'destIcao',
|
||||||
'destName',
|
'destName',
|
||||||
'fuel',
|
'fuel',
|
||||||
|
'health', // v0.39.0 (ISS-069) — structural health in [0, 100]
|
||||||
'id',
|
'id',
|
||||||
'lat',
|
'lat',
|
||||||
'locationIcao',
|
'locationIcao',
|
||||||
@@ -120,6 +124,8 @@ describe('OpenAPI spec', () => {
|
|||||||
'lon',
|
'lon',
|
||||||
'maxFuel',
|
'maxFuel',
|
||||||
'model',
|
'model',
|
||||||
|
'queue', // v0.25.0 (ISS-043) — upcoming legs not yet started
|
||||||
|
'repairUntilSimTime', // v0.39.0 (ISS-069) — active repair gate; null when not repairing
|
||||||
'status',
|
'status',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -136,14 +142,16 @@ describe('OpenAPI spec', () => {
|
|||||||
'originName',
|
'originName',
|
||||||
'planeId',
|
'planeId',
|
||||||
'reward',
|
'reward',
|
||||||
|
'special', // v0.37.0 (ISS-073) — special status from the specials registry
|
||||||
'status',
|
'status',
|
||||||
|
'weightKg', // v0.27.0 (ISS-045) — package weight in kg
|
||||||
]);
|
]);
|
||||||
|
|
||||||
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 +177,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 nine 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,9 +191,12 @@ 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',
|
||||||
|
'/api/planes/{planeId}/repair', // v0.39.0 (ISS-069)
|
||||||
]) {
|
]) {
|
||||||
const ok = post(p).responses['200'] as { content: Record<string, { schema: { $ref: string } }> };
|
const ok = post(p).responses['200'] as { content: Record<string, { schema: { $ref: string } }> };
|
||||||
expect(ok.content['application/json'].schema.$ref).toBe('#/components/schemas/StateSnapshot');
|
expect(ok.content['application/json'].schema.$ref).toBe('#/components/schemas/StateSnapshot');
|
||||||
@@ -196,9 +207,12 @@ 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', '409']); // v0.34.0 (ISS-065) — 409 on duplicate leg / active-leg re-queue
|
||||||
|
expect(errorCodes('/api/planes/{planeId}/queue/cancel')).toEqual(['400', '404']); // v0.25.0
|
||||||
expect(errorCodes('/api/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
|
||||||
|
expect(errorCodes('/api/planes/{planeId}/repair')).toEqual(['404', '409']); // v0.39.0 (ISS-069) — no body, so no 400
|
||||||
|
|
||||||
// Request bodies: load/fly/buy/sell take their dedicated schemas; abandon takes none;
|
// Request bodies: load/fly/buy/sell take their dedicated schemas; abandon takes none;
|
||||||
// refuel's is OPTIONAL (v0.16.0) — no body still means "fill to full".
|
// refuel's is OPTIONAL (v0.16.0) — no body still means "fill to full".
|
||||||
@@ -208,6 +222,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',
|
||||||
);
|
);
|
||||||
@@ -218,13 +238,17 @@ describe('OpenAPI spec', () => {
|
|||||||
const refuelBody = post('/api/planes/{planeId}/refuel').requestBody;
|
const refuelBody = post('/api/planes/{planeId}/refuel').requestBody;
|
||||||
expect(refuelBody?.content['application/json'].schema.$ref).toBe('#/components/schemas/RefuelRequest');
|
expect(refuelBody?.content['application/json'].schema.$ref).toBe('#/components/schemas/RefuelRequest');
|
||||||
expect(refuelBody?.required).toBe(false); // optional — no body still means "fill to full"
|
expect(refuelBody?.required).toBe(false); // optional — no body still means "fill to full"
|
||||||
|
expect(post('/api/planes/{planeId}/repair').requestBody).toBeUndefined(); // v0.39.0 (ISS-069) — no body, like abandon
|
||||||
|
|
||||||
// Path parameters are declared on the parameterized routes.
|
// Path parameters are declared on the parameterized routes.
|
||||||
const paramNames = (p: string) => post(p).parameters?.map((x) => x.name);
|
const paramNames = (p: string) => post(p).parameters?.map((x) => x.name);
|
||||||
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']);
|
||||||
|
expect(paramNames('/api/planes/{planeId}/repair')).toEqual(['planeId']); // v0.39.0 (ISS-069)
|
||||||
|
|
||||||
// Request schema shapes.
|
// Request schema shapes.
|
||||||
const load = openApiSpec.components.schemas.LoadPackageRequest;
|
const load = openApiSpec.components.schemas.LoadPackageRequest;
|
||||||
@@ -233,6 +257,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');
|
||||||
|
|||||||
+173
-13
@@ -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,11 +229,84 @@ 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' } } },
|
||||||
|
},
|
||||||
|
409: {
|
||||||
|
description:
|
||||||
|
'The destination is already in the flight queue, or (while flying) it equals the active leg\'s destination (v0.34.0, ISS-065)',
|
||||||
|
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'/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',
|
||||||
description:
|
description:
|
||||||
'Buy an aircraft at its full catalog price (v0.8.0). The new plane arrives "available" with a full tank at the home base (EDDF). Only models in the in-code catalog are known.',
|
'Buy an aircraft at its catalog price minus a reputation-scaled discount (v0.38.0, ISS-072 — higher reputation buys cheaper; no discount at the starting reputation). The new plane arrives "available" with a full tank at the home base (EDDF). Only models in the in-code catalog are known.',
|
||||||
operationId: 'buyPlane',
|
operationId: 'buyPlane',
|
||||||
requestBody: {
|
requestBody: {
|
||||||
required: true,
|
required: true,
|
||||||
@@ -272,7 +370,7 @@ export const openApiSpec = {
|
|||||||
post: {
|
post: {
|
||||||
summary: 'Refuel a grounded plane',
|
summary: 'Refuel a grounded plane',
|
||||||
description:
|
description:
|
||||||
'Refuel a grounded plane at the fuel price per added liter, deducted from cash (v0.8.0). The body is optional (v0.16.0): no body or mode "full" fills to maxFuel; mode "forFlight" with packageId adds just enough for that loaded package\'s destination leg — liters = required fuel for the great-circle distance (weight-proportional burn model) minus what is already in the tank.',
|
'Refuel a grounded plane at the fuel price per added liter, deducted from cash (v0.8.0); the per-liter price is reduced by a reputation-scaled discount (v0.38.0, ISS-072 — higher reputation refuels cheaper; no discount at the starting reputation). The body is optional (v0.16.0): no body or mode "full" fills to maxFuel; mode "forFlight" with packageId buys 150% of the weather-adjusted fuel required for that loaded package\'s destination leg, capped at tank capacity — liters = min(maxFuel, max(current fuel, 1.5 × required)) − current fuel, where required is the weight-proportional burn model over the great-circle distance with a ±20% wind adjustment (v0.36.0).',
|
||||||
operationId: 'refuelPlane',
|
operationId: 'refuelPlane',
|
||||||
parameters: [
|
parameters: [
|
||||||
{ name: 'planeId', in: 'path', required: true, schema: { type: 'string' } },
|
{ name: 'planeId', in: 'path', required: true, schema: { type: 'string' } },
|
||||||
@@ -306,6 +404,31 @@ export const openApiSpec = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
'/api/planes/{planeId}/repair': {
|
||||||
|
post: {
|
||||||
|
summary: 'Repair a grounded plane',
|
||||||
|
description:
|
||||||
|
'Restore a worn-out plane to full health for the flat repair cost (v0.39.0, ISS-069): the plane is grounded in "repairing" status until 3 sim days pass, when the engine\'s tick flips it back to "available". While repairing it cannot load, fly, refuel or be sold; a broken plane (health ≤ 0) still flies but burns 50% more fuel. A full-health repair is a free no-op (cost 0). No body.',
|
||||||
|
operationId: 'repairPlane',
|
||||||
|
parameters: [
|
||||||
|
{ name: 'planeId', in: 'path', required: true, schema: { type: 'string' } },
|
||||||
|
],
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: 'Repair started (or free no-op) — full state snapshot (health 100, status "repairing" with the gate set, cash reduced by the flat cost)',
|
||||||
|
content: { 'application/json': { schema: { $ref: '#/components/schemas/StateSnapshot' } } },
|
||||||
|
},
|
||||||
|
404: {
|
||||||
|
description: 'Unknown plane id',
|
||||||
|
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||||||
|
},
|
||||||
|
409: {
|
||||||
|
description: 'Plane is not grounded (flying or already repairing), or cash is insufficient for the repair cost',
|
||||||
|
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
components: {
|
components: {
|
||||||
schemas: {
|
schemas: {
|
||||||
@@ -378,7 +501,7 @@ export const openApiSpec = {
|
|||||||
description: 'Aircraft catalog (v0.10.0) — static per build, one entry per plane type.',
|
description: 'Aircraft catalog (v0.10.0) — static per build, one entry per plane type.',
|
||||||
items: { $ref: '#/components/schemas/CatalogEntry' },
|
items: { $ref: '#/components/schemas/CatalogEntry' },
|
||||||
},
|
},
|
||||||
fuelPricePerL: { type: 'number', description: 'Jet-A1 price in €/liter; refuel cost = (maxFuel − fuel) × this.' },
|
fuelPricePerL: { type: 'number', description: 'Jet-A1 price in €/liter; refuel cost = (maxFuel − fuel) × this, reduced by a reputation-scaled discount at refuel time (v0.38.0).' },
|
||||||
sellFraction: { type: 'number', description: 'Fraction of the catalog price paid out when selling a plane.' },
|
sellFraction: { type: 'number', description: 'Fraction of the catalog price paid out when selling a plane.' },
|
||||||
},
|
},
|
||||||
required: ['account', 'simState', 'planes', 'packages', 'catalog', 'fuelPricePerL', 'sellFraction'],
|
required: ['account', 'simState', 'planes', 'packages', 'catalog', 'fuelPricePerL', 'sellFraction'],
|
||||||
@@ -388,20 +511,20 @@ export const openApiSpec = {
|
|||||||
properties: {
|
properties: {
|
||||||
model: { type: 'string', description: 'Catalog model id (e.g. "atr72") — used by buy/fly.' },
|
model: { type: 'string', description: 'Catalog model id (e.g. "atr72") — used by buy/fly.' },
|
||||||
label: { type: 'string', description: 'Display name (e.g. "ATR 72-600").' },
|
label: { type: 'string', description: 'Display name (e.g. "ATR 72-600").' },
|
||||||
price: { type: 'number', description: 'Market price in € (buy cost; sell pays out sellFraction × this).' },
|
price: { type: 'number', description: 'Market price in € — the full catalog list price. Buying costs this minus a reputation-scaled discount at purchase time (v0.38.0); selling pays out sellFraction × this.' },
|
||||||
maxFuel: { type: 'number', description: 'Fuel capacity, liters.' },
|
maxFuel: { type: 'number', description: 'Fuel capacity, liters.' },
|
||||||
burnPerKm: { type: 'number', description: 'Cruise fuel burn at a full tank, liters/km (v0.16.0 weighted model calibrates to this).' },
|
burnPerKm: { type: 'number', description: 'Cruise fuel burn at a full tank, liters/km (v0.16.0 weighted model calibrates to this). Actual per-leg burn varies ±20% with wind — departure weather vs route heading (v0.36.0).' },
|
||||||
emptyWeightKg: { type: 'number', description: 'Operating empty weight, kg — with burnPerKm and maxFuel it drives the v0.16.0 weight-proportional burn model.' },
|
emptyWeightKg: { type: 'number', description: 'Operating empty weight, kg — with burnPerKm and maxFuel it drives the v0.16.0 weight-proportional burn model.' },
|
||||||
speedKmh: { type: 'number', description: 'Cruise speed, km/h.' },
|
speedKmh: { type: 'number', description: 'Cruise speed, km/h.' },
|
||||||
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',
|
||||||
properties: {
|
properties: {
|
||||||
cash: { type: 'number' },
|
cash: { type: 'number' },
|
||||||
reputation: { type: 'number' },
|
reputation: { type: 'number', description: 'Reputation — grows with delivered packages; drives the purchase/fuel discounts and job reward quality (v0.38.0, ISS-072).' },
|
||||||
},
|
},
|
||||||
required: ['cash', 'reputation'],
|
required: ['cash', 'reputation'],
|
||||||
},
|
},
|
||||||
@@ -430,7 +553,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 +564,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: {
|
||||||
@@ -463,7 +600,7 @@ export const openApiSpec = {
|
|||||||
mode: {
|
mode: {
|
||||||
enum: ['full', 'forFlight'],
|
enum: ['full', 'forFlight'],
|
||||||
description:
|
description:
|
||||||
'"full" (default) fills the tank to maxFuel; "forFlight" adds just enough fuel for one loaded package\'s destination leg.',
|
'"full" (default) fills the tank to maxFuel; "forFlight" buys 150% of the weather-adjusted fuel required for one loaded package\'s destination leg, capped at tank capacity (v0.36.0).',
|
||||||
},
|
},
|
||||||
packageId: {
|
packageId: {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
@@ -483,7 +620,7 @@ export const openApiSpec = {
|
|||||||
properties: {
|
properties: {
|
||||||
id: { type: 'string' },
|
id: { type: 'string' },
|
||||||
model: { type: 'string' },
|
model: { type: 'string' },
|
||||||
status: { enum: ['available', 'flying'] },
|
status: { enum: ['available', 'flying', 'repairing'] }, // v0.39.0 (ISS-069) — "repairing" = grounded in the shop until repairUntilSimTime passes
|
||||||
fuel: { type: 'number' },
|
fuel: { type: 'number' },
|
||||||
maxFuel: { type: 'number' },
|
maxFuel: { type: 'number' },
|
||||||
locationIcao: { type: 'string' },
|
locationIcao: { type: 'string' },
|
||||||
@@ -502,6 +639,22 @@ 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.',
|
||||||
|
},
|
||||||
|
health: {
|
||||||
|
type: 'number',
|
||||||
|
description:
|
||||||
|
'Structural health in [0, 100] (v0.39.0) — wears down by a fixed amount per km of landed legs; at 0 the plane is broken and burns 50% more fuel until repaired.',
|
||||||
|
},
|
||||||
|
repairUntilSimTime: {
|
||||||
|
type: ['integer', 'null'],
|
||||||
|
description:
|
||||||
|
'Sim-clock epoch ms when an active repair finishes (v0.39.0) — set with status "repairing", cleared by the engine\'s flip-back; null when not in repair.',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
required: [
|
required: [
|
||||||
'id',
|
'id',
|
||||||
@@ -516,6 +669,9 @@ export const openApiSpec = {
|
|||||||
'departureSimTime',
|
'departureSimTime',
|
||||||
'lat',
|
'lat',
|
||||||
'lon',
|
'lon',
|
||||||
|
'queue',
|
||||||
|
'health', // v0.39.0 (ISS-069) — structural health in [0, 100]
|
||||||
|
'repairUntilSimTime', // v0.39.0 (ISS-069) — active repair gate; null when not repairing
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
PackageState: {
|
PackageState: {
|
||||||
@@ -529,10 +685,13 @@ 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.' },
|
||||||
|
special: { type: ['string', 'null'], description: 'Special status from the specials registry (v0.37.0); null = none.' },
|
||||||
deadlineSimTime: { type: 'integer', description: 'Sim-clock epoch ms (game time)' },
|
deadlineSimTime: { type: 'integer', description: 'Sim-clock epoch ms (game time)' },
|
||||||
// 'expired' = an open OR loaded package whose deadline passed on a tick;
|
// 'expired' = an open OR loaded package whose deadline passed on a tick;
|
||||||
// loaded expired packages keep their slot, deliver no reward, abandon free.
|
// loaded expired packages keep their slot, deliver no reward, abandon free.
|
||||||
status: { enum: ['open', 'loaded', 'delivered', 'expired', 'abandoned'] },
|
// 'ignored' (v0.29.0, ISS-047 part B) = a dismissed open job — terminal, leaves the market.
|
||||||
|
status: { enum: ['open', 'loaded', 'delivered', 'expired', 'abandoned', 'ignored'] },
|
||||||
planeId: { type: ['string', 'null'] },
|
planeId: { type: ['string', 'null'] },
|
||||||
createdSimTime: { type: 'integer' },
|
createdSimTime: { type: 'integer' },
|
||||||
},
|
},
|
||||||
@@ -545,6 +704,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',
|
||||||
|
|||||||
+255
-27
@@ -6,14 +6,17 @@ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
|||||||
import { openGameDb } from '@/lib/db/client';
|
import { openGameDb } from '@/lib/db/client';
|
||||||
import * as schema from '@/lib/db/schema';
|
import * as schema from '@/lib/db/schema';
|
||||||
import { loadAirportsGeoJson } from '@/lib/airports';
|
import { loadAirportsGeoJson } from '@/lib/airports';
|
||||||
import { haversineKm } from '@/lib/geo';
|
import { haversineKm, initialBearingDeg } from '@/lib/geo';
|
||||||
import { PLANE_TYPES } from '@/lib/game/catalog';
|
import { weatherFor } from '@/lib/weather';
|
||||||
|
import { BROKEN_BURN_MULTIPLIER, PLANE_TYPES, WEAR_PER_KM } from '@/lib/game/catalog';
|
||||||
|
import { SPECIALS } from '@/lib/game/specials';
|
||||||
import { MARKET_TARGET_OPEN_PACKAGES, mulberry32 } from '@/lib/game/seed';
|
import { MARKET_TARGET_OPEN_PACKAGES, mulberry32 } from '@/lib/game/seed';
|
||||||
import { arrivalTimeFor, fuelAtWeighted } from './movement';
|
import { arrivalTimeFor, fuelAtWeighted, requiredFuelFor, windBurnFactor } from './movement';
|
||||||
import {
|
import {
|
||||||
clampTimeScale,
|
clampTimeScale,
|
||||||
deliverPackages,
|
deliverPackages,
|
||||||
expirePackages,
|
expirePackages,
|
||||||
|
finishRepairs,
|
||||||
MAX_TICK_ELAPSED_MS,
|
MAX_TICK_ELAPSED_MS,
|
||||||
MAX_TIME_SCALE,
|
MAX_TIME_SCALE,
|
||||||
MIN_TIME_SCALE,
|
MIN_TIME_SCALE,
|
||||||
@@ -105,6 +108,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 +120,7 @@ describe('expirePackages', () => {
|
|||||||
expect(byId.get('p-loaded-past')).toBe('expired'); // v0.12.0: loaded packages expire too (keep their slot)
|
expect(byId.get('p-loaded-past')).toBe('expired'); // v0.12.0: loaded packages expire too (keep their slot)
|
||||||
expect(byId.get('p-delivered-past')).toBe('delivered'); // terminal — never flips
|
expect(byId.get('p-delivered-past')).toBe('delivered'); // terminal — never flips
|
||||||
expect(byId.get('p-abandoned-past')).toBe('abandoned'); // terminal — never flips
|
expect(byId.get('p-abandoned-past')).toBe('abandoned'); // terminal — never flips
|
||||||
|
expect(byId.get('p-ignored-past')).toBe('ignored'); // v0.29.0 (ISS-047 part B) — terminal, never flips
|
||||||
});
|
});
|
||||||
|
|
||||||
it('a deadline exactly at the clock counts as expired', async () => {
|
it('a deadline exactly at the clock counts as expired', async () => {
|
||||||
@@ -163,7 +168,7 @@ describe('deliverPackages (v0.12.0 arrival delivery)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('delivers in-deadline cargo, unbinds expired cargo for free, keeps other-dest cargo bound', async () => {
|
it('delivers in-deadline cargo, unbinds expired cargo for free, keeps other-dest cargo bound', async () => {
|
||||||
const { inst, to, km, type } = await setupFlight('deliver-mixed.db');
|
const { inst, from, to, km, type } = await setupFlight('deliver-mixed.db');
|
||||||
const accountBefore = (await inst.db.select().from(schema.account))[0]!;
|
const accountBefore = (await inst.db.select().from(schema.account))[0]!;
|
||||||
|
|
||||||
// Three packages on the plane: one in-deadline for TO, one expired for TO,
|
// Three packages on the plane: one in-deadline for TO, one expired for TO,
|
||||||
@@ -182,8 +187,11 @@ describe('deliverPackages (v0.12.0 arrival delivery)', () => {
|
|||||||
expect(plane.locationIcao).toBe(TO);
|
expect(plane.locationIcao).toBe(TO);
|
||||||
expect(plane.lat).toBe(to.lat);
|
expect(plane.lat).toBe(to.lat);
|
||||||
expect(plane.lon).toBe(to.lon);
|
expect(plane.lon).toBe(to.lon);
|
||||||
// Remaining fuel per the v0.16.0 weight-proportional burn model (closed form).
|
// Remaining fuel per the v0.16.0 weight-proportional burn model (closed form), wind-adjusted
|
||||||
expect(plane.fuel).toBeCloseTo(fuelAtWeighted(type.maxFuel, type.burnPerKm, type.emptyWeightKg, type.maxFuel, km, 1), 6);
|
// (v0.36.0) by the departure airport's weather vs the route heading.
|
||||||
|
const w = weatherFor(FROM);
|
||||||
|
const f = windBurnFactor(w.windKt, w.windDirDeg, initialBearingDeg(from.lat, from.lon, to.lat, to.lon));
|
||||||
|
expect(plane.fuel).toBeCloseTo(fuelAtWeighted(type.maxFuel, type.burnPerKm * f, type.emptyWeightKg, type.maxFuel, km, 1), 6);
|
||||||
expect(plane.departureSimTime).toBeNull();
|
expect(plane.departureSimTime).toBeNull();
|
||||||
expect(plane.destIcao).toBeNull(); // flight context cleared on landing
|
expect(plane.destIcao).toBeNull(); // flight context cleared on landing
|
||||||
|
|
||||||
@@ -228,6 +236,26 @@ describe('deliverPackages (v0.12.0 arrival delivery)', () => {
|
|||||||
); // rep per delivered package
|
); // rep per delivered package
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('adds the registry rep bonus for a special package at delivery (v0.37.0)', async () => {
|
||||||
|
const { inst, km } = await setupFlight('deliver-special.db');
|
||||||
|
const accountBefore = (await inst.db.select().from(schema.account))[0]!;
|
||||||
|
|
||||||
|
await inst.db.insert(schema.packages).values([
|
||||||
|
{ id: 'p-prio', originIcao: FROM, destIcao: TO, reward: 5_000, cargoType: 'Mail', cargoCount: 10, special: 'priority', deadlineSimTime: T0 + 24 * 3_600_000, status: 'loaded', planeId: 'plane-starter', createdSimTime: T0 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const clock = T0 + travelMs(km, PLANE_TYPES.atr72.speedKmh); // exactly at arrival
|
||||||
|
expect(await deliverPackages(inst, clock)).toBe(1);
|
||||||
|
|
||||||
|
const account = (await inst.db.select().from(schema.account))[0]!;
|
||||||
|
expect(account.cash).toBeCloseTo(accountBefore.cash + 5_000, 9);
|
||||||
|
// Distance-based gain + the registry rep bonus — double-rounded like every other award.
|
||||||
|
expect(account.reputation).toBeCloseTo(
|
||||||
|
Math.round((accountBefore.reputation + reputationGainFor(km) + SPECIALS.priority.repBonus) * 100) / 100,
|
||||||
|
9,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('leaves planes without route info alone', async () => {
|
it('leaves planes without route info alone', async () => {
|
||||||
const inst = await freshDb('deliver-noroute.db');
|
const inst = await freshDb('deliver-noroute.db');
|
||||||
// Flying plane whose dest_icao is missing — defensive skip, no crash.
|
// Flying plane whose dest_icao is missing — defensive skip, no crash.
|
||||||
@@ -235,14 +263,190 @@ 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, from, 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 — both legs
|
||||||
|
// wind-adjusted (v0.36.0) exactly as deliverPackages computes them.
|
||||||
|
const wFrom = weatherFor(FROM);
|
||||||
|
const fOut = windBurnFactor(wFrom.windKt, wFrom.windDirDeg, initialBearingDeg(from.lat, from.lon, to.lat, to.lon));
|
||||||
|
const landedFuel = fuelAtWeighted(type.maxFuel, type.burnPerKm * fOut, type.emptyWeightKg, type.maxFuel, km, 1);
|
||||||
|
const wTo = weatherFor(TO); // the chained leg departs from the arrival airport
|
||||||
|
const fNext = windBurnFactor(wTo.windKt, wTo.windDirDeg, initialBearingDeg(to.lat, to.lon, nextP.lat, nextP.lon));
|
||||||
|
expect(requiredFuelFor(type.burnPerKm * fNext, type.emptyWeightKg, type.maxFuel, nextKm)).toBeLessThanOrEqual(landedFuel);
|
||||||
|
|
||||||
|
await inst.db.update(schema.planes)
|
||||||
|
.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, from, to, km, type } = await setupFlight('chain-nofuel.db');
|
||||||
|
|
||||||
|
// Depart with exactly the wind-adjusted fuel for EDDF→LEMD (v0.36.0) → lands empty, cannot start LEMD→EHAM.
|
||||||
|
const w = weatherFor(FROM);
|
||||||
|
const f = windBurnFactor(w.windKt, w.windDirDeg, initialBearingDeg(from.lat, from.lon, to.lat, to.lon));
|
||||||
|
await inst.db.update(schema.planes)
|
||||||
|
.set({
|
||||||
|
fuel: requiredFuelFor(type.burnPerKm * f, 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('wear on landed legs + broken burn (v0.39.0, ISS-069)', () => {
|
||||||
|
const FROM = 'EDDF';
|
||||||
|
const TO = 'LEMD';
|
||||||
|
|
||||||
|
// Starter flying EDDF→LEMD with a full tank — same shape as the deliverPackages setup above.
|
||||||
|
async function setupFlight(name: string) {
|
||||||
|
const inst = await freshDb(name);
|
||||||
|
const fc = await loadAirportsGeoJson();
|
||||||
|
const from = fc.features.find((f) => f.properties.icao === FROM)!.properties;
|
||||||
|
const to = fc.features.find((f) => f.properties.icao === TO)!.properties;
|
||||||
|
const km = haversineKm(from.lat, from.lon, to.lat, to.lon);
|
||||||
|
const type = PLANE_TYPES.atr72;
|
||||||
|
|
||||||
|
await inst.db.update(schema.planes)
|
||||||
|
.set({ status: 'flying', destIcao: TO, departureSimTime: T0, fuel: type.maxFuel })
|
||||||
|
.where(eq(schema.planes.id, 'plane-starter'));
|
||||||
|
|
||||||
|
return { inst, from, to, km, type };
|
||||||
|
}
|
||||||
|
|
||||||
|
it('drops health by exactly WEAR_PER_KM × km on a landed leg', async () => {
|
||||||
|
const { inst, km } = await setupFlight('wear-leg.db');
|
||||||
|
|
||||||
|
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.health).toBeCloseTo(100 - WEAR_PER_KM * km, 9); // one mid-range hop ≈ −9.8 health
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clamps at 0 across multiple legs', async () => {
|
||||||
|
const { inst, from, to, km, type } = await setupFlight('wear-clamp.db');
|
||||||
|
// Start below one leg's wear so the first landing clamps…
|
||||||
|
await inst.db.update(schema.planes)
|
||||||
|
.set({ health: WEAR_PER_KM * km / 2 })
|
||||||
|
.where(eq(schema.planes.id, 'plane-starter'));
|
||||||
|
|
||||||
|
expect(await deliverPackages(inst, T0 + travelMs(km, type.speedKmh))).toBe(1);
|
||||||
|
let plane = (await inst.db.select().from(schema.planes))[0];
|
||||||
|
expect(plane.health).toBe(0); // clamped — never negative…
|
||||||
|
|
||||||
|
// …and a second leg keeps it pinned at 0.
|
||||||
|
const eham = (await loadAirportsGeoJson()).features.find((f) => f.properties.icao === 'EHAM')!.properties;
|
||||||
|
const nextKm = haversineKm(to.lat, to.lon, eham.lat, eham.lon);
|
||||||
|
await inst.db.update(schema.planes)
|
||||||
|
.set({ status: 'flying', destIcao: 'EHAM', departureSimTime: T0 + travelMs(km, type.speedKmh), fuel: type.maxFuel })
|
||||||
|
.where(eq(schema.planes.id, 'plane-starter'));
|
||||||
|
|
||||||
|
expect(await deliverPackages(inst, T0 + travelMs(km, type.speedKmh) + travelMs(nextKm, type.speedKmh))).toBe(1);
|
||||||
|
plane = (await inst.db.select().from(schema.planes))[0];
|
||||||
|
expect(plane.health).toBe(0); // still clamped after the second leg
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a broken plane burns ×1.5 more fuel on the leg (landed fuel)', async () => {
|
||||||
|
const { inst, from, to, km, type } = await setupFlight('wear-broken.db');
|
||||||
|
await inst.db.update(schema.planes).set({ health: 0 }).where(eq(schema.planes.id, 'plane-starter'));
|
||||||
|
|
||||||
|
expect(await deliverPackages(inst, T0 + travelMs(km, type.speedKmh))).toBe(1);
|
||||||
|
|
||||||
|
const w = weatherFor(FROM);
|
||||||
|
const f = windBurnFactor(w.windKt, w.windDirDeg, initialBearingDeg(from.lat, from.lon, to.lat, to.lon));
|
||||||
|
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||||
|
// The ×1.5-adjusted closed form — the same factor livePlane() shows in flight…
|
||||||
|
expect(plane.fuel).toBeCloseTo(
|
||||||
|
fuelAtWeighted(type.maxFuel, type.burnPerKm * f * BROKEN_BURN_MULTIPLIER, type.emptyWeightKg, type.maxFuel, km, 1),
|
||||||
|
6,
|
||||||
|
);
|
||||||
|
|
||||||
|
// …and strictly less than the healthy control on the identical leg.
|
||||||
|
const control = await setupFlight('wear-broken-control.db');
|
||||||
|
expect(await deliverPackages(control.inst, T0 + travelMs(km, type.speedKmh))).toBe(1);
|
||||||
|
const healthy = (await control.inst.db.select().from(schema.planes))[0];
|
||||||
|
expect(healthy.fuel).toBeCloseTo(fuelAtWeighted(type.maxFuel, type.burnPerKm * f, type.emptyWeightKg, type.maxFuel, km, 1), 6);
|
||||||
|
expect(plane.fuel).toBeLessThan(healthy.fuel); // the broken plane genuinely burned more
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('finishRepairs (v0.39.0, ISS-069)', () => {
|
||||||
|
it('flips repairing planes back to available once the gate passes, clearing the gate', async () => {
|
||||||
|
const inst = await freshDb('repair-finish.db');
|
||||||
|
await inst.db.update(schema.planes)
|
||||||
|
.set({ status: 'repairing', repairUntilSimTime: T0 + 5_000 })
|
||||||
|
.where(eq(schema.planes.id, 'plane-starter'));
|
||||||
|
|
||||||
|
expect(await finishRepairs(inst, T0 + 4_999)).toBe(0); // gate not reached yet…
|
||||||
|
let plane = (await inst.db.select().from(schema.planes))[0];
|
||||||
|
expect(plane.status).toBe('repairing');
|
||||||
|
|
||||||
|
expect(await finishRepairs(inst, T0 + 5_000)).toBe(1); // <= is inclusive at the clock
|
||||||
|
plane = (await inst.db.select().from(schema.planes))[0];
|
||||||
|
expect(plane.status).toBe('available');
|
||||||
|
expect(plane.repairUntilSimTime ?? null).toBeNull(); // gate cleared
|
||||||
|
});
|
||||||
|
|
||||||
|
it('runs inside tickOnce even when nothing landed: a past gate flips the plane back', async () => {
|
||||||
|
const inst = await freshDb('repair-tick.db');
|
||||||
|
await inst.db.update(schema.planes)
|
||||||
|
.set({ status: 'repairing', repairUntilSimTime: T0 + 1_500 })
|
||||||
|
.where(eq(schema.planes.id, 'plane-starter'));
|
||||||
|
|
||||||
|
vi.setSystemTime(T0 + 2_000); // one clamped tick at scale 1 → clock T0+2000 ≥ gate; nothing is flying
|
||||||
|
await tickOnce(inst);
|
||||||
|
|
||||||
|
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||||
|
expect(plane.status).toBe('available');
|
||||||
|
expect(plane.repairUntilSimTime ?? null).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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 +455,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 +463,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 +487,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');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -336,9 +563,10 @@ describe('spawnOpenPackages', () => {
|
|||||||
expect(icaoSet.has(p.destIcao)).toBe(true);
|
expect(icaoSet.has(p.destIcao)).toBe(true);
|
||||||
expect(p.originIcao).not.toBe(p.destIcao);
|
expect(p.originIcao).not.toBe(p.destIcao);
|
||||||
expect(p.planeId).toBeNull();
|
expect(p.planeId).toBeNull();
|
||||||
// Same deadline window as the seed: now + 6–72h game time.
|
// Same deadline window as the seed: now + 6–72h game time — scaled by any special status (v0.37.0).
|
||||||
expect(p.deadlineSimTime).toBeGreaterThan(clock + 6 * 3_600_000 - 1);
|
const scale = p.special ? SPECIALS[p.special]?.deadlineScale ?? 1 : 1;
|
||||||
expect(p.deadlineSimTime).toBeLessThanOrEqual(clock + 72 * 3_600_000);
|
expect(p.deadlineSimTime).toBeGreaterThan(clock + 6 * 3_600_000 * scale - 1);
|
||||||
|
expect(p.deadlineSimTime).toBeLessThanOrEqual(clock + 72 * 3_600_000 * scale);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Already at target → nothing more to spawn.
|
// Already at target → nothing more to spawn.
|
||||||
|
|||||||
+146
-37
@@ -3,8 +3,11 @@ import { eq, inArray } from 'drizzle-orm';
|
|||||||
import { getGameDb, persist, type GameDb } from '@/lib/db/client';
|
import { getGameDb, persist, type GameDb } from '@/lib/db/client';
|
||||||
import * as schema from '@/lib/db/schema';
|
import * as schema from '@/lib/db/schema';
|
||||||
import { loadAirportsGeoJson } from '@/lib/airports';
|
import { loadAirportsGeoJson } from '@/lib/airports';
|
||||||
import { haversineKm } from '@/lib/geo';
|
import { haversineKm, initialBearingDeg } from '@/lib/geo';
|
||||||
import { PLANE_TYPES } from '@/lib/game/catalog';
|
import { weatherFor } from '@/lib/weather';
|
||||||
|
import { conditionBurnMultiplier, PLANE_TYPES, WEAR_PER_KM } from '@/lib/game/catalog';
|
||||||
|
import { SPECIALS } from '@/lib/game/specials';
|
||||||
|
import { parseIcaoQueue } from '@/lib/game/state';
|
||||||
import {
|
import {
|
||||||
generatePackage,
|
generatePackage,
|
||||||
MARKET_TARGET_OPEN_PACKAGES,
|
MARKET_TARGET_OPEN_PACKAGES,
|
||||||
@@ -12,7 +15,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, windBurnFactor } from './movement';
|
||||||
|
|
||||||
// Server-authoritative sim engine (v0.7.0) — tick-on-read: there is NO background
|
// Server-authoritative sim engine (v0.7.0) — tick-on-read: there is NO background
|
||||||
// timer. Every read/action endpoint calls tickNow() first; the clock advances by
|
// timer. Every read/action endpoint calls tickNow() first; the clock advances by
|
||||||
@@ -74,9 +77,11 @@ 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);
|
||||||
|
// v0.39.0 (ISS-069): repairs whose gate has passed flip back to 'available' — runs even when
|
||||||
|
// nothing landed this tick; BEFORE the spawns so a just-repaired plane gets coverage work too.
|
||||||
|
await finishRepairs(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
|
||||||
// at its new location within the same tick.
|
// at its new location within the same tick.
|
||||||
@@ -87,13 +92,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.
|
||||||
@@ -120,13 +129,35 @@ export async function expirePackages(inst: GameDb, clockMs: number): Promise<num
|
|||||||
return n;
|
return n;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Repairs whose gate has passed flip the plane back to 'available' and clear the gate
|
||||||
|
// (v0.39.0, ISS-069) — same shape as expirePackages: one guarded pass over the rows,
|
||||||
|
// `<=` inclusive at the clock. Returns the count flipped.
|
||||||
|
export async function finishRepairs(inst: GameDb, clockMs: number): Promise<number> {
|
||||||
|
const rows = await inst.db.select().from(schema.planes).where(eq(schema.planes.status, 'repairing'));
|
||||||
|
let n = 0;
|
||||||
|
for (const p of rows) {
|
||||||
|
if (p.repairUntilSimTime !== null && p.repairUntilSimTime <= clockMs) {
|
||||||
|
await inst.db.update(schema.planes)
|
||||||
|
.set({ status: 'available', repairUntilSimTime: null })
|
||||||
|
.where(eq(schema.planes.id, p.id));
|
||||||
|
n++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
// 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),
|
||||||
@@ -147,20 +178,25 @@ export async function deliverPackages(inst: GameDb, clockMs: number): Promise<nu
|
|||||||
const distanceKm = haversineKm(from.lat, from.lon, to.lat, to.lon);
|
const distanceKm = haversineKm(from.lat, from.lon, to.lat, to.lon);
|
||||||
if (clockMs < arrivalTimeFor(p.departureSimTime, distanceKm, type.speedKmh)) continue;
|
if (clockMs < arrivalTimeFor(p.departureSimTime, distanceKm, type.speedKmh)) continue;
|
||||||
|
|
||||||
// Arrived: progress is exactly 1 here, so remaining fuel is the full-leg weighted burn.
|
// Arrived: progress is exactly 1 here, so remaining fuel is the full-leg weighted burn —
|
||||||
await inst.db.update(schema.planes)
|
// wind-adjusted (v0.36.0) by departure weather vs route heading, same factor flyPlane used.
|
||||||
.set({
|
// v0.39.0 (ISS-069): broken planes (health ≤ 0 at DEPARTURE) burn BROKEN_BURN_MULTIPLIER × more —
|
||||||
status: 'available',
|
// p.health is the pre-landing value, so this stays bit-consistent with livePlane()'s gauge.
|
||||||
locationIcao: to.icao,
|
const w = weatherFor(p.locationIcao);
|
||||||
lat: to.lat,
|
const landedFuel = fuelAtWeighted(
|
||||||
lon: to.lon,
|
p.fuel,
|
||||||
fuel: fuelAtWeighted(p.fuel, type.burnPerKm, type.emptyWeightKg, type.maxFuel, distanceKm, 1),
|
type.burnPerKm * windBurnFactor(w.windKt, w.windDirDeg, initialBearingDeg(from.lat, from.lon, to.lat, to.lon)) * conditionBurnMultiplier(p.health),
|
||||||
departureSimTime: null,
|
type.emptyWeightKg,
|
||||||
destIcao: null,
|
type.maxFuel,
|
||||||
})
|
distanceKm,
|
||||||
.where(eq(schema.planes.id, p.id));
|
1,
|
||||||
|
);
|
||||||
|
|
||||||
// Deliver every bound package whose destination is the airport just reached.
|
// Wear (v0.39.0, ISS-069): health drops by WEAR_PER_KM per km of this landed leg, clamped at 0.
|
||||||
|
const newHealth = Math.max(0, p.health - WEAR_PER_KM * distanceKm);
|
||||||
|
|
||||||
|
// 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) {
|
||||||
@@ -168,15 +204,73 @@ export async function deliverPackages(inst: GameDb, clockMs: number): Promise<nu
|
|||||||
// Fresh account read per award so multiple deliveries in one tick all count.
|
// Fresh account read per award so multiple deliveries in one tick all count.
|
||||||
const fresh = (await inst.db.select().from(schema.account))[0];
|
const fresh = (await inst.db.select().from(schema.account))[0];
|
||||||
if (!fresh) throw new Error('account row missing');
|
if (!fresh) throw new Error('account row missing');
|
||||||
|
// v0.37.0 (ISS-073): special packages add the registry's flat rep bonus on top of the
|
||||||
|
// distance-based gain — same double-rounding convention as before (gain rounded to cents, total re-rounded).
|
||||||
|
const repBonus = pk.special ? SPECIALS[pk.special]?.repBonus ?? 0 : 0;
|
||||||
await inst.db.update(schema.account).set({
|
await inst.db.update(schema.account).set({
|
||||||
cash: fresh.cash + pk.reward,
|
cash: fresh.cash + pk.reward,
|
||||||
reputation: Math.round((fresh.reputation + reputationGainFor(distanceKm)) * 100) / 100,
|
reputation: Math.round((fresh.reputation + reputationGainFor(distanceKm) + repBonus) * 100) / 100,
|
||||||
});
|
});
|
||||||
} else if (pk.status === 'expired') {
|
} else if (pk.status === 'expired') {
|
||||||
// Expired cargo keeps no value — just free the slot.
|
// Expired cargo keeps no value — just free the slot.
|
||||||
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) {
|
||||||
|
// Wind-adjusted requirement for the NEXT leg — it departs from `to` (v0.36.0). v0.39.0 (ISS-069):
|
||||||
|
// the chained leg starts AFTER this landing's wear, so its burn uses newHealth — exactly what
|
||||||
|
// livePlane()/the next landing will compute for that flight.
|
||||||
|
const wNext = weatherFor(to.icao);
|
||||||
|
const nextRequiredL = requiredFuelFor(
|
||||||
|
type.burnPerKm * windBurnFactor(wNext.windKt, wNext.windDirDeg, initialBearingDeg(to.lat, to.lon, next.lat, next.lon)) * conditionBurnMultiplier(newHealth),
|
||||||
|
type.emptyWeightKg,
|
||||||
|
type.maxFuel,
|
||||||
|
haversineKm(to.lat, to.lon, next.lat, next.lon),
|
||||||
|
);
|
||||||
|
if (nextRequiredL <= landedFuel) {
|
||||||
|
queue.shift();
|
||||||
|
chainedTo = nextDest;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await inst.db.update(schema.planes)
|
||||||
|
.set(
|
||||||
|
chainedTo
|
||||||
|
? {
|
||||||
|
status: 'flying',
|
||||||
|
locationIcao: to.icao,
|
||||||
|
lat: to.lat,
|
||||||
|
lon: to.lon,
|
||||||
|
fuel: landedFuel,
|
||||||
|
health: newHealth, // v0.39.0 (ISS-069) — this leg's wear persists into the chained flight
|
||||||
|
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,
|
||||||
|
health: newHealth, // v0.39.0 (ISS-069) — this leg's wear persists on the grounded plane
|
||||||
|
departureSimTime: null,
|
||||||
|
destIcao: null,
|
||||||
|
queue: JSON.stringify(queue),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.where(eq(schema.planes.id, p.id));
|
||||||
n++;
|
n++;
|
||||||
}
|
}
|
||||||
return n;
|
return n;
|
||||||
@@ -204,9 +298,14 @@ export async function spawnOpenPackages(
|
|||||||
lon: f.properties.lon,
|
lon: f.properties.lon,
|
||||||
}));
|
}));
|
||||||
const rand = rng ?? mulberry32(randomInt(0, 2 ** 31));
|
const rand = rng ?? mulberry32(randomInt(0, 2 ** 31));
|
||||||
|
// v0.38.0 (ISS-072): spawned jobs carry the account's CURRENT reputation quality — read once per spawn batch.
|
||||||
|
const account = (await inst.db.select().from(schema.account))[0];
|
||||||
|
if (!account) throw new Error('account row missing');
|
||||||
|
|
||||||
for (let i = 0; i < needed; i++) {
|
for (let i = 0; i < needed; i++) {
|
||||||
await inst.db.insert(schema.packages).values(generatePackage({ id: randomUUID(), airports, rng: rand, nowMs: clockMs }));
|
await inst.db.insert(schema.packages).values(
|
||||||
|
generatePackage({ id: randomUUID(), airports, rng: rand, nowMs: clockMs, reputation: account.reputation }),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return needed;
|
return needed;
|
||||||
}
|
}
|
||||||
@@ -248,12 +347,15 @@ export async function spawnCoveragePackages(
|
|||||||
lon: f.properties.lon,
|
lon: f.properties.lon,
|
||||||
}));
|
}));
|
||||||
const rand = rng ?? mulberry32(randomInt(0, 2 ** 31));
|
const rand = rng ?? mulberry32(randomInt(0, 2 ** 31));
|
||||||
|
// v0.38.0 (ISS-072): same reputation quality as spawnOpenPackages — read once per batch.
|
||||||
|
const account = (await inst.db.select().from(schema.account))[0];
|
||||||
|
if (!account) throw new Error('account row missing');
|
||||||
|
|
||||||
let n = 0;
|
let n = 0;
|
||||||
for (const icao of missing) {
|
for (const icao of missing) {
|
||||||
if (!airports.some((a) => a.icao === icao)) continue; // defensive — the dataset is the source of all ICACs
|
if (!airports.some((a) => a.icao === icao)) continue; // defensive — the dataset is the source of all ICACs
|
||||||
await inst.db.insert(schema.packages).values(
|
await inst.db.insert(schema.packages).values(
|
||||||
generatePackage({ id: randomUUID(), airports, rng: rand, nowMs: clockMs, originIcao: icao }),
|
generatePackage({ id: randomUUID(), airports, rng: rand, nowMs: clockMs, originIcao: icao, reputation: account.reputation }),
|
||||||
);
|
);
|
||||||
n++;
|
n++;
|
||||||
}
|
}
|
||||||
@@ -281,13 +383,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);
|
||||||
|
|||||||
@@ -5,10 +5,12 @@ import {
|
|||||||
burnRateConstant,
|
burnRateConstant,
|
||||||
FUEL_DENSITY_KG_PER_L,
|
FUEL_DENSITY_KG_PER_L,
|
||||||
fuelAtWeighted,
|
fuelAtWeighted,
|
||||||
|
maxRangeKm,
|
||||||
positionAt,
|
positionAt,
|
||||||
progressAt,
|
progressAt,
|
||||||
requiredFuelFor,
|
requiredFuelFor,
|
||||||
travelDurationMs,
|
travelDurationMs,
|
||||||
|
windBurnFactor,
|
||||||
} from './movement';
|
} from './movement';
|
||||||
|
|
||||||
// Synthetic in-flight fixtures — no DB needed: everything is a pure function of
|
// Synthetic in-flight fixtures — no DB needed: everything is a pure function of
|
||||||
@@ -164,3 +166,71 @@ describe('weighted burn model (v0.16.0)', () => {
|
|||||||
expect(fuelAtWeighted(TANK, BURN, WE, TANK, KM, progressAt(DEP, KM, SPEED, clock))).toBeGreaterThan(0);
|
expect(fuelAtWeighted(TANK, BURN, WE, TANK, KM, progressAt(DEP, KM, SPEED, clock))).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('windBurnFactor (v0.36.0, ISS-070)', () => {
|
||||||
|
it('full headwind at max wind is exactly +20%', () => {
|
||||||
|
// bearing == windDirDeg: the wind blows straight from ahead.
|
||||||
|
expect(windBurnFactor(30, 90, 90)).toBe(1.2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('full tailwind at max wind is exactly −20%', () => {
|
||||||
|
// windDirDeg = bearing + 180 (mod 360): the wind blows from behind.
|
||||||
|
expect(windBurnFactor(30, 270, 90)).toBe(0.8);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a pure crosswind (90° off) leaves burn unchanged', () => {
|
||||||
|
expect(windBurnFactor(30, 0, 90)).toBeCloseTo(1, 9);
|
||||||
|
expect(windBurnFactor(30, 180, 90)).toBeCloseTo(1, 9);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('small wind stays near 1', () => {
|
||||||
|
const f = windBurnFactor(2, 45, 45); // worst case: full headwind at min wind
|
||||||
|
expect(f).toBeGreaterThan(1.01);
|
||||||
|
expect(f).toBeLessThan(1.02);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stays within [0.8, 1.2] for the whole weather range', () => {
|
||||||
|
for (const kt of [2, 15, 30]) {
|
||||||
|
for (const off of [0, 45, 90, 135, 180, 270, 315]) {
|
||||||
|
const f = windBurnFactor(kt, 100, (100 + off) % 360);
|
||||||
|
expect(f).toBeGreaterThanOrEqual(0.8 - 1e-12);
|
||||||
|
expect(f).toBeLessThanOrEqual(1.2 + 1e-12);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applied as burnPerKm × f, requiredFuelFor and fuelAtWeighted scale accordingly (pinned)', () => {
|
||||||
|
// Fixed inputs: ATR numbers, 1000 km leg / half of the FRA→MAD leg.
|
||||||
|
expect(requiredFuelFor(BURN * 1.2, WE, TANK, 1_000)).toBeCloseTo(1444.911589, 6);
|
||||||
|
expect(requiredFuelFor(BURN * 0.8, WE, TANK, 1_000)).toBeCloseTo(944.348095, 6);
|
||||||
|
expect(fuelAtWeighted(TANK, BURN * 1.2, WE, TANK, KM, 0.5)).toBeCloseTo(4867.313136, 6);
|
||||||
|
expect(fuelAtWeighted(TANK, BURN * 0.8, WE, TANK, KM, 0.5)).toBeCloseTo(5331.677856, 6);
|
||||||
|
|
||||||
|
// Headwind always costs more than tailwind over the same leg…
|
||||||
|
expect(requiredFuelFor(BURN * 1.2, WE, TANK, KM)).toBeGreaterThan(requiredFuelFor(BURN * 0.8, WE, TANK, KM));
|
||||||
|
expect(fuelAtWeighted(TANK, BURN * 1.2, WE, TANK, KM, 1)).toBeLessThan(fuelAtWeighted(TANK, BURN * 0.8, WE, TANK, KM, 1));
|
||||||
|
// …and a neutral factor (f = 1) reproduces the unadjusted model exactly.
|
||||||
|
expect(requiredFuelFor(BURN * windBurnFactor(30, 0, 90), WE, TANK, KM)).toBeCloseTo(requiredFuelFor(BURN, WE, TANK, KM), 9);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('maxRangeKm (v0.28.0)', () => {
|
||||||
|
it('ATR 72-600 full tank ≈ 4439 km (±5%)', () => {
|
||||||
|
const r = maxRangeKm(BURN, WE, TANK);
|
||||||
|
expect(r).toBeGreaterThan(4439 * 0.95);
|
||||||
|
expect(r).toBeLessThan(4439 * 1.05);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('increases with tank size and decreases with burn rate', () => {
|
||||||
|
expect(maxRangeKm(BURN, WE, TANK * 2)).toBeGreaterThan(maxRangeKm(BURN, WE, TANK));
|
||||||
|
expect(maxRangeKm(3, WE, TANK)).toBeLessThan(maxRangeKm(BURN, WE, TANK));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('zero fuel → zero range', () => {
|
||||||
|
expect(maxRangeKm(BURN, WE, 0)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a non-positive empty weight', () => {
|
||||||
|
expect(() => maxRangeKm(BURN, 0, TANK)).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -118,3 +118,22 @@ export function requiredFuelFor(
|
|||||||
const rho = FUEL_DENSITY_KG_PER_L;
|
const rho = FUEL_DENSITY_KG_PER_L;
|
||||||
return (emptyWeightKg / rho) * (Math.exp(k * rho * distanceKm) - 1);
|
return (emptyWeightKg / rho) * (Math.exp(k * rho * distanceKm) - 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Wind adjustment to per-km burn (v0.36.0, ISS-070): the headwind component of a wind blowing
|
||||||
|
// FROM `windDirDeg` against a leg heading `bearingDeg`, as a multiplier on burnPerKm —
|
||||||
|
// factor = 1 + headwindKt/150, exactly [0.8, 1.2] over the weather range 2–30 kt (±20%).
|
||||||
|
// Pure; callers apply it as burnPerKm * factor so every call site agrees on one value per leg.
|
||||||
|
export function windBurnFactor(windKt: number, windDirDeg: number, bearingDeg: number): number {
|
||||||
|
const headwindKt = windKt * Math.cos(((bearingDeg - windDirDeg) * Math.PI) / 180);
|
||||||
|
return 1 + headwindKt / 150;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Max range from a FULL tank under the weighted model — solve requiredFuelFor(d) = maxFuel for d:
|
||||||
|
// (We/ρ)(e^{kρd} − 1) = F → d = ln(1 + ρF/We) · (We + ρF)/(ρ·burnPerKm).
|
||||||
|
// Returns raw km (call sites round); zero fuel gives exactly 0. Pure — display-only, the sim
|
||||||
|
// itself does not model range exhaustion (see fuelAtWeighted).
|
||||||
|
export function maxRangeKm(burnPerKm: number, emptyWeightKg: number, maxFuel: number): number {
|
||||||
|
if (!(emptyWeightKg > 0)) throw new Error('emptyWeightKg must be > 0');
|
||||||
|
const rho = FUEL_DENSITY_KG_PER_L;
|
||||||
|
return Math.log(1 + (rho * maxFuel) / emptyWeightKg) * ((emptyWeightKg + rho * maxFuel) / (rho * burnPerKm));
|
||||||
|
}
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "flight-ops",
|
"name": "flight-ops",
|
||||||
"version": "0.23.2",
|
"version": "0.40.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev -p 3000",
|
"dev": "next dev -p 3000",
|
||||||
|
|||||||
Reference in New Issue
Block a user