Compare commits

..
Author SHA1 Message Date
kamilkrzywda d53ad41c09 Merge pull request 'v0.54.0 — long priority jobs are now always finishable (#119)' (#125) from feat/expiry-floor into master
CI / build + unit + e2e (push) Failing after 8m47s
2026-09-22 17:31:51 +02:00
kamilkrzywda 82061ed869 Clamp generated package expiry to travel time at max catalog speed + buffer so long jobs are finishable (ISS-119)
CI / build + unit + e2e (pull_request) Successful in 9m43s
2026-09-22 15:21:50 +00:00
kamilkrzywda 2a4b341a9c Merge pull request 'v0.53.0 — antimeridian routes no longer chord across the map (#120)' (#123) from feat/antimeridian-route into master
CI / build + unit + e2e (push) Successful in 12m43s
2026-09-22 17:00:15 +02:00
kamilkrzywda cd2c766895 Unwrap great-circle arc longitudes across the antimeridian so routes don't chord across the map (ISS-120)
CI / build + unit + e2e (pull_request) Successful in 10m58s
2026-09-22 14:48:53 +00:00
kamilkrzywda 23896b2cc4 Merge pull request 'v0.52.0 — fixed world clock at 24x (1h = 1 day): no time skip, background tick, idle-pause (#117)' (#121) from feat/fixed-world-clock into master
CI / build + unit + e2e (push) Successful in 12m28s
v0.52.0 — fixed world clock at 24x (1h = 1 day): no time skip, background tick, idle-pause (#117)
2026-09-22 16:13:41 +02:00
kamilkrzywda 189a2c04eb Fix e2e specs for fixed world clock: idle-pause expiry window, scale-1 motion settle, self-heal (ISS-117)
CI / build + unit + e2e (pull_request) Successful in 12m44s
2026-09-22 14:00:53 +00:00
kamilkrzywda a0560ec767 Fixed world clock: no player time controls (ISS-117)
CI / build + unit + e2e (pull_request) Failing after 12m10s
The world always runs at WORLD_TIME_SCALE = 24 (lib/worldClock.ts). A server
background tick (instrumentation.ts -> startBackgroundTick, 1 s interval)
advances the sim with no open browser; idle-pause freezes the clock exactly
while no plane is flying or in repair. Fresh worlds start year 2000
(STARTING_CLOCK_MS in seed.ts).

- Removed: POST /api/sim + OpenAPI entry, applyTimeControls/clampTimeScale,
  lib/timeScale.ts, LeftPane pause button + scale slider machinery,
  ISS-057 slow-on-landing/resume in flyPlane.
- Snapshot simState is now { clockMs } only; client extrapolates at the fixed
  rate between polls and freezes while idle (useSimClock).
- New test-only hook POST /api/test/set-timescale behind FLIGHT_TEST_HOOKS=1
  (sibling of set-health); all e2e setScale helpers call it, afterEach restores
  the world rate. E2E pause tests replaced by idle-pause assertions; sim.spec.ts
  activates the world via set-health + /repair for advance/expiry tests.
2026-09-22 10:50:21 +00:00
kamilkrzywda d31bc5d60f Merge pull request 'v0.51.0 — spec card shows guaranteed (worst-case headwind) max range (#99)' (#104) from feat/fuel-range-consistency into master
CI / build + unit + e2e (push) Successful in 9m11s
2026-09-22 11:10:04 +02:00
kamilkrzywda 8319b2fa78 Max range display is the guaranteed (worst-case headwind) full-tank range (ISS-099, v0.51.0)
CI / build + unit + e2e (pull_request) Successful in 12m19s
2026-09-22 08:57:09 +00:00
kamilkrzywda f01c3f417c Merge pull request 'v0.50.0 — queued/planned route dotted line yellow-ish instead of gray (#98)' (#103) from feat/queued-route-color into master
CI / build + unit + e2e (push) Successful in 9m11s
2026-09-22 10:29:13 +02:00
kamilkrzywda c3339f0e40 Make queued/planned route dotted line amber/gold instead of gray (ISS-098)
CI / build + unit + e2e (pull_request) Successful in 10m51s
Extract the queue-line color from an inline literal to exported
QUEUE_ROUTE_LINE_COLOR and retune it #64748b -> #d4a017. Dashed style
and width unchanged. Unit test pins the constant; existing e2e specs
(queue.spec.ts, relocation.spec.ts) already cover the rendering path.
2026-09-22 08:02:10 +00:00
kamilkrzywda d215203988 Merge pull request 'v0.49.0 — airport popup Relocate works for flying planes via queue (#96)' (#102) from feat/relocate-queue into master
CI / build + unit + e2e (push) Successful in 9m11s
2026-09-22 09:48:50 +02:00
kamilkrzywda 11fc6f9d5c Airport popup Relocate works for flying/repairing planes via queue (ISS-096, v0.49.0)
CI / build + unit + e2e (pull_request) Successful in 12m56s
The airport popup's Relocate row now lists ALL planes instead of only
grounded ones; the click handler picks the endpoint by the selected
plane's current status — available → /fly (unchanged), flying/repairing
→ /queue so the destination is appended to the flight queue and chains
on landing. No-planes fallback reworded to 'No planes to relocate.'

E2E +1: relocation.spec.ts dispatches the starter on a leg via API,
slows the sim to 1x, opens the popup for a different airport, selects
the flying plane and clicks Relocate here — asserts the queue contains
the clicked ICAO while the plane still flies its original destination.
2026-09-22 07:32:31 +00:00
kamilkrzywda 8f32d5bf81 Merge pull request 'v0.48.0 — show leg distance (km) on job list and on-board contracts (#95)' (#100) from feat/leg-distance into master
CI / build + unit + e2e (push) Successful in 9m5s
2026-09-22 09:10:37 +02:00
kamilkrzywda a8233829c3 Show leg distance in km on job list and on-board contracts (ISS-095, v0.48.0)
CI / build + unit + e2e (pull_request) Successful in 10m21s
2026-09-22 06:58:05 +00:00
kamilkrzywda bf6f1b3c95 Merge pull request 'v0.47.0 — reward: weight dominates payoff (REWARD_PER_KG 0.5→2.0) (#90)' (#94) from feat/reward-weight into master
CI / build + unit + e2e (push) Successful in 9m0s
2026-09-22 01:36:12 +02:00
kamilkrzywda 9ce8660fd2 Job reward retuned so weight dominates payoff (ISS-090, v0.47.0)
CI / build + unit + e2e (pull_request) Successful in 8m59s
2026-09-21 23:24:49 +00:00
kamilkrzywda 3dbac9c549 Merge pull request 'v0.46.0 — queue: consume-on-dispatch, chain backstops, sell guard + tests (#88)' (#93) from feat/queue-tests-fixes into master
CI / build + unit + e2e (push) Successful in 11m40s
2026-09-22 01:22:26 +02:00
kamilkrzywda b76acbacac fix(e2e): bound post-departure fuel burn in refuel-cost spec (#88)
CI / build + unit + e2e (pull_request) Successful in 9m5s
2026-09-21 23:13:21 +00:00
kamilkrzywda 479b993c09 Queue consumed on dispatch; engine chain hardened; queued planes unsellable (ISS-088)
CI / build + unit + e2e (pull_request) Failing after 12m59s
2026-09-21 22:36:01 +00:00
kamilkrzywda 76f5b6f8ce Merge pull request 'Broken planes grounded + gradual repair (ISS-089, v0.45.0)' (#92) from feat/broken-cannot-fly into master
CI / build + unit + e2e (push) Successful in 7m54s
2026-09-21 23:31:08 +02:00
kamilkrzywda 1401a2f6eb Broken planes cannot fly; repair heals gradually (ISS-089)
CI / build + unit + e2e (pull_request) Successful in 8m22s
- flyPlane 409s a broken plane (health <= 0) before any refuel math — fuel/cash untouched
- engine auto-chain skips chaining when a landing's wear breaks the plane (queue kept intact)
- finishRepairs heals along round(f x 100) each tick; flip to available at exactly 100 on gate pass
- repairPlane no longer jumps health to 100 — it starts the gradual heal
- REMOVED BROKEN_BURN_MULTIPLIER / conditionBurnMultiplier and all burn sites (unreachable now)
- OpenAPI + route comments reworded; unit tests reworked; e2e maintenance.spec.ts per issue
- v0.45.0
2026-09-21 21:17:51 +00:00
kamilkrzywda 0de0aaffd9 Merge pull request 'World airports + flyable job range (ISS-079, v0.44.0)' (#91) from feat/world-airports into master
CI / build + unit + e2e (push) Successful in 8m41s
2026-09-21 21:48:50 +02:00
61 changed files with 1894 additions and 1755 deletions
+48
View File
@@ -1,5 +1,53 @@
# Changelog # Changelog
## v0.54.0 — 2026-09-22 15:20
- **Long priority jobs are now always finishable (ISS-119).** `generatePackage` in `lib/game/seed.ts` drew a flat 6–72 h expiry window (priority ×0.5 → 3–36 h) independent of leg distance, so some jobs' deadlines were shorter than the flight itself and could never be completed (legs go up to MAX_JOB_LEG_KM = 9000 km; the fastest catalog plane is ~920 km/h ≈ 10 h for a max leg). The drawn window is now clamped UP after special scaling: deadline ≥ now + `travelDurationMs(legKm, FASTEST_CATALOG_SPEED_KMH)` + `EXPIRY_BUFFER_MS` (flat 1 h), reusing the existing `travelDurationMs` from `lib/sim/movement.ts`. New constant `FASTEST_CATALOG_SPEED_KMH` in `lib/game/catalog.ts` — data-driven max of `PLANE_TYPES` speeds, so a job must be finishable with the best available aircraft (NOT the starter's 510 km/h, which would wreck long-job economics); adding a faster plane tightens the floor automatically. `EXPIRY_BUFFER_MS` lives next to the deadline tunables in seed.ts. The window is a single uniform draw, so clamping up covers both requirements: short legs (drawn × scale already above the floor) are byte-identical to before, and when the raw draw falls below the floor the deadline lands exactly on it — which stays ≤ the scaled ceiling for any pair of airports on Earth (max leg ~20000 km → floor ≈ 22.8 h < the 36 h priority ceiling), so no separate ceiling raise is needed. Forced `deadlineOffsetMs` skips the floor, same as it skips special scaling (the e2e expiry hook keeps working).
- **Tests.** Unit +5 in `lib/game/seed.test.ts` (new "expiry floor" block): pins the constants (fastest = max of catalog speeds and > starter speed; buffer exactly 1 h); a seeded sweep over 200 unforced packages on the real dataset asserting deadline ≥ now + travel time at max speed + buffer for every one (specials roll naturally, ~15% priority); the farthest within-cap pair in the real dataset (~9000 km) forced with both `priority` and null across 50 seeds each — invariant holds everywhere AND at least one package per special lands exactly on the floor (deterministic for fixed seeds, proving the clamp engages rather than holding vacuously); short legs (EDDF→EHAM ≈ 370 km → floor ≈ 1.4 h < the priority minimum window) pinned byte-identical to a replay of the pre-v0.54.0 draw order (no economy regression); forced deadline offsets still skip the floor. E2E +1 in `e2e/expiry.spec.ts`: the EXISTING `POST /api/test/create-package` hook forces EDDB→KSMF (~9000 km, at MAX_JOB_LEG_KM) with `special: 'priority'`; 10 packages are created and every one asserted to expire ≥ travel time at max speed + buffer after creation (and ≤ the 36 h priority ceiling), then all ignored for cleanup — no new server hooks.
## v0.53.0 — 2026-09-22 14:47
- **Antimeridian routes no longer chord across the map (ISS-120).** Routes whose great circle crosses ±180° (e.g. Auckland → Fua'amotu, Tonga) rendered as a line spanning the whole map: `greatCircleArc` sampled `positionAt`, which wraps every sample's longitude into [−180, 180], so consecutive samples jumped +179.x → −179.x and MapLibre — which draws LineStrings as-is with no ±180 splitting — connected them across the globe. New module-private `unwrapLons` in `lib/mapData.ts` re-expresses each arc's sample sequence in continuous longitudes (first sample keeps its raw lon; every later one is shifted by ±360 relative to the previous UNWRAPPED value until |Δlon| ≤ 180), applied inside `greatCircleArc` so all four line builders (`routesGeoJson`, `queuedRoutesGeoJson`, `hoverRouteGeoJson`, `hoverTraceGeoJson`) are fixed at once. One line stays one LineString — no splitting; out-of-range values render on MapLibre's adjacent world copies (`renderWorldCopies` is default-on). `queuedRoutesGeoJson` gets a second global pass after concatenation: each leg's arc unwraps relative to its OWN start airport, so at a joint the sequence can jump ±360 (leg 1 ends at dest+360k, leg 2 restarts at the raw dest) and the re-unwrap keeps multi-leg chains continuous. Non-crossing routes are byte-identical (no |Δlon| > 180 anywhere → the pass is a no-op).
- **Tests.** Unit +4 in `lib/mapData.test.ts` (new "antimeridian crossing" block, real dataset pairs NZAA/NFTF/NZCH): eastward and westward crossings stay continuous (every consecutive |Δlon| < 180), endpoints exact mod 360 with the first sample still the raw origin, sampled chord length ≈ haversine within 1%, a non-crossing route's lons stay in [−180, 180], and a grounded plane's two-leg queue chain crossing ±180 stays continuous across the joint (the regression `queuedRoutesGeoJson`'s global pass guards). E2E +1 in `e2e/hover.spec.ts`: hovering an antimeridian-crossing package (hook-created NZAA → NFTF) draws one continuous trace — no rendered segment may contain a ±180 longitude jump; `expectHoverRoute`'s endpoint check now compares longitudes on the circle (mod 360, identical for any pair within ±180 so existing routes are unaffected) and `fitToAirports` unwraps crossing pairs so their bounds follow the short arc across the date line instead of spanning the wrong side of the globe.
- **Known limitation (follow-up).** Plane MARKERS still use the snapshot's wrapped lat/lon (`lib/game/state.ts` → `positionAt`), so a plane mid-crossing teleports 360° on its marker at the instant it passes ±180 while its route line stays continuous. Fixing needs airport coordinates inside `planesGeoJson` (signature change + `Map.tsx` call site) — deliberately out of scope for this fix.
## v0.52.0 — 2026-09-22 10:45
- **Fixed world clock, no player time controls (ISS-117).** The pause button and the 1×–1000× scale slider are gone from the left pane (`components/LeftPane.tsx`) — the world now always runs at a fixed `WORLD_TIME_SCALE = 24` (new client-safe `lib/worldClock.ts`, re-exported by the engine). The server advances time in a background tick: new `instrumentation.ts` starts `startBackgroundTick()` (1 s interval, singleton-guarded) under `NEXT_RUNTIME === 'nodejs'`, so the sim moves even with no open browser. **Idle-pause:** while no plane is flying or in repair the clock is frozen exactly — pure `shouldAdvanceTime(planes)` in `lib/sim/engine.ts` gates every tick (no catch-up jump on wake; `lastRealTickMs` still updates). Fresh worlds start at year 2000 (`STARTING_CLOCK_MS = Date.UTC(2000, 0, 1)` in `lib/game/seed.ts`; existing saves untouched — no migration). The client clock (`lib/useSimClock.ts`) extrapolates between polls at the fixed rate and freezes while idle, mirroring the server.
- **API.** `POST /api/sim` deleted (route + OpenAPI entry; spec now lists twelve endpoints) and its controls removed from the engine (`applyTimeControls`, `clampTimeScale`, MIN/MAX_TIME_SCALE). The `/api/state` snapshot's `simState` is now `{ clockMs }` only — no `timeScale`/`paused`. New test-only hook `POST /api/test/set-timescale` behind the existing `FLIGHT_TEST_HOOKS=1` gate (sibling of `/api/test/set-health`, same 404-when-disabled convention): body `{ timeScale: finite number in [1, 100_000] }` else 400; ticks first under the sim lock and returns `{ clockMs, timeScale }`. The e2e suite's per-spec `setScale` helpers now call it (and verify from the response); every spec restores the world rate in `afterEach`.
- **Tests.** Unit: engine tests re-anchored to `STARTING_CLOCK_MS` with a `makeWorldActive` helper (fresh DBs seed a grounded starter, so advancing-world tests put one in flight first) plus new idle-pause coverage (`shouldAdvanceTime` synthetic arrays; grounded fleet freezes the clock exactly; repairing keeps it advancing); actions/seed/db/openapi tests re-anchored to the year-2000 origin and fixed world rate; `lib/timeScale.ts`, its test, `components/left-pane.test.ts` and the old `/api/sim` route + test deleted. E2E: all specs rewritten off `POST /api/sim` — pause tests replaced by idle-pause assertions (e.g. `right-pane.spec.ts` verifies a double-read 1.5 s apart that a grounded fleet freezes the clock exactly, which also makes its penalty-preview math deterministic); `sim.spec.ts` activates the world via set-health + `/repair` for its advance/expiry tests and pins the new hook's validation; `panes.spec.ts` drops the slider-commit test (controls gone) and samples the smooth client clock at the fixed 24× rate, asserting the pause/slider testids are absent; `state.spec.ts` restart test self-heals to a fully grounded fleet first and now asserts the post-restart clock is byte-identical (frozen across the restart); `two-planes.spec.ts` loses its ISS-057 slow-on-landing assertions — the auto-chain observation leg now flies at world rate so polls can see it; `api.spec.ts` endpoint list drops `/api/sim` (thirteen → twelve).
## v0.51.0 — 2026-09-22 08:21
- **Max range display is now guaranteed flyable in all weather (ISS-099).** The spec card's "Max range" (`components/RightPane.tsx`) showed `maxRangeKm` — the weighted-model closed form with NO wind — while dispatch's fuel guard (`flyPlane`, and the engine's auto-chain) uses wind-adjusted burn (up to +20% headwind, v0.36.0), so a leg inside the displayed range could 409 "insufficient fuel" when the departure airport had a headwind. New `MAX_WIND_BURN_FACTOR` (= 1.2 — pinned in tests to be exactly `windBurnFactor`'s maximum) and `guaranteedRangeKm(burnPerKm, emptyWeightKg, maxFuel)` = `maxRangeKm` under worst-case headwind burn (`lib/sim/movement.ts`). The UI now shows the guaranteed range: any leg within it is flyable in all weather. ATR 72-600: 4436 → 3696 km; Boeing 777: 13045 → 10871 km — matching the `maxRangeKm ÷ 1.2` convention seed.ts already uses for its job-generation cap
- **Tests.** Unit +15: new `guaranteedRangeKm` block in `lib/sim/movement.test.ts` (3 tests — MAX_WIND_BURN_FACTOR pinned to `windBurnFactor(30, 90, 90)`, ATR ≈ 3697 km ±5%, zero-fuel/invalid-weight guards) and a per-type consistency loop in `lib/game/catalog.test.ts` (12 tests — for ALL six catalog types: `requiredFuelFor(guaranteed range)` ≤ maxFuel under worst-case headwind, tightness at 1.05× > maxFuel, and the no-wind closed form still an exact inverse). E2E: `e2e/right-pane.spec.ts` market-tab test now imports and asserts `guaranteedRangeKm` directly (local mirror deleted)
## v0.50.0 — 2026-09-22 08:00
- **Queued/planned route line is now amber/gold instead of gray (ISS-098).** The dotted queue line in `components/Map.tsx` (a flying plane's not-yet-started legs, ISS-043) was drawn in muted slate `#64748b`. It is now an amber/gold `#d4a017`, readable on both light and dark tiles. The color was extracted from the inline literal to a new exported module-scope constant `QUEUE_ROUTE_LINE_COLOR` so it is testable; the dashed/dotted style (`line-dasharray [0.1, 1.5]`) and width (3) are unchanged
- **Tests.** Unit +1: new `components/map.test.ts` pins `QUEUE_ROUTE_LINE_COLOR === '#d4a017'`. No new e2e spec — the existing queued-route rendering paths (`e2e/queue.spec.ts`, `e2e/relocation.spec.ts`) already exercise this layer, and a pure color swap needs no behavioral coverage
## v0.49.0 — 2026-09-22 07:25
- **Airport popup Relocate now works for flying/repairing planes (ISS-096).** The Relocate row in the map's airport popup (`components/Map.tsx`) previously listed only grounded planes and showed "No grounded planes to relocate." when none was available. It now lists ALL planes from the latest snapshot, and the click handler picks the endpoint by the SELECTED plane's current status: `available` → `POST /api/planes/{id}/fly` (immediate relocation, unchanged), `flying`/`repairing` → `POST /api/planes/{id}/queue` (the destination is appended to the plane's flight queue and chains on landing). Success text is "Relocation started." for fly and "Added to queue." for queue; the no-planes fallback guard is reworded to "No planes to relocate." No API changes — both endpoints existed since v0.25.0/v0.46.0
- **Tests.** E2E +1 (`e2e/relocation.spec.ts`): dispatches the starter plane on a leg via the API so it is flying, slows the sim to 1x so the leg cannot land mid-test, opens the airport popup for a different airport, selects the flying plane from the (now full) list and clicks "Relocate here", then asserts via state that the plane's `queue` contains the clicked airport's ICAO while it is still flying its original destination
## v0.48.0 — 2026-09-22 06:55
- **Leg distance shown on contracts (ISS-095).** `PackageState` gains `distanceKm` (`lib/game/state.ts`) — the great-circle origin→dest distance, computed server-side in `getStateSnapshot()` from the airport GeoJSON already loaded for live-plane math (same `haversineKm` as flight movement; 0 if a coord is missing). Shown in two places via new `formatKm()` (`lib/format.ts`, integer km, no space before "km"): the on-board contract line (`EGCC → GMME 554km €23,826.00 …`) and the job-list row (`€37,408.00 215km Perishables 0.4t …`). OpenAPI spec updated in lockstep; nothing stored
- **Tests.** Unit: `app/api/state/route.test.ts` asserts every package's `distanceKm` equals the haversine of its airports' coordinates (computed with the same lib/geo helper); `lib/openapi.test.ts` pinned key list gains `distanceKm`; new `formatKm` test in `lib/format.test.ts`. E2E: `right-pane.spec.ts` +2 assertions (job-row distance span matches /^\d+km$/, generic and hook-created package), `actions.spec.ts` +1 (on-board line)
## v0.47.0 — 2026-09-21 23:01
- **Job payoff retuned so weight dominates (ISS-090).** `REWARD_PER_KG` in `lib/game/seed.ts` rises from €0.5/kg to €2.0/kg — the linear form is unchanged, only the constant moves. Before, the weight term was small vs base+distance, so heavier jobs barely paid more (a 1000 km leg: €3,250 @500 kg → €5,000 @4,000 kg — per-kg rate fell from €4.59 to €1.01) and there was no sense in taking heavy jobs; after the retune the same leg pays €4,000 / €11,000, so weight clearly drives payoff. Safe for viability: fuel burn (`lib/sim/movement.ts`) is weight-independent, so this only raises income
- **Tests.** `seed.test.ts`: stale test name "… 0.5 * weightKg …" made generic; new literal pins in the `rewardFor` formula test — `rewardFor(1000, 4000) === 11_000` and `rewardFor(1000, 500) === 4_000` — so future retunes are deliberate. All other reward assertions recompute through the exported constants and pass unchanged
## v0.46.0 — 2026-09-21 22:32
- **Queue consumption on dispatch (ISS-088).** `flyPlane` (`lib/game/actions.ts`) now consumes `queue[0]` when the requested destination IS the first queued leg, so a landing never chains a zero-distance self-hop (the "phantom hop" — a→a completing instantly before reaching the real next leg). Flying to a non-first queued leg stays an explicit override and leaves the queue untouched; the persisted queue is written back in the same update
- **Engine auto-chain hardening.** `deliverPackages` (`lib/sim/engine.ts`) gains two guards: (1) defensive skip — if `queue[0]` equals the arrival airport (only reachable from legacy/corrupt rows now that dispatch consumes), it drops the leg without flying; (2) full-tank backstop — if the next queued leg does not fit even a full tank, the plane grounds as `'available'` with the queue kept intact instead of chaining an unflyable leg (mirrors `flyPlane`'s 409 without throwing mid-tick)
- **Selling a queued plane is rejected.** `sellPlane` (`lib/game/actions.ts`) returns 409 `the plane has queued legs — cancel queued legs first` for a grounded plane with a non-empty queue, instead of silently discarding the queued legs
- **Test hook: `/api/test/set-cash`.** New route under `app/api/test/` (sibling of `set-fuel`/`add-cash`) setting account cash to an ABSOLUTE value `{ amount }` — same `FLIGHT_TEST_HOOKS=1` gate, strict JSON validation, post-tick state snapshot. Deliberately NOT in the OpenAPI spec: test hooks are out-of-spec by convention and `lib/openapi.test.ts` pins exactly 13 public paths
- **Tests.** Unit +5 (373 total): `flyPlane` consumes queue[0] on dispatch / skip-ahead keeps the queue intact; `sellPlane` 409s a grounded plane with queued legs leaving the row untouched; engine full-tank backstop grounds with the queue intact (FGSL ~4267 km from LEMD — beyond ATR range); engine defensive self-leg drop. E2E: `e2e/queue.spec.ts` +3 — three queued legs fly in FIFO order across landings (dispatch response snapshot proves queue[0] consumed), a mid-flight drained tank is topped up before the chained leg at exactly the rep-discounted cost (expectations computed with the same lib helpers as the engine, via `/api/test/set-fuel` drain + new `set-cash` hook), cancelling the only queued leg mid-flight leaves a plain landing with no auto-chain; `e2e/state.spec.ts` +1 — a staged two-leg queue survives `docker compose restart flight` byte-identical; `e2e/two-planes.spec.ts` reworked — the 240 s poll that absorbed the phantom hop is replaced by explicit assertions: the fly response snapshot shows only `[b]` queued, and every state observed while waiting for the chain must never show location a with dest a
## v0.45.0 — 2026-09-21 21:12
- **Broken planes cannot fly (ISS-089).** A plane at `health ≤ 0` is now grounded until repaired instead of flying on fumes: `flyPlane` (`lib/game/actions.ts`) rejects it with 409 `the plane is broken (health 0) — repair it first` BEFORE any auto-refuel math, so fuel and cash stay untouched; the engine's auto-chain (`deliverPackages`, `lib/sim/engine.ts`) skips chaining when a landing's wear breaks the plane — it stays grounded at the arrival airport with its queue kept intact for a later dispatch (same shape as an unaffordable top-up)
- **Repair is gradual.** `repairPlane` no longer jumps health to 100 — it grounds the plane in `'repairing'` with the gate at `clock + REPAIR_DURATION_MS` and leaves health where it was. The engine's `finishRepairs` pass (`lib/sim/engine.ts`) now heals along a linear curve each tick: `health = min(100, max(startHealth, round(f × 100)))` with `f` the fraction of the repair duration elapsed — monotonic (never drops below the start value), and the flip to `'available'` at exactly 100 happens when the gate passes. The left-pane health bar doubles as the repair progress indicator; no UI change
- **REMOVED the broken-plane ×1.5 burn mechanic.** `BROKEN_BURN_MULTIPLIER` + `conditionBurnMultiplier()` are deleted from `lib/game/catalog.ts` and every burn site reverts to plain wind-adjusted consumption: `flyPlane` dispatch requirement, engine landed fuel + auto-chain next-leg check (`lib/sim/engine.ts`), the live in-flight gauge (`livePlane`, `lib/game/state.ts`). The mechanic was unreachable anyway — broken planes can no longer depart
- **Docs.** OpenAPI reworded to match: fly's 409 lists the broken case, queue/repair descriptions + the `PlaneState.health` field note the gradual heal and the cannot-fly rule; route comment in `app/api/planes/[planeId]/repair/route.ts` updated. No endpoints added or removed — count pins unchanged
- **Tests.** Unit: new `flyPlane` broken-guard test (409 + fuel/cash untouched) and a worn-but-not-broken control; the ×1.5 broken-burn tests deleted (`lib/game/catalog.test.ts`, `lib/game/actions.test.ts`, `lib/sim/engine.test.ts`) in favor of an engine chain-stop test (a plane that lands broken stays grounded with its queue intact despite enough fuel) and a `finishRepairs` curve test (mid-repair climb to `round(f × 100)`, floor at the start value, exact 100 + `'available'` at the gate); `repairPlane` unit pins updated — health is NOT restored by the action itself. E2E: `e2e/maintenance.spec.ts` reworked per the issue — force broken → pill shows AND dispatch 409s with the tank untouched (the ×1.5 burn flight leg is gone) → click Repair → "in repair" pill with health still ~0 → shorten the gate via the hook → back to service at exactly 100; `e2e/right-pane.spec.ts` drops the dead condition factor from its auto-refuel math
## v0.44.0 — 2026-09-21 17:10 ## v0.44.0 — 2026-09-21 17:10
- **World airport dataset (ISS-079).** `data/airports_eu.csv` (242 EU rows) is replaced by `data/airports_world.csv` — the full OurAirports world dump curated to one marker per metro area: keep `large_airport` rows only, then greedy 40 km haversine clustering with one airport kept per cluster ("biggest" proxy: scheduled service → IATA code → Wikipedia page → file order; no pax data exists in the CSV). Result: **1174 large airports worldwide → 1119 kept, 55 dropped into 52 multi-airport clusters** (e.g. Newark ← JFK + LaGuardia, Dallas ← DFW, Warsaw Chopin merged with Modlin). New committed generator `scripts/build-world-airports.mjs` re-derives the CSV from the source mirror (aborts on download failure; asserts EDDF/UNNT/LPPD presence, unique ICACOs, sane row count) - **World airport dataset (ISS-079).** `data/airports_eu.csv` (242 EU rows) is replaced by `data/airports_world.csv` — the full OurAirports world dump curated to one marker per metro area: keep `large_airport` rows only, then greedy 40 km haversine clustering with one airport kept per cluster ("biggest" proxy: scheduled service → IATA code → Wikipedia page → file order; no pax data exists in the CSV). Result: **1174 large airports worldwide → 1119 kept, 55 dropped into 52 multi-airport clusters** (e.g. Newark ← JFK + LaGuardia, Dallas ← DFW, Warsaw Chopin merged with Modlin). New committed generator `scripts/build-world-airports.mjs` re-derives the CSV from the source mirror (aborts on download failure; asserts EDDF/UNNT/LPPD presence, unique ICACOs, sane row count)
- **Loader.** `lib/airports.ts` reads the world file and drops the continent check (type filter kept as a guard); new load-time guard throws on duplicate ICAO — three consumers key features by ICAO with silent-overwrite semantics, so duplicates would corrupt state silently - **Loader.** `lib/airports.ts` reads the world file and drops the continent check (type filter kept as a guard); new load-time guard throws on duplicate ICAO — three consumers key features by ICAO with silent-overwrite semantics, so duplicates would corrupt state silently
+1 -1
View File
@@ -94,7 +94,7 @@ describe('POST /api/packages/:id/abandon', () => {
// Response convention (v0.8.0): the FULL snapshot, same shape as GET /api/state. // Response convention (v0.8.0): the FULL snapshot, same shape as GET /api/state.
const body = (await res.json()) as { const body = (await res.json()) as {
account: { cash: number }; account: { cash: number };
simState: { clockMs: number; timeScale: number; paused: boolean }; simState: { clockMs: number }; // ISS-117
packages: Array<{ id: string; status: string; planeId: string | null }>; packages: Array<{ id: string; status: string; planeId: string | null }>;
}; };
expect(Object.keys(body).sort()).toEqual([ expect(Object.keys(body).sort()).toEqual([
+1 -1
View File
@@ -92,7 +92,7 @@ describe('POST /api/packages/:id/load', () => {
// Response convention (v0.8.0): the FULL snapshot, same shape as GET /api/state. // Response convention (v0.8.0): the FULL snapshot, same shape as GET /api/state.
const body = (await res.json()) as { const body = (await res.json()) as {
account: { cash: number }; account: { cash: number };
simState: { clockMs: number; timeScale: number; paused: boolean }; simState: { clockMs: number }; // ISS-117
planes: Array<Record<string, unknown>>; planes: Array<Record<string, unknown>>;
packages: Array<{ id: string; status: string; planeId: string | null }>; packages: Array<{ id: string; status: string; planeId: string | null }>;
}; };
+1 -1
View File
@@ -84,7 +84,7 @@ describe('POST /api/planes/:planeId/fly', () => {
// Response convention (v0.8.0): the FULL snapshot, same shape as GET /api/state. // Response convention (v0.8.0): the FULL snapshot, same shape as GET /api/state.
const body = (await res.json()) as { const body = (await res.json()) as {
account: { cash: number }; account: { cash: number };
simState: { clockMs: number; timeScale: number; paused: boolean }; simState: { clockMs: number }; // ISS-117
planes: Array<{ id: string; status: string; destIcao: string | null; departureSimTime: number | null; locationIcao: string }>; planes: Array<{ id: string; status: string; destIcao: string | null; departureSimTime: number | null; locationIcao: string }>;
}; };
expect(Object.keys(body).sort()).toEqual([ expect(Object.keys(body).sort()).toEqual([
+5 -5
View File
@@ -1,10 +1,10 @@
import { performAction, repairPlane } from '@/lib/game/actions'; 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 // POST /api/planes/:planeId/repair (v0.39.0, ISS-069) — no body. Grounds a worn-out plane in
// full health for the flat REPAIR_COST: it is grounded in "repairing" status until 3 sim days // "repairing" status for the flat REPAIR_COST; its health climbs back to 100 over 3 sim days and
// pass, when the engine's tick flips it back to "available". A full-health repair is a free // the engine's tick flips it to "available" exactly when full (v0.45.0, ISS-089). A full-health
// no-op (cost 0). 404 unknown id; 409 plane not grounded or insufficient funds. On success // repair is a free no-op (cost 0). 404 unknown id; 409 plane not grounded or insufficient funds.
// returns the full state snapshot. // On success returns the full state snapshot.
export async function POST(_req: Request, ctx: { params: Promise<{ planeId: string }> }) { export async function POST(_req: Request, ctx: { params: Promise<{ planeId: string }> }) {
const { planeId } = await ctx.params; const { planeId } = await ctx.params;
-116
View File
@@ -1,116 +0,0 @@
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
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';
let dir: string;
beforeAll(async () => {
// Same pattern as app/api/state/route.test.ts: point the DB singleton at a
// throwaway file (fresh module registry per test file → fresh singleton).
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-sim-api-'));
process.env.FLIGHT_DB_PATH = path.join(dir, 'sim.db');
});
afterAll(async () => {
delete process.env.FLIGHT_DB_PATH;
await fs.rm(dir, { recursive: true, force: true });
});
const post = (body: unknown) => POST(new Request('http://localhost/api/sim', { method: 'POST', body: JSON.stringify(body) }));
const postRaw = (text: string) => POST(new Request('http://localhost/api/sim', { method: 'POST', body: text }));
describe('POST /api/sim', () => {
it('returns the current simState for an empty body (tick + no-op)', async () => {
const res = await post({});
expect(res.status).toBe(200);
const body = (await res.json()) as { clockMs: number; timeScale: number; paused: boolean };
expect(typeof body.clockMs).toBe('number');
expect(body.timeScale).toBe(1); // seeded default
expect(body.paused).toBe(false);
});
it('sets and clamps timeScale into [1, 1000]', async () => {
const hi = await post({ timeScale: 2000 });
expect(hi.status).toBe(200);
expect(((await hi.json()) as { timeScale: number }).timeScale).toBe(1000);
const lo = await post({ timeScale: 0.1 });
expect(lo.status).toBe(200);
expect(((await lo.json()) as { timeScale: number }).timeScale).toBe(1);
// The top of the ladder passes through unclamped (v0.14.0).
const max = await post({ timeScale: 1000 });
expect(max.status).toBe(200);
expect(((await max.json()) as { timeScale: number }).timeScale).toBe(1000);
// In-range values pass through unclamped — ladder and off-ladder alike.
const ladder = await post({ timeScale: 500 });
expect(ladder.status).toBe(200);
expect(((await ladder.json()) as { timeScale: number }).timeScale).toBe(500);
const mid = await post({ timeScale: 37 });
expect(mid.status).toBe(200);
expect(((await mid.json()) as { timeScale: number }).timeScale).toBe(37);
// Restore the default for later tests.
await post({ timeScale: 1 });
});
it('toggles paused and freezes the clock across ticks', async () => {
const pause = await post({ paused: true });
expect(pause.status).toBe(200);
expect(((await pause.json()) as { paused: boolean }).paused).toBe(true);
// Two reads while paused → identical clock (tick-on-read advances nothing).
const a = await post({});
const b = await post({});
expect(((await a.json()) as { clockMs: number }).clockMs).toBe(
((await b.json()) as { clockMs: number }).clockMs,
);
const resume = await post({ paused: false });
expect(resume.status).toBe(200);
expect(((await resume.json()) as { paused: boolean }).paused).toBe(false);
});
it('applies both fields in one call', async () => {
const res = await post({ timeScale: 64, paused: true });
expect(res.status).toBe(200);
const body = (await res.json()) as { timeScale: number; paused: boolean };
expect(body.timeScale).toBe(64);
expect(body.paused).toBe(true);
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 () => {
for (const body of [{ timeScale: 'fast' }, { timeScale: Infinity }, { paused: 'yes' }, { bogus: 1 }]) {
const res = await post(body);
expect(res.status).toBe(400);
expect(((await res.json()) as { error: string }).error).toBeTruthy();
}
for (const text of ['nope{', '[1,2]', '"str"', 'null']) {
const res = await postRaw(text);
expect(res.status).toBe(400);
}
});
});
-42
View File
@@ -1,42 +0,0 @@
import { applyTimeControls } from '@/lib/sim/engine';
// POST /api/sim — set sim time controls (v0.7.0). Body: { timeScale?: 1–1000, paused?: boolean }.
// Ticks the clock first (so the response reflects current game time), then applies
// and persists the new controls. Returns the updated simState.
export async function POST(req: Request) {
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 !== 'timeScale' && key !== 'paused') {
return Response.json({ error: `unknown field "${key}"` }, { status: 400 });
}
}
if (input.timeScale !== undefined && (typeof input.timeScale !== 'number' || !Number.isFinite(input.timeScale))) {
return Response.json({ error: 'timeScale must be a finite number (1–1000)' }, { status: 400 });
}
if (input.paused !== undefined && typeof input.paused !== 'boolean') {
return Response.json({ error: 'paused must be a boolean' }, { status: 400 });
}
try {
const sim = await applyTimeControls({
timeScale: input.timeScale as number | undefined,
paused: input.paused as boolean | undefined,
});
return Response.json({ clockMs: sim.clockMs, timeScale: sim.timeScale, paused: sim.paused });
} catch (err) {
console.error('failed to apply sim controls', err);
return Response.json({ error: 'failed to apply sim controls' }, { status: 500 });
}
}
+15 -4
View File
@@ -2,6 +2,8 @@ import fs from 'node:fs/promises';
import os from 'node:os'; import os from 'node:os';
import path from 'node:path'; import path from 'node:path';
import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { loadAirportsGeoJson } from '@/lib/airports';
import { haversineKm } from '@/lib/geo';
import { FUEL_PRICE_PER_L, PLANE_TYPES, SELL_FRACTION } from '@/lib/game/catalog'; import { FUEL_PRICE_PER_L, PLANE_TYPES, SELL_FRACTION } from '@/lib/game/catalog';
import { GET } from './route'; import { GET } from './route';
@@ -41,10 +43,8 @@ describe('GET /api/state', () => {
expect(typeof account.cash).toBe('number'); expect(typeof account.cash).toBe('number');
expect(typeof account.reputation).toBe('number'); expect(typeof account.reputation).toBe('number');
const sim = body.simState as { clockMs: number; timeScale: number; paused: boolean }; const sim = body.simState as { clockMs: number }; // ISS-117: time controls are gone — clock only
expect(typeof sim.clockMs).toBe('number'); expect(typeof sim.clockMs).toBe('number');
expect(typeof sim.timeScale).toBe('number');
expect(sim.paused).toBe(false);
const planes = body.planes as Array<Record<string, unknown>>; const planes = body.planes as Array<Record<string, unknown>>;
expect(planes.length).toBeGreaterThanOrEqual(1); expect(planes.length).toBeGreaterThanOrEqual(1);
@@ -58,9 +58,20 @@ 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', 'weightKg', 'deadlineSimTime', 'status']) { for (const key of ['id', 'originIcao', 'destIcao', 'reward', 'cargoType', 'weightKg', 'distanceKm', 'deadlineSimTime', 'status']) {
expect(packages[0], `package.${key}`).toHaveProperty(key); expect(packages[0], `package.${key}`).toHaveProperty(key);
} }
// v0.48.0 (ISS-095): distanceKm is computed server-side — it must equal the haversine of the
// fixture airports' coordinates for every package in the snapshot.
const fc = await loadAirportsGeoJson();
const coords = new Map(fc.features.map((f) => [f.properties.icao, f.properties]));
for (const pkg of packages) {
const from = coords.get(String(pkg.originIcao));
const to = coords.get(String(pkg.destIcao));
if (!from || !to) continue; // missing coord → 0 fallback (cannot happen with the committed dataset)
expect(pkg.distanceKm, `package ${String(pkg.id)} distance`).toBeCloseTo(haversineKm(from.lat, from.lon, to.lat, to.lon), 6);
}
}); });
it('is stable across repeated calls (same seeded DB)', async () => { it('is stable across repeated calls (same seeded DB)', async () => {
+54
View File
@@ -0,0 +1,54 @@
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.46.0, ISS-088) — sets the account's cash to an ABSOLUTE value so the e2e suite
// can stage exact balances (e.g. drain it below a refuel cost to test grounding, then restore it).
// 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; add-cash
// stays for relative grants, this one is exact.
//
// POST /api/test/set-cash
// body { amount: number } — required; a finite number in [0, ∞) (400 otherwise). Sets
// account.cash to exactly that value and 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 !== 'amount') {
return Response.json({ error: `unknown field "${key}"` }, { status: 400 });
}
}
const amount = input.amount;
if (typeof amount !== 'number' || !Number.isFinite(amount) || amount < 0) {
return Response.json({ error: 'amount is required and must be a finite number >= 0' }, { status: 400 });
}
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(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 });
}
}
+47
View File
@@ -0,0 +1,47 @@
import { setTestTimeScale } from '@/lib/sim/engine';
// TEST-ONLY hook (v0.52.0, ISS-117) — lets the e2e suite override the effective time scale now
// that there are no player-facing controls; without it every spec would run at the fixed world
// rate and a cross-Europe leg would take hours of real time. 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/set-health — same gate, same conventions.
//
// POST /api/test/set-timescale
// body { timeScale: number } — a finite number in [1, 100_000] (400 otherwise).
// Ticks first so the write lands on current game state, under the sim lock.
// Returns { clockMs, timeScale } — the new effective scale for test assertions.
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 !== 'timeScale') {
return Response.json({ error: `unknown field "${key}"` }, { status: 400 });
}
}
const timeScale = input.timeScale;
if (typeof timeScale !== 'number' || !Number.isFinite(timeScale) || timeScale < 1 || timeScale > 100_000) {
return Response.json({ error: 'timeScale must be a finite number in [1, 100000]' }, { status: 400 });
}
try {
const row = await setTestTimeScale(timeScale);
return Response.json({ clockMs: row.clockMs, timeScale: row.timeScale });
} catch (err) {
console.error('test hook failed', err);
return Response.json({ error: 'test hook failed' }, { status: 500 });
}
}
+2 -2
View File
@@ -16,7 +16,7 @@ export default function Home() {
// Single polling source for the whole shell (v0.9.0): one /api/state loop, snapshot passed // Single polling source for the whole shell (v0.9.0): one /api/state loop, snapshot passed
// down as props so later panes can share it without extra poll loops. applySnapshot (v0.10.0) // down as props so later panes can share it without extra poll loops. applySnapshot (v0.10.0)
// lets a pane push an action endpoint's full response snapshot in immediately. // lets a pane push an action endpoint's full response snapshot in immediately.
const { state, loading, error, refresh, applySnapshot } = useGameState(1000); const { state, loading, error, applySnapshot } = useGameState(1000);
// ISS-002 (v0.15.0): the side panels' hover target — shared here so hovering a row in // ISS-002 (v0.15.0): the side panels' hover target — shared here so hovering a row in
// either pane highlights the matching plane/route/airports on the map. // either pane highlights the matching plane/route/airports on the map.
@@ -45,7 +45,7 @@ export default function Home() {
<div className="app-shell"> <div className="app-shell">
{/* ISS-021 (v0.20.0) — applySnapshot also reaches the left pane: its On board section's {/* ISS-021 (v0.20.0) — applySnapshot also reaches the left pane: its On board section's
fly/abandon actions push their response snapshot into the shared state */} fly/abandon actions push their response snapshot into the shared state */}
<LeftPane state={state} loading={loading} error={error} onRefresh={refresh} applySnapshot={applySnapshot} onHover={setHovered} onError={onError} /> <LeftPane state={state} loading={loading} error={error} applySnapshot={applySnapshot} onHover={setHovered} onError={onError} />
{/* v0.11.0 — the same polled snapshot drives the map's plane markers + route lines; {/* v0.11.0 — the same polled snapshot drives the map's plane markers + route lines;
ISS-010 (v0.18.0) — applySnapshot lets the airport popup's Relocate row push its ISS-010 (v0.18.0) — applySnapshot lets the airport popup's Relocate row push its
action response into the shared state, exactly like RightPane does */} action response into the shared state, exactly like RightPane does */}
+13 -219
View File
@@ -1,12 +1,11 @@
'use client'; 'use client';
import { useCallback, useEffect, useRef, useState, type ChangeEvent } from 'react'; import { useEffect, useRef, useState } 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 { PLANE_TYPES } from '@/lib/game/catalog';
import { abandonPenalty } from '@/lib/game/penalty'; import { abandonPenalty } from '@/lib/game/penalty';
import { formatCash, formatDeadlineRelative, formatFuel, formatSimClock, formatSpecialLabel } from '@/lib/format'; import { formatCash, formatDeadlineRelative, formatFuel, formatKm, formatSimClock, formatSpecialLabel } from '@/lib/format';
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 { cargoWeightFor } from './RightPane'; import { cargoWeightFor } from './RightPane';
@@ -16,8 +15,6 @@ interface LeftPaneProps {
state: StateSnapshot | null; state: StateSnapshot | null;
loading: boolean; loading: boolean;
error: string | null; error: string | null;
// The shell's single-poll refresh — called after a sim-control POST so the pane updates immediately.
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 per-plane cargo rows' fly/abandon actions live in this pane. // state — needed now that the per-plane cargo rows' fly/abandon actions live in this pane.
applySnapshot: (snapshot: StateSnapshot) => void; applySnapshot: (snapshot: StateSnapshot) => void;
@@ -27,12 +24,6 @@ interface LeftPaneProps {
onError?: (message: string) => void; onError?: (message: string) => void;
} }
// Discrete ladder slider (v0.9.2, values reworked in v0.14.0): position t ∈ [0,1] → nearest
// step of the ladder 1×/2×/5×/10×/25×/50×/100×/250×/500×/1000× — each of the 10 positions
// roughly doubles the speed.
const SCALE_SLIDER_STEP = 1 / (TIME_SCALES.length - 1); // one ladder step per keyboard-arrow step
const SCALE_COMMIT_DEBOUNCE_MS = 250;
// Flying planes show their current leg from the snapshot's dest fields (v0.12.0). // Flying planes show their current leg from the snapshot's dest fields (v0.12.0).
function planeLocation(p: PlaneState): string { function planeLocation(p: PlaneState): string {
if (p.status === 'flying' && p.destName) return `${p.locationName} → ${p.destName}`; if (p.status === 'flying' && p.destName) return `${p.locationName} → ${p.destName}`;
@@ -47,51 +38,7 @@ function loadoutFor(state: StateSnapshot, p: PlaneState): string {
return `${(used / 1000).toFixed(1)}t / ${(maxPayloadKg / 1000).toFixed(1)}t`; return `${(used / 1000).toFixed(1)}t / ${(maxPayloadKg / 1000).toFixed(1)}t`;
} }
// ISS-001 — an optimistic slider value is cleared exactly when a rendered snapshot CONFIRMS it export default function LeftPane({ state, loading, error, applySnapshot, onHover, onError }: LeftPaneProps) {
// (server scale === pending), never earlier: clearing at commit time would snap the pointer back
// to the stale scale for one poll cycle, then jump forward again on the confirming snapshot.
export function pendingScaleConfirmed(pendingScale: number | null, serverTimeScale: number): boolean {
return pendingScale !== null && serverTimeScale === pendingScale;
}
// ISS-008 fix (v0.17.0, CI runs #44/#47): bounded retry for POST /api/sim — a commit dropped by
// the runner's flaky uplink used to leave the UI optimistic while the server kept the old scale.
// Up to maxAttempts tries with delayMs between them; stops early once superseded() flips (a newer
// intent arrived and the caller's loop sends it instead). post is idempotent set-semantics, so
// re-sending a body whose response was lost cannot double-apply. Pure core — unit-tested in node.
export const SIM_POST_MAX_ATTEMPTS = 3;
export const SIM_POST_RETRY_DELAY_MS = 500;
export type SimPostRetryResult =
| { ok: true }
| { ok: false; reason: 'exhausted'; error: unknown }
| { ok: false; reason: 'superseded' };
export async function retrySimPost(
post: () => Promise<void>,
superseded: () => boolean,
sleep: (ms: number) => Promise<void> = (ms) => new Promise((r) => setTimeout(r, ms)),
maxAttempts: number = SIM_POST_MAX_ATTEMPTS,
delayMs: number = SIM_POST_RETRY_DELAY_MS,
): Promise<SimPostRetryResult> {
let lastError: unknown;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
if (superseded()) return { ok: false, reason: 'superseded' }; // a newer intent takes over — don't even send this body
try {
await post();
return { ok: true };
} catch (err) {
lastError = err;
if (superseded()) return { ok: false, reason: 'superseded' }; // a newer intent arrived during the attempt — don't wait out the backoff
if (attempt < maxAttempts) await sleep(delayMs);
}
}
return { ok: false, reason: 'exhausted', error: lastError };
}
export default function LeftPane({ state, loading, error, onRefresh, applySnapshot, onHover, onError }: LeftPaneProps) {
const [busy, setBusy] = useState(false);
// ISS-021 (v0.20.0): shared action runner for the per-plane cargo/queue actions (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);
@@ -123,73 +70,6 @@ export default function LeftPane({ state, loading, error, onRefresh, applySnapsh
void airportsPromiseRef.current.then(setAirportOptions).catch(() => {}); void airportsPromiseRef.current.then(setAirportOptions).catch(() => {});
}, []); }, []);
// POST /api/sim (retried up to SIM_POST_MAX_ATTEMPTS× on failure — CI runs #44/#47), then
// refetch the full snapshot immediately so the pane reflects the new controls without waiting
// for the next poll tick. After the final attempt fails keep the stale UI and surface an error
// toast (the running poll retries on its own). A commit that lands while another POST is in
// flight — including mid-retry — is held in a single slot (last intent wins) and supersedes
// the in-flight body, so every user intent reaches the server (the [1,1000] clamp stays as
// backstop). The gate is a REF, not React state: closures never see a stale "busy" value,
// which used to let a flush re-queue instead of send and silently drop a commit.
const queuedRef = useRef<{ timeScale?: number; paused?: boolean } | null>(null);
const busyRef = useRef(false);
type SimControlsOutcome = 'sent' | 'queued' | 'failed';
// 'sent': this body reached the server (a confirming snapshot follows via onRefresh).
// 'queued': an in-flight call holds it — its flush will send it.
// 'failed': the POST for this body never succeeded; callers drop optimistic values only then.
const applySimControls = useCallback(
async (body: { timeScale?: number; paused?: boolean }): Promise<SimControlsOutcome> => {
if (busyRef.current) {
queuedRef.current = body; // last intent wins — the in-flight call's flush sends it
return 'queued';
}
busyRef.current = true;
setBusy(true);
let sentOwn = false;
try {
let current: { timeScale?: number; paused?: boolean } | null = body;
while (current !== null) {
const target = current; // stable across this retry round
const result = await retrySimPost(
async () => {
const res = await fetch('/api/sim', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(target),
cache: 'no-store',
});
if (!res.ok) throw new Error(`POST /api/sim → HTTP ${res.status}`);
},
() => queuedRef.current !== null, // a newer intent supersedes the in-flight body mid-retry
);
if (result.ok) {
onRefresh();
if (target === body) sentOwn = true; // object identity — queued bodies are fresh literals
} else if (result.reason === 'exhausted') {
throw result.error; // only after the final attempt fails → error toast below
}
current = queuedRef.current; // pick up anything that arrived while we were in flight
queuedRef.current = null;
}
} catch (err) {
console.error('failed to apply sim controls', err);
// ISS-008: surface the failure as an error toast too — previously console-only.
onError?.(`Failed to update time controls (${err instanceof Error ? err.message : String(err)})`);
queuedRef.current = null; // don't replay a stale intent after a failed round-trip
} finally {
busyRef.current = false;
setBusy(false);
}
return sentOwn ? 'sent' : 'failed';
},
[onRefresh, onError],
);
const paused = state?.simState.paused ?? false;
const timeScale = state?.simState.timeScale ?? 1;
// Snapshot clock — feeds the deadline/penalty previews in the per-plane cargo rows. // Snapshot clock — feeds the deadline/penalty previews in the per-plane cargo rows.
const clockMs = state?.simState.clockMs ?? 0; const clockMs = state?.simState.clockMs ?? 0;
@@ -208,69 +88,6 @@ export default function LeftPane({ state, loading, error, onRefresh, applySnapsh
relocatePlane?.status === 'flying' && relocateDest !== null && relocatePlane?.status === 'flying' && relocateDest !== null &&
(relocatePlane.destIcao === relocateDest.icao || relocatePlane.queue.includes(relocateDest.icao)); (relocatePlane.destIcao === relocateDest.icao || relocatePlane.queue.includes(relocateDest.icao));
// 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
// user releases the slider (browsers fire a native 'change' on release). The server's
// [1,1000] clamp stays as the backstop.
const [pendingScale, setPendingScaleState] = useState<number | null>(null);
const pendingRef = useRef<number | null>(null);
const commitTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const setPending = useCallback((s: number | null) => {
pendingRef.current = s;
setPendingScaleState(s);
}, []);
const commitNow = useCallback(() => {
if (commitTimerRef.current !== null) {
clearTimeout(commitTimerRef.current);
commitTimerRef.current = null;
}
const s = pendingRef.current;
if (s === null) return;
// ISS-001: keep the chosen value on screen until a snapshot CONFIRMS it — clearing at
// commit time would snap the slider back to the stale scale for one poll cycle, then jump
// forward again when the confirming snapshot lands. 'queued' also keeps it (the in-flight
// call flushes it); only a genuine failure drops it so the display falls back to truth.
void applySimControls({ timeScale: s }).then((outcome) => {
if (outcome === 'failed') setPending(null);
});
}, [applySimControls, setPending]);
// Confirmation: once a rendered snapshot carries the pending scale AND no commit is in
// flight, the optimistic value has been superseded by server truth — clear it (display is
// unchanged either way). The busy guard matters: while a commit POST is still out, an OLD
// snapshot that happens to match the pending value is coincidence, not confirmation (e.g. a
// quick drag back to the previous scale must not be swallowed before its own commit fires).
useEffect(() => {
if (!busy && state && pendingScaleConfirmed(pendingScale, state.simState.timeScale)) setPending(null);
}, [state, pendingScale, busy, setPending]);
const onSliderInput = useCallback(
(e: ChangeEvent<HTMLInputElement>) => {
setPending(sliderToScale(Number(e.target.value)));
if (commitTimerRef.current !== null) clearTimeout(commitTimerRef.current);
commitTimerRef.current = setTimeout(commitNow, SCALE_COMMIT_DEBOUNCE_MS);
},
[commitNow, setPending],
);
const sliderRef = useRef<HTMLInputElement>(null);
useEffect(() => {
const el = sliderRef.current;
if (!el) return;
el.addEventListener('change', commitNow); // flush on pointer-up / keyboard release
return () => el.removeEventListener('change', commitNow);
}, [commitNow]);
// Drop any pending debounce timer on unmount.
useEffect(
() => () => {
if (commitTimerRef.current !== null) clearTimeout(commitTimerRef.current);
},
[],
);
// ISS-008: browser-notification opt-in. Starts null (SSR-safe — the real permission is read // ISS-008: browser-notification opt-in. Starts null (SSR-safe — the real permission is read
// in an effect after hydration, so server and client render identically) and follows the // in an effect after hydration, so server and client render identically) and follows the
// result of Notification.requestPermission(). 'unsupported' = no Notification API at all. // result of Notification.requestPermission(). 'unsupported' = no Notification API at all.
@@ -290,13 +107,11 @@ export default function LeftPane({ state, loading, error, onRefresh, applySnapsh
? 'Notifications unavailable' ? 'Notifications unavailable'
: 'Enable notifications'; : 'Enable notifications';
const displayedScale = pendingScale ?? timeScale; // ISS-007: the clock ticks client-side between polls at the fixed world rate, re-syncing to
// server truth on every snapshot (and freezing while nothing is happening). Only the sim-clock
// ISS-007: the clock ticks client-side between polls, re-syncing to server truth on every // paragraph uses this — deadline and penalty previews stay snapshot-based (deterministic against
// snapshot. Fed with EFFECTIVE controls so a scale drag / pause click takes effect immediately, // the server clock).
// not when the confirming snapshot lands. Only the sim-clock paragraph uses this — deadline and const simClockMs = useSimClock(state);
// penalty previews stay snapshot-based (deterministic against the server clock).
const simClockMs = useSimClock(state, { timeScale: displayedScale, paused });
return ( return (
<aside data-testid="left-pane" className="pane left-pane"> <aside data-testid="left-pane" className="pane left-pane">
@@ -441,6 +256,8 @@ export default function LeftPane({ state, loading, error, onRefresh, applySnapsh
<span data-testid={`onboard-route-${pkg.id}`} className="contract-route"> <span data-testid={`onboard-route-${pkg.id}`} className="contract-route">
{pkg.originIcao} → {pkg.destIcao} {pkg.originIcao} → {pkg.destIcao}
</span> </span>
{/* v0.48.0 (ISS-095): leg distance after the route, e.g. "EGCC → GMME 554km". */}
<span data-testid={`onboard-distance-${pkg.id}`}>{formatKm(pkg.distanceKm)}</span>
{/* v0.37.0 (ISS-073): special-status badge — rendered only when the package has one. */} {/* v0.37.0 (ISS-073): special-status badge — rendered only when the package has one. */}
{pkg.special && ( {pkg.special && (
<span data-testid={`package-special-${pkg.id}`} className="special-badge"> <span data-testid={`package-special-${pkg.id}`} className="special-badge">
@@ -561,32 +378,9 @@ export default function LeftPane({ state, loading, error, onRefresh, applySnapsh
<section className="pane-section"> <section className="pane-section">
<h3 className="pane-heading">Time</h3> <h3 className="pane-heading">Time</h3>
<p data-testid="sim-clock" className="sim-clock"> {/* ISS-117 (v0.52.0): no player-facing time controls — the world runs at a fixed rate;
{simClockMs !== null && formatSimClock(simClockMs)} only the clock display remains here. */}
{paused ? ' (paused)' : ''} <p data-testid="sim-clock" className="sim-clock">{simClockMs !== null && formatSimClock(simClockMs)}</p>
</p>
<div className="time-controls">
<button
type="button"
data-testid="pause-toggle"
disabled={busy}
onClick={() => void applySimControls({ paused: !paused })}
>
{paused ? 'Resume' : 'Pause'}
</button>
<input
ref={sliderRef}
type="range"
data-testid="time-scale-slider"
aria-label="Time scale"
min={0}
max={1}
step={SCALE_SLIDER_STEP}
value={scaleToSlider(displayedScale)}
onChange={onSliderInput}
/>
<span data-testid="time-scale-value">{displayedScale}×</span>
</div>
{/* ISS-008: explicit opt-in for browser notifications (fired when the window is hidden). */} {/* ISS-008: explicit opt-in for browser notifications (fired when the window is hidden). */}
<button <button
type="button" type="button"
+19 -12
View File
@@ -32,6 +32,8 @@ const ROUTES_LEGS_LAYER_ID = 'routes-legs';
// (dest → queue[0] → …) as their own source + layer, drawn under everything else dynamic. // (dest → queue[0] → …) as their own source + layer, drawn under everything else dynamic.
const QUEUE_ROUTES_SOURCE_ID = 'queue-routes'; const QUEUE_ROUTES_SOURCE_ID = 'queue-routes';
const QUEUE_ROUTES_LAYER_ID = 'queue-routes'; const QUEUE_ROUTES_LAYER_ID = 'queue-routes';
// ISS-098 (v0.50.0): amber/gold — planned legs read as "queued", visible on light and dark tiles alike.
export const QUEUE_ROUTE_LINE_COLOR = '#d4a017';
// 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';
@@ -231,21 +233,22 @@ export default function FlightMap({ state, hovered, applySnapshot }: MapProps) {
// deterministic generated weather — no gameplay effects per PLAN.md. // deterministic generated weather — no gameplay effects per PLAN.md.
const w = weatherFor(p.icao); const w = weatherFor(p.icao);
const localTime = approxLocalTime(p.lon, Date.now()); const localTime = approxLocalTime(p.lon, Date.now());
// ISS-010 (v0.18.0): Relocate row — grounded planes from the latest snapshot can be // ISS-010 (v0.18.0): Relocate row — every plane from the latest snapshot can be sent to
// repositioned to this airport via POST /api/planes/{id}/fly (pure repositioning). // this airport: grounded ones fly immediately (POST /api/planes/{id}/fly), flying/repairing
// ones get the leg appended to their queue (POST /api/planes/{id}/queue, ISS-096 v0.49.0).
const st = stateRef.current; const st = stateRef.current;
const groundedPlanes = st ? st.planes.filter((pl) => pl.status === 'available') : []; const planes = st ? st.planes : [];
const planeLabel = (model: string) => st?.catalog.find((c) => c.model === model)?.label ?? model; const planeLabel = (model: string) => st?.catalog.find((c) => c.model === model)?.label ?? model;
const relocateHtml = const relocateHtml =
groundedPlanes.length > 0 planes.length > 0
? `<select data-testid="airport-popup-plane" aria-label="Plane to relocate here">` + ? `<select data-testid="airport-popup-plane" aria-label="Plane to relocate here">` +
`<option value="">Select a plane\u2026</option>` + `<option value="">Select a plane\u2026</option>` +
groundedPlanes.map( planes.map(
(pl) => (pl) =>
`<option value="${escapeHtml(pl.id)}">${escapeHtml(planeLabel(pl.model))} \u2014 ${escapeHtml(pl.locationIcao)}</option>`, `<option value="${escapeHtml(pl.id)}">${escapeHtml(planeLabel(pl.model))} \u2014 ${escapeHtml(pl.locationIcao)}</option>`,
).join('') + ).join('') +
`</select><button type="button" data-testid="airport-popup-relocate" disabled>Relocate here</button>` `</select><button type="button" data-testid="airport-popup-relocate" disabled>Relocate here</button>`
: `<span data-testid="airport-popup-no-planes">No grounded planes to relocate.</span>`; : `<span data-testid="airport-popup-no-planes">No planes to relocate.</span>`;
const popup = new maplibregl.Popup({ offset: 10 }) const popup = new maplibregl.Popup({ offset: 10 })
.setLngLat(f.geometry.coordinates as [number, number]) .setLngLat(f.geometry.coordinates as [number, number])
.setHTML( .setHTML(
@@ -263,7 +266,7 @@ export default function FlightMap({ state, hovered, applySnapshot }: MapProps) {
const el = popup.getElement(); const el = popup.getElement();
const select = el?.querySelector<HTMLSelectElement>('[data-testid="airport-popup-plane"]'); const select = el?.querySelector<HTMLSelectElement>('[data-testid="airport-popup-plane"]');
const button = el?.querySelector<HTMLButtonElement>('[data-testid="airport-popup-relocate"]'); const button = el?.querySelector<HTMLButtonElement>('[data-testid="airport-popup-relocate"]');
// The status span is always in the popup HTML (even with no grounded planes), so it // The status span is always in the popup HTML (even with no planes), so it
// exists whenever the select/button do. // exists whenever the select/button do.
const statusEl = el?.querySelector<HTMLElement>('[data-testid="airport-popup-relocate-status"]'); const statusEl = el?.querySelector<HTMLElement>('[data-testid="airport-popup-relocate-status"]');
if (select && button && statusEl) { if (select && button && statusEl) {
@@ -276,7 +279,11 @@ export default function FlightMap({ state, hovered, applySnapshot }: MapProps) {
button.disabled = true; button.disabled = true;
statusEl.textContent = 'Relocating\u2026'; statusEl.textContent = 'Relocating\u2026';
try { try {
const res = await fetch(`/api/planes/${planeId}/fly`, { // ISS-096 (v0.49.0): endpoint follows the SELECTED plane's current status — grounded
// planes fly now, flying/repairing ones get the leg queued for after landing.
const planeStatus = stateRef.current?.planes.find((pl) => pl.id === planeId)?.status ?? 'available';
const action = planeStatus === 'available' ? 'fly' : 'queue';
const res = await fetch(`/api/planes/${planeId}/${action}`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ destIcao: p.icao }), body: JSON.stringify({ destIcao: p.icao }),
@@ -285,7 +292,7 @@ export default function FlightMap({ state, hovered, applySnapshot }: MapProps) {
const data = (await res.json()) as StateSnapshot & { error?: string }; const data = (await res.json()) as StateSnapshot & { error?: string };
if (!res.ok) throw new Error(data.error ?? `HTTP ${res.status}`); if (!res.ok) throw new Error(data.error ?? `HTTP ${res.status}`);
applySnapshotRef.current(data); // shared shell state updates immediately, like the panes' runAction applySnapshotRef.current(data); // shared shell state updates immediately, like the panes' runAction
statusEl.textContent = 'Relocation started.'; statusEl.textContent = action === 'fly' ? 'Relocation started.' : 'Added to queue.';
} catch (err) { } catch (err) {
statusEl.textContent = err instanceof Error ? err.message : String(err); statusEl.textContent = err instanceof Error ? err.message : String(err);
button.disabled = false; // allow retry / picking another plane button.disabled = false; // allow retry / picking another plane
@@ -377,8 +384,8 @@ export default function FlightMap({ state, hovered, applySnapshot }: MapProps) {
}); });
} }
// ISS-043 (v0.26.0): dotted queue line — added after routes-legs but BEFORE the hover // 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 // layers so a hovered trace still draws on top of it. Amber/gold + fine dots reads as
// "planned/future", distinct from the solid amber active leg and the [2,2] yellow hover dash. // "planned/future", distinct from the solid bright-amber active leg and the [2,2] yellow hover dash.
if (!map.getSource(QUEUE_ROUTES_SOURCE_ID)) { if (!map.getSource(QUEUE_ROUTES_SOURCE_ID)) {
map.addSource(QUEUE_ROUTES_SOURCE_ID, { map.addSource(QUEUE_ROUTES_SOURCE_ID, {
type: 'geojson', type: 'geojson',
@@ -391,7 +398,7 @@ export default function FlightMap({ state, hovered, applySnapshot }: MapProps) {
type: 'line', type: 'line',
source: QUEUE_ROUTES_SOURCE_ID, source: QUEUE_ROUTES_SOURCE_ID,
paint: { paint: {
'line-color': '#64748b', // muted slate — planned legs, not actively flying 'line-color': QUEUE_ROUTE_LINE_COLOR, // amber/gold — planned legs, not actively flying (ISS-098)
'line-width': 3, 'line-width': 3,
'line-dasharray': [0.1, 1.5], // dotted — distinct from solid legs and the [2,2] hover dash 'line-dasharray': [0.1, 1.5], // dotted — distinct from solid legs and the [2,2] hover dash
}, },
+7 -4
View File
@@ -3,8 +3,8 @@
import { useState } from 'react'; import { useState } from 'react';
import type { PackageState, PlaneState, StateSnapshot } from '@/lib/game/state'; import type { PackageState, PlaneState, StateSnapshot } from '@/lib/game/state';
import { MAX_PLANE_DISCOUNT, PLANE_DISCOUNT_PER_REP, PLANE_TYPES, repRate, SELL_FRACTION } from '@/lib/game/catalog'; import { MAX_PLANE_DISCOUNT, PLANE_DISCOUNT_PER_REP, PLANE_TYPES, repRate, SELL_FRACTION } from '@/lib/game/catalog';
import { maxRangeKm } from '@/lib/sim/movement'; import { guaranteedRangeKm } from '@/lib/sim/movement';
import { formatCargo, formatCash, formatDeadlineRelative, formatSpecialLabel } from '@/lib/format'; import { formatCargo, formatCash, formatDeadlineRelative, formatKm, formatSpecialLabel } from '@/lib/format';
import { useGameAction } from '@/lib/useGameAction'; import { useGameAction } from '@/lib/useGameAction';
import type { HoverTarget } from './hoverTypes'; import type { HoverTarget } from './hoverTypes';
@@ -234,6 +234,8 @@ export default function RightPane({ state, loading, applySnapshot, onHover, onEr
</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>
{/* v0.48.0 (ISS-095): leg distance after the reward, e.g. "€37,408.00 215km". */}
<span data-testid={`open-package-distance-${pkg.id}`}>{formatKm(pkg.distanceKm)}</span>
{/* ISS-066 (v0.35.0): cargo shows as type + tonnage ("Mail 10.0t"), not "type × count". */} {/* 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-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}`}>
@@ -320,7 +322,8 @@ export default function RightPane({ state, loading, applySnapshot, onHover, onEr
{/* 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). {/* 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. */} All values come from the snapshot's catalog; max range is the GUARANTEED full-tank range —
worst-case headwind, so any leg within it is flyable in all weather (v0.51.0, ISS-099). */}
{[...catalog].sort((a, b) => a.price - b.price).map((e) => ( {[...catalog].sort((a, b) => a.price - b.price).map((e) => (
<div key={e.model} className="plane-type-card" data-testid={`plane-type-${e.model}`}> <div key={e.model} className="plane-type-card" data-testid={`plane-type-${e.model}`}>
<h4 className="plane-type-name">{e.label}</h4> <h4 className="plane-type-name">{e.label}</h4>
@@ -351,7 +354,7 @@ export default function RightPane({ state, loading, applySnapshot, onHover, onEr
<div className="stat-row"> <div className="stat-row">
<span>Max range</span> <span>Max range</span>
<strong data-testid={`plane-type-range-${e.model}`}> <strong data-testid={`plane-type-range-${e.model}`}>
{Math.round(maxRangeKm(e.burnPerKm, e.emptyWeightKg, e.maxFuel))} km {Math.round(guaranteedRangeKm(e.burnPerKm, e.emptyWeightKg, e.maxFuel))} km
</strong> </strong>
</div> </div>
</div> </div>
-91
View File
@@ -1,91 +0,0 @@
import { describe, expect, it, vi } from 'vitest';
import { pendingScaleConfirmed, retrySimPost, SIM_POST_MAX_ATTEMPTS, SIM_POST_RETRY_DELAY_MS } from './LeftPane';
// ISS-001 — the optimistic slider value is cleared exactly when a rendered snapshot confirms
// it (server scale === pending), never earlier. Clearing at commit time would snap the pointer
// back to the stale scale for one poll cycle, then jump forward again on the confirming snapshot.
describe('pendingScaleConfirmed', () => {
it('is false while there is no pending value', () => {
expect(pendingScaleConfirmed(null, 1)).toBe(false);
expect(pendingScaleConfirmed(null, 64)).toBe(false);
});
it('stays false while the server still reports the stale scale (the ISS-001 window)', () => {
// User committed 64×; the last rendered snapshot is still at 1× → must NOT clear yet.
expect(pendingScaleConfirmed(64, 1)).toBe(false);
expect(pendingScaleConfirmed(32, 8)).toBe(false);
});
it('becomes true exactly when a snapshot carries the pending scale', () => {
expect(pendingScaleConfirmed(64, 64)).toBe(true);
expect(pendingScaleConfirmed(1, 1)).toBe(true); // committing the current value confirms immediately
});
});
// ISS-008 fix (CI runs #44/#47): a dropped POST /api/sim must be retried before surfacing the
// error — up to SIM_POST_MAX_ATTEMPTS tries with SIM_POST_RETRY_DELAY_MS between them, and a newer
// intent supersedes the in-flight body instead of waiting out its retries.
describe('retrySimPost', () => {
it('pins the retry budget: 3 attempts total, ~500 ms backoff', () => {
expect(SIM_POST_MAX_ATTEMPTS).toBe(3);
expect(SIM_POST_RETRY_DELAY_MS).toBe(500);
});
it('succeeds on the first attempt without sleeping or retrying', async () => {
const post = vi.fn(async () => {});
const sleep = vi.fn(async () => {});
await expect(retrySimPost(post, () => false, sleep)).resolves.toEqual({ ok: true });
expect(post).toHaveBeenCalledTimes(1);
expect(sleep).not.toHaveBeenCalled();
});
it('retries with the backoff delay and succeeds on a later attempt', async () => {
let calls = 0;
const post = vi.fn(async () => {
if (++calls < 3) throw new Error('dropped');
});
const sleep = vi.fn(async () => {});
await expect(retrySimPost(post, () => false, sleep)).resolves.toEqual({ ok: true });
expect(post).toHaveBeenCalledTimes(3);
expect(sleep).toHaveBeenCalledTimes(2); // one backoff between each pair of attempts
expect(sleep).toHaveBeenCalledWith(SIM_POST_RETRY_DELAY_MS);
});
it('gives up after the final attempt with the last error (no trailing sleep)', async () => {
const err = new Error('down');
const post = vi.fn(async () => {
throw err;
});
const sleep = vi.fn(async () => {});
const result = await retrySimPost(post, () => false, sleep);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.reason).toBe('exhausted');
if (result.reason === 'exhausted') expect(result.error).toBe(err); // the LAST attempt's error
}
expect(post).toHaveBeenCalledTimes(SIM_POST_MAX_ATTEMPTS);
expect(sleep).toHaveBeenCalledTimes(SIM_POST_MAX_ATTEMPTS - 1);
});
it('stops retrying once a newer intent supersedes the in-flight body (no further attempts or sleeps)', async () => {
let calls = 0;
const post = vi.fn(async () => {
calls++; // flip "superseded" right after the first failure, like a queued commitNow would
throw new Error('dropped');
});
const sleep = vi.fn(async () => {});
await expect(retrySimPost(post, () => calls >= 1, sleep)).resolves.toEqual({ ok: false, reason: 'superseded' });
expect(post).toHaveBeenCalledTimes(1); // the caller's loop sends the newer body instead
expect(sleep).not.toHaveBeenCalled(); // supersede is checked before the backoff sleep
});
it('never even sends a body that is already superseded', async () => {
const post = vi.fn(async () => {});
const sleep = vi.fn(async () => {});
await expect(retrySimPost(post, () => true, sleep)).resolves.toEqual({ ok: false, reason: 'superseded' });
expect(post).not.toHaveBeenCalled();
expect(sleep).not.toHaveBeenCalled();
});
});
+11
View File
@@ -0,0 +1,11 @@
import { describe, expect, it } from 'vitest';
import { QUEUE_ROUTE_LINE_COLOR } from './Map';
// ISS-098 (v0.50.0): the dotted queued/planned route line is amber/gold, not gray — pinned so a
// future retune is deliberate. E2E already exercises the rendering path (queue.spec.ts, relocation.spec.ts).
describe('queued route line color', () => {
it('pins the amber/gold queue-line color readable on light and dark tiles', () => {
expect(QUEUE_ROUTE_LINE_COLOR).toBe('#d4a017');
});
});
+2 -1
View File
@@ -42,6 +42,7 @@ function pkg(over: Partial<PackageState> & Pick<PackageState, 'id'>): PackageSta
cargoCount: 4, cargoCount: 4,
weightKg: 1_000, weightKg: 1_000,
special: null, // v0.37.0 (ISS-073) — no special status by default in fixtures special: null, // v0.37.0 (ISS-073) — no special status by default in fixtures
distanceKm: 1839, // v0.48.0 (ISS-095) — EDDF→LEMD great-circle distance
deadlineSimTime: 1_000_000, deadlineSimTime: 1_000_000,
status: 'open', status: 'open',
planeId: null, planeId: null,
@@ -53,7 +54,7 @@ function pkg(over: Partial<PackageState> & Pick<PackageState, 'id'>): PackageSta
function snap(planes: PlaneState[], packages: PackageState[] = []): StateSnapshot { function snap(planes: PlaneState[], packages: PackageState[] = []): StateSnapshot {
return { return {
account: { cash: 0, reputation: 0 }, account: { cash: 0, reputation: 0 },
simState: { clockMs: 0, timeScale: 1, paused: false }, simState: { clockMs: 0 },
planes, planes,
packages, packages,
catalog: CATALOG, catalog: CATALOG,
+57 -30
View File
@@ -14,7 +14,8 @@ import { windBurnFactor } from '../lib/sim/movement';
// penalty while grounded; expired packages deliver/abandon with no reward and no // penalty while grounded; expired packages deliver/abandon with no reward and no
// penalty. Mirrors lib/game/penalty.ts for exact-value assertions. // penalty. Mirrors lib/game/penalty.ts for exact-value assertions.
const SCALE = 1000; // fastest sim speed (v0.14.0 ladder top) — keeps flights to seconds of real time const SCALE = 1000; // test rate for fast-forwarding legs — keeps flights to seconds of real time
const WORLD = 24; // the fixed world rate (ISS-117) — must match lib/worldClock.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)
// Mirrors lib/sim/movement.ts (v0.16.0 weight-proportional burn). // Mirrors lib/sim/movement.ts (v0.16.0 weight-proportional burn).
@@ -39,7 +40,7 @@ function penaltyFor(reward: number, createdSimTime: number, deadlineSimTime: num
interface StateBody { interface StateBody {
account: { cash: number; reputation: number }; account: { cash: number; reputation: number };
simState: { clockMs: number; timeScale: number; paused: boolean }; simState: { clockMs: number }; // ISS-117: the snapshot carries only the game clock
planes: Array<{ id: string; status: string; fuel: number; maxFuel: number; locationIcao: string; destIcao: string | null }>; planes: Array<{ id: string; status: string; fuel: number; maxFuel: number; locationIcao: string; destIcao: string | null }>;
packages: Array<{ packages: Array<{
id: string; originIcao: string; destIcao: string; reward: number; cargoType: string; cargoCount: number; id: string; originIcao: string; destIcao: string; reward: number; cargoType: string; cargoCount: number;
@@ -69,11 +70,6 @@ async function createPackage(
return (await res.json()) as StateBody['packages'][number]; return (await res.json()) as StateBody['packages'][number];
} }
async function postSim(page: Page, body: Record<string, unknown>): Promise<void> {
const res = await page.request.post('/api/sim', { data: body });
expect(res.ok()).toBeTruthy();
}
// Test hook (FLIGHT_TEST_HOOKS=1): top a plane's tank back to full — replaces the removed /refuel // Test hook (FLIGHT_TEST_HOOKS=1): top a plane's tank back to full — replaces the removed /refuel
// endpoint (ISS-082). Reads maxFuel from the snapshot so no catalog mirror is needed. // endpoint (ISS-082). Reads maxFuel from the snapshot so no catalog mirror is needed.
async function setFuelFull(page: Page, planeId: string): Promise<void> { async function setFuelFull(page: Page, planeId: string): Promise<void> {
@@ -82,15 +78,38 @@ async function setFuelFull(page: Page, planeId: string): Promise<void> {
expect((await page.request.post('/api/test/set-fuel', { data: { planeId, fuel: p.maxFuel } })).status()).toBe(200); expect((await page.request.post('/api/test/set-fuel', { data: { planeId, fuel: p.maxFuel } })).status()).toBe(200);
} }
// Set the time scale and VERIFY from the response that it stuck — if something external ever // Test hook (FLIGHT_TEST_HOOKS=1): set the world rate and VERIFY from the response that it stuck —
// resets the live instance's controls mid-test, fail loudly here instead of timing out later. // if something external ever 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, paused: false } }); const res = await page.request.post('/api/test/set-timescale', { data: { timeScale: scale } });
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/test/set-timescale`).toBe(scale);
}
// Test hook (FLIGHT_TEST_HOOKS=1): force a plane's health and/or repair gate — same shape as e2e/sim.spec.ts.
async function setHealth(page: Page, planeId: string, fields: Record<string, unknown>): Promise<void> {
const res = await page.request.post('/api/test/set-health', { data: { planeId, ...fields } });
expect(res.status()).toBe(200);
}
// Idle-pause (ISS-117) freezes a fully grounded fleet — put the starter in repair so the clock
// advances for tests that need it. The €1.5k repair cost is why the expiry test captures its
// cash/rep baseline AFTER deactivateWorld; deactivateWorld restores full health and no gate afterwards.
async function activateWorld(page: Page): Promise<void> {
await setHealth(page, 'plane-starter', { health: 50 });
expect((await page.request.post('/api/planes/plane-starter/repair')).status()).toBe(200);
}
// Shorten the repair gate to "now" — the next advancing tick flips the plane back to available.
async function deactivateWorld(page: Page): Promise<void> {
await setHealth(page, 'plane-starter', { health: 100, repairUntilMs: 0 });
await expect
.poll(async () => (await getState(page)).planes.find((p) => p.id === 'plane-starter')?.status ?? '', {
timeout: 30_000,
message: 'waiting for the starter to flip back to available',
})
.toBe('available');
} }
// Closest airport to the hub from /api/airports → shortest possible flight. Returns both // Closest airport to the hub from /api/airports → shortest possible flight. Returns both
@@ -110,10 +129,10 @@ async function nearestToHub(page: Page): Promise<{ icao: string; lat: number; lo
return { icao: nearest.icao, lat: nearest.lat, lon: nearest.lon, hubLat: hub.lat, hubLon: hub.lon }; return { icao: nearest.icao, lat: nearest.lat, lon: nearest.lon, hubLat: hub.lat, hubLon: hub.lon };
} }
// Every spec that touches the time controls restores scale=1 / unpaused on exit so // Every spec that touches the world rate restores it to the fixed world rate (ISS-117) on exit so
// a failure mid-test can't leave the live instance fast-forwarded for later specs. // a failure mid-test can't leave the live instance fast-forwarded for later specs.
test.afterEach(async ({ page }) => { test.afterEach(async ({ page }) => {
await postSim(page, { timeScale: 1, paused: false }); await setScale(page, WORLD);
}); });
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
@@ -142,8 +161,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-041: the landing above paused the sim — setScale clears pause so the return leg // Make sure the world rate is back at SCALE so the return leg fast-forwards.
// fast-forwards instead of taking real-time hours.
await setScale(page, SCALE); await setScale(page, SCALE);
const res = await page.request.post(`/api/planes/${plane.id}/fly`, { data: { destIcao: HUB } }); const res = await page.request.post(`/api/planes/${plane.id}/fly`, { data: { destIcao: HUB } });
expect(res.ok()).toBeTruthy(); // repositioning needs no package (v0.12.0 manual flight) expect(res.ok()).toBeTruthy(); // repositioning needs no package (v0.12.0 manual flight)
@@ -197,6 +215,8 @@ test('full core loop via UI: load → fly to destination → exact reward + rep
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. // 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}`); await expect(page.getByTestId(`onboard-route-${pkg.id}`)).toContainText(`${HUB} → ${nearest.icao}`);
// v0.48.0 (ISS-095): leg distance on the on-board line, right after the route — integer km.
await expect(page.getByTestId(`onboard-distance-${pkg.id}`)).toHaveText(/^\d+km$/);
// FLY via the UI: click the fly-to button in the on-board list (ISS-081: ICAO label). // 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}`);
@@ -253,8 +273,7 @@ test('full core loop via UI: load → fly to destination → exact reward + rep
// ISS-082 (v0.42.0): manual refuel is gone — the return leg below auto-refuels on dispatch; the // ISS-082 (v0.42.0): manual refuel is gone — the return leg below auto-refuels on dispatch; the
// exact top-up cost delta is pinned in e2e/right-pane.spec.ts instead of here. // exact top-up cost delta is pinned in e2e/right-pane.spec.ts instead of here.
// Restore: fly home (repositioning — no package needed in the v0.12.0 model). ISS-041: the // Restore: fly home (repositioning — no package needed in the v0.12.0 model), at SCALE.
// 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();
@@ -302,7 +321,6 @@ test('abandon while grounded deducts exactly the formula penalty', async ({ page
test('expired packages deliver and abandon with no reward and no penalty', async ({ page }) => { test('expired packages deliver and abandon with no reward and no penalty', async ({ page }) => {
test.setTimeout(480_000); // two ~10 s real-time legs at 1000× (HUB↔LEMD) plus expiry wait test.setTimeout(480_000); // two ~10 s real-time legs at 1000× (HUB↔LEMD) plus expiry wait
await flyHomeViaApi(page); await flyHomeViaApi(page);
const before = await getState(page);
// A: load, let it expire while loaded, then FLY to its destination — arrival must // A: load, let it expire while loaded, then FLY to its destination — arrival must
// unbind it WITHOUT paying out. B: load, let it expire, abandon for free. B's // unbind it WITHOUT paying out. B: load, let it expire, abandon for free. B's
@@ -316,12 +334,23 @@ test('expired packages deliver and abandon with no reward and no penalty', async
expect(res.ok()).toBeTruthy(); expect(res.ok()).toBeTruthy();
} }
// Fast-forward past both deadlines — the tick flips loaded → expired (slot kept). // Idle-pause (ISS-117): a fully grounded fleet freezes the clock, so expirePackages never runs —
await setScale(page, SCALE); // put the starter in repair for the expiry window only. The €1.5k repair cost is why the cash/rep
let s = await getState(page); // baseline below is captured AFTER deactivateWorld, not before it.
await expect.poll(async () => ((await getState(page)).packages.find((p) => p.id === pkgA.id)!.status), { timeout: 30_000 }).toBe('expired'); await activateWorld(page);
s = await getState(page); try {
expect(s.packages.find((p) => p.id === pkgB.id)!.status).toBe('expired'); // Fast-forward past both deadlines — the tick flips loaded → expired (slot kept).
await setScale(page, SCALE);
let s = await getState(page);
await expect.poll(async () => ((await getState(page)).packages.find((p) => p.id === pkgA.id)!.status), { timeout: 30_000 }).toBe('expired');
s = await getState(page);
expect(s.packages.find((p) => p.id === pkgB.id)!.status).toBe('expired');
} finally {
await deactivateWorld(page);
}
const before = await getState(page); // baseline AFTER the repair cost — no-reward assertions compare against this
let s = before;
// Fly to the destination — arrival unbinds A for free (no reward, no rep). // Fly to the destination — arrival unbinds A for free (no reward, no rep).
const flyRes = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: 'LEMD' } }); const flyRes = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: 'LEMD' } });
@@ -343,8 +372,7 @@ 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: top up + fly home. ISS-041: the LEMD arrival above paused the sim, so // Restore: top up + fly home at SCALE.
// re-set SCALE for the return leg (setScale clears pause; the expiry wait's scale is kept).
await setFuelFull(page, 'plane-starter'); await setFuelFull(page, 'plane-starter');
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 } });
@@ -384,8 +412,7 @@ 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-041: the hop arrival above // Fly home from the hop so the fuel section starts at the hub, at SCALE.
// 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();
+1 -2
View File
@@ -13,7 +13,7 @@ test('GET /api/version returns the app version from package.json', async ({ page
expect(body.version).toBe(pkg.version); expect(body.version).toBe(pkg.version);
}); });
test('GET /api/openapi serves the spec with all thirteen endpoints and the package.json version', async ({ page }) => { test('GET /api/openapi serves the spec with all twelve 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');
@@ -32,7 +32,6 @@ test('GET /api/openapi serves the spec with all thirteen endpoints and the packa
'/api/planes/{planeId}/queue', '/api/planes/{planeId}/queue',
'/api/planes/{planeId}/queue/cancel', '/api/planes/{planeId}/queue/cancel',
'/api/planes/{planeId}/repair', // v0.39.0 (ISS-069) '/api/planes/{planeId}/repair', // v0.39.0 (ISS-069)
'/api/sim',
'/api/state', '/api/state',
'/api/version', '/api/version',
]); ]);
+72
View File
@@ -0,0 +1,72 @@
import type { Page } from '@playwright/test';
import { expect, test } from '@playwright/test';
// Real helpers (v0.54.0) — the expiry floor must match lib/ exactly; Playwright resolves the repo's
// tsconfig paths, so importing beats mirroring a third copy of the constants/formulas.
import { haversineKm } from '../lib/geo';
import { travelDurationMs } from '../lib/sim/movement';
import { FASTEST_CATALOG_SPEED_KMH } from '../lib/game/catalog';
import { EXPIRY_BUFFER_MS } from '../lib/game/seed';
// v0.54.0 (ISS-119) — long priority jobs must be finishable: a package's deadline is clamped up to
// now + travel time at the fastest catalog speed + buffer, so no generated job can expire before even
// the best plane could land. The EXISTING POST /api/test/create-package hook (FLIGHT_TEST_HOOKS=1)
// forces a far-apart endpoint pair (~9000 km, at MAX_JOB_LEG_KM); this spec asserts the invariant on
// every returned package — no new server hooks needed. No scale changes, plane interaction, polling or
// map queries are involved (the assertion is self-contained in the hook response), so none of those
// conventions apply here.
const FROM = 'EDDB'; // Berlin Brandenburg
const TO = 'KSMF'; // Sacramento International — ~9000 km from EDDB, at MAX_JOB_LEG_KM
const COUNT = 10; // priority packages created and checked
interface Pkg {
id: string;
originIcao: string;
destIcao: string;
createdSimTime: number;
deadlineSimTime: number;
}
async function createPriorityPackage(page: Page, from: string, to: string): Promise<Pkg> {
const res = await page.request.post('/api/test/create-package', { data: { originIcao: from, destIcao: to, special: 'priority' } });
expect(res.status()).toBe(201);
return (await res.json()) as Pkg;
}
test('long-leg priority packages always expire after travel time at max speed + buffer', async ({ page }) => {
// The pair's real coordinates from the live dataset — the floor is computed on the same km the server uses.
const res = await page.request.get('/api/airports');
expect(res.ok()).toBeTruthy();
const fc = (await res.json()) as { features: Array<{ properties: { icao: string; lat: number; lon: number } }> };
const from = fc.features.find((f) => f.properties.icao === FROM)?.properties;
const to = fc.features.find((f) => f.properties.icao === TO)?.properties;
expect(from, `${FROM} missing from the airport dataset`).toBeTruthy();
expect(to, `${TO} missing from the airport dataset`).toBeTruthy();
if (!from || !to) return; // guarded above — keeps the math below total
const km = haversineKm(from.lat, from.lon, to.lat, to.lon);
expect(km).toBeGreaterThan(7_000); // guard: the pair must still be a long leg or this test is vacuous
const floorMs = travelDurationMs(km, FASTEST_CATALOG_SPEED_KMH) + EXPIRY_BUFFER_MS;
const created: Pkg[] = [];
try {
for (let i = 0; i < COUNT; i++) {
const pkg = await createPriorityPackage(page, FROM, TO);
expect(pkg.originIcao).toBe(FROM);
expect(pkg.destIcao).toBe(TO);
// The invariant (ISS-119): the deadline must leave at least travel time + buffer after creation.
// deadlineSimTime = round(now + offset) with offset ≥ floorMs, so this holds exactly.
const offsetMs = pkg.deadlineSimTime - pkg.createdSimTime;
expect(offsetMs).toBeGreaterThanOrEqual(Math.floor(floorMs));
// And the clamp must not blow past the priority ceiling (36 h) — the deadline stays sane.
expect(offsetMs).toBeLessThanOrEqual(36 * 3_600_000);
created.push(pkg);
}
} finally {
// Cleanup: dismiss every package this test created so it leaves no trace on the shared board.
for (const pkg of created) {
const r = await page.request.post(`/api/packages/${pkg.id}/ignore`);
expect(r.ok(), `ignoring ${pkg.id}`).toBeTruthy();
}
}
});
+83 -28
View File
@@ -11,7 +11,8 @@ import { test, expect, type Page } from '@playwright/test';
// cargo rows and the Relocate section; the relocate preview rings only its TARGET airport, which // cargo rows and the Relocate section; the relocate preview rings only its TARGET airport, which
// additionally gets a distinct green ring in `airports-target-highlight` (ISS-022). // additionally gets a distinct green ring in `airports-target-highlight` (ISS-022).
const SCALE = 1000; // fastest sim speed (v0.14.0 ladder top) — keeps flights to seconds of real time const SCALE = 1000; // test rate for fast-forwarding legs — keeps flights to seconds of real time
const WORLD = 24; // the fixed world rate (ISS-117) — must match lib/worldClock.ts
const HUB = 'EDDF'; // must match HUB_ICAO in lib/game/seed.ts const HUB = 'EDDF'; // must match HUB_ICAO in lib/game/seed.ts
// Madrid–Barajas — same long leg as e2e/map-interactions.spec.ts: ~1420 km → ~10 s at scale 1000. // Madrid–Barajas — same long leg as e2e/map-interactions.spec.ts: ~1420 km → ~10 s at scale 1000.
const DEST = 'LEMD'; const DEST = 'LEMD';
@@ -41,7 +42,7 @@ interface PackageState {
interface StateBody { interface StateBody {
account: { cash: number; reputation: number }; account: { cash: number; reputation: number };
simState: { clockMs: number; timeScale: number; paused: boolean }; simState: { clockMs: number }; // ISS-117: the snapshot carries only the game clock
planes: PlaneState[]; planes: PlaneState[];
packages: PackageState[]; packages: PackageState[];
} }
@@ -52,24 +53,18 @@ async function getState(page: Page): Promise<StateBody> {
return (await res.json()) as StateBody; return (await res.json()) as StateBody;
} }
async function postSim(page: Page, body: unknown) { // Test hook (FLIGHT_TEST_HOOKS=1): set the world rate and VERIFY from the response that it stuck —
return page.request.post('/api/sim', { data: body }); // a silent reset would otherwise surface as an unexplained landing-wait timeout.
}
// Set the time scale and VERIFY from the response that it stuck — a silent reset would
// 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, paused: false } }); const res = await page.request.post('/api/test/set-timescale', { data: { timeScale: scale } });
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/test/set-timescale`).toBe(scale);
} }
// Leave the live instance in a sane state for the other specs (scale 1, running). // Leave the live instance in a sane state for the other specs (the fixed world rate).
test.afterEach(async ({ page }) => { test.afterEach(async ({ page }) => {
const res = await postSim(page, { timeScale: 1, paused: false }); await setScale(page, WORLD);
expect(res.status()).toBe(200);
}); });
// Test hook (FLIGHT_TEST_HOOKS=1): create an open package on a known route. // Test hook (FLIGHT_TEST_HOOKS=1): create an open package on a known route.
@@ -106,8 +101,7 @@ async function flyHomeViaApi(page: Page): Promise<void> {
.toBe(true); .toBe(true);
} }
if (starter.locationIcao !== HUB) { if (starter.locationIcao !== HUB) {
// ISS-041: the landing above paused the sim — setScale clears pause so the return leg // Make sure the world rate is back at SCALE so the return leg fast-forwards.
// 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 } });
expect(res.status()).toBe(200); // repositioning needs no package (v0.12.0 manual flight) expect(res.status()).toBe(200); // repositioning needs no package (v0.12.0 manual flight)
@@ -177,6 +171,16 @@ async function fitToAirports(page: Page, icaos: string[]): Promise<void> {
if (!f) throw new Error(`airport ${icao} not found in /api/airports`); if (!f) throw new Error(`airport ${icao} not found in /api/airports`);
return [f.properties.lon, f.properties.lat] as [number, number]; return [f.properties.lon, f.properties.lat] as [number, number];
}); });
// ISS-120 (v0.53.0): raw min/max bounds for a pair straddling ±180 span the WRONG side of the globe
// (−175…+174 covers the Atlantic, not the Pacific) — unwrap each point relative to its predecessor so
// the bounds follow the short arc across the date line. Non-crossing pairs are unchanged.
for (let i = 1; i < points.length; i++) {
let lon = points[i]![0];
const prev = points[i - 1]![0];
while (lon - prev > 180) lon -= 360;
while (prev - lon > 180) lon += 360;
points[i]![0] = lon;
}
await page.evaluate((pts: [number, number][]) => { await page.evaluate((pts: [number, number][]) => {
const m = (window as any).__flightMap; const m = (window as any).__flightMap;
if (!m) throw new Error('map not ready'); if (!m) throw new Error('map not ready');
@@ -223,8 +227,13 @@ const HOVER_ROUTE_EPS_DEG = 0.05; // ~5 km: above worst-case quantization at any
async function expectHoverRoute(page: Page, fromIcao: string, toIcao: string): Promise<void> { async function expectHoverRoute(page: Page, fromIcao: string, toIcao: string): Promise<void> {
const [from, to] = await Promise.all([airportLonLat(page, fromIcao), airportLonLat(page, toIcao)]); const [from, to] = await Promise.all([airportLonLat(page, fromIcao), airportLonLat(page, toIcao)]);
const near = (a: [number, number], b: [number, number]) => // ISS-120 (v0.53.0): antimeridian-crossing traces end UNWRAPPED — the dest-side endpoint is ≡ dest
Math.abs(a[0] - b[0]) <= HOVER_ROUTE_EPS_DEG && Math.abs(a[1] - b[1]) <= HOVER_ROUTE_EPS_DEG; // (mod 360) but sits at e.g. +184.9 instead of −175.1, so compare longitudes on the circle. For any
// two lons within ±180 of each other this equals the plain |Δlon| — existing routes are unaffected.
const near = (a: [number, number], b: [number, number]) => {
const dLon = Math.abs(a[0] - b[0]);
return Math.min(dLon, 360 - dLon) <= HOVER_ROUTE_EPS_DEG && Math.abs(a[1] - b[1]) <= HOVER_ROUTE_EPS_DEG;
};
await expect.poll(async () => { await expect.poll(async () => {
const feats = (await renderedFeatures(page, ['hover-route'])) as Array<{ geometry?: { type: string; coordinates: [number, number][] } }>; const feats = (await renderedFeatures(page, ['hover-route'])) as Array<{ geometry?: { type: string; coordinates: [number, number][] } }>;
// Features are plain objects with in-page-evaluated geometry (see renderedFeatures). The filter // Features are plain objects with in-page-evaluated geometry (see renderedFeatures). The filter
@@ -350,6 +359,51 @@ test('hovering an open package highlights its endpoint airports', async ({ page
}).toBe(0); }).toBe(0);
}); });
// ISS-120 (v0.53.0): Auckland → Fua'amotu (Tonga) — both in the world dataset, and their short great
// circle crosses ±180 EASTWARD. Before the fix the arc's wrapped samples jumped +179.x → −179.x and
// MapLibre connected them with a chord across the whole map; now the trace is one continuous line.
test('hovering an antimeridian-crossing package draws one continuous trace, no chord across the map', async ({ page }) => {
test.setTimeout(300_000); // self-heal can take minutes after an interrupted run
await flyHomeViaApi(page);
const pkg = await createPackage(page, 'NZAA', 'NFTF');
await page.goto('/');
await waitForAirportCount(page);
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 });
// fitToAirports unwraps crossing pairs (ISS-120) so both endpoints are in-viewport.
await fitToAirports(page, ['NZAA', 'NFTF']);
await row.hover();
await expectHoverRoute(page, 'NZAA', 'NFTF'); // endpoint check is mod-360 aware now (ISS-120)
// The regression itself: no rendered segment may contain a ±180 longitude jump — that was the chord.
await expect.poll(async () => {
const feats = (await renderedFeatures(page, ['hover-route'])) as Array<{ geometry?: { type: string; coordinates: [number, number][] } }>;
const segs = feats.filter(
(f): f is { geometry: { type: 'LineString'; coordinates: [number, number][] } } =>
!!f.geometry && f.geometry.type === 'LineString' && Array.isArray(f.geometry.coordinates) && f.geometry.coordinates.length >= 2,
);
if (!segs.length) return false; // none yet — keep polling
for (const s of segs) {
const c = s.geometry.coordinates;
for (let i = 1; i < c.length; i++) {
if (Math.abs(c[i]![0] - c[i - 1]![0]) >= 180) return false; // a wrapped jump — the chord bug is back
}
}
return true;
}, { timeout: 15_000, message: 'waiting for the hover-route trace to be continuous across the antimeridian' }).toBe(true);
await unhover(page);
await expect.poll(async () => (await renderedFeatures(page, ['hover-route'])).length, {
timeout: 15_000,
message: 'waiting for the hover route trace to clear after mouseleave',
}).toBe(0);
});
test('hovering an on-board cargo row draws its trace and keeps the plane halo', async ({ page }) => { test('hovering an on-board cargo row draws its trace and keeps the plane halo', 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); // deterministic: the starter sits grounded at EDDF await flyHomeViaApi(page); // deterministic: the starter sits grounded at EDDF
@@ -472,8 +526,9 @@ test('hovering the fleet row of a flying plane highlights its route', async ({ p
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
// e2e/map-interactions.spec.ts' marker click): the route line stays drawn while paused. // e2e/map-interactions.spec.ts' marker click): at scale 1 the leg takes minutes of real time, and
await postSim(page, { paused: true }); // the route line stays drawn while frozen.
await setScale(page, 1);
await block.hover(); await block.hover();
await expect.poll(async () => { await expect.poll(async () => {
@@ -489,7 +544,7 @@ test('hovering the fleet row of a flying plane highlights its route', async ({ p
// Resume and fast-forward to arrival, then fly home so the live instance stays in its // Resume and fast-forward to arrival, then fly home so the live instance stays in its
// onboarding state (starter grounded at EDDF) for the other specs. // onboarding state (starter grounded at EDDF) for the other specs.
await postSim(page, { timeScale: SCALE, paused: false }); await setScale(page, SCALE);
await expect await expect
.poll(async () => { .poll(async () => {
const p = (await getState(page)).planes.find((x) => x.id === 'plane-starter'); const p = (await getState(page)).planes.find((x) => x.id === 'plane-starter');
@@ -497,7 +552,7 @@ test('hovering the fleet row of a flying plane highlights its route', async ({ p
}, { 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-041: the arrival above paused the sim, so re-set SCALE for this leg (setScale clears pause). // Re-set SCALE for the return leg.
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);
@@ -535,10 +590,10 @@ test('hovering an on-board package while its carrier is mid-flight traces from t
expect(flyBody.planes.find((p) => p.id === 'plane-starter')?.status).toBe('flying'); expect(flyBody.planes.find((p) => p.id === 'plane-starter')?.status).toBe('flying');
// Freeze immediately: at a leftover scale of 1000 the ~10 s leg could otherwise land under us and // Freeze immediately: at a leftover scale of 1000 the ~10 s leg could otherwise land under us and
// auto-chain the queued EHAM leg, which would change (or empty) the trace while we assert. Paused, // auto-chain the queued EHAM leg, which would change (or empty) the trace while we assert. At
// every /api/state snapshot reports the same state — and the new rule's start point is the LANDING // scale 1 every /api/state snapshot reports the same state for minutes — and the new rule's start
// airport regardless of progress, so no mid-flight position window is needed at all. // point is the LANDING airport regardless of progress, so no mid-flight position window is needed at all.
await postSim(page, { paused: true }); await setScale(page, 1);
const row = page.locator(`[data-testid="onboard-package-${pkg.id}"]`); const row = page.locator(`[data-testid="onboard-package-${pkg.id}"]`);
await expect(row, 'the loaded package appears in the On board section').toBeVisible({ timeout: 15_000 }); await expect(row, 'the loaded package appears in the On board section').toBeVisible({ timeout: 15_000 });
@@ -584,7 +639,7 @@ test('hovering an on-board package while its carrier is mid-flight traces from t
// not DEST — it never delivered), then fly home. // not DEST — it never delivered), then fly home.
const cancelRes = await page.request.post('/api/planes/plane-starter/queue/cancel', { data: { index: 0 } }); const cancelRes = await page.request.post('/api/planes/plane-starter/queue/cancel', { data: { index: 0 } });
expect(cancelRes.status()).toBe(200); expect(cancelRes.status()).toBe(200);
await setScale(page, SCALE); // also clears the pause above await setScale(page, SCALE);
await expect await expect
.poll(async () => { .poll(async () => {
const p = (await getState(page)).planes.find((x) => x.id === 'plane-starter'); const p = (await getState(page)).planes.find((x) => x.id === 'plane-starter');
@@ -595,7 +650,7 @@ test('hovering an on-board package while its carrier is mid-flight traces from t
const abandon = await page.request.post(`/api/packages/${pkg.id}/abandon`); const abandon = await page.request.post(`/api/packages/${pkg.id}/abandon`);
expect(abandon.status()).toBe(200); // only possible once grounded — the poll above guarantees it expect(abandon.status()).toBe(200); // only possible once grounded — the poll above guarantees it
// ISS-041: the arrival above paused the sim, so re-set SCALE for this leg (setScale clears pause). // Re-set SCALE for the return leg.
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);
+33 -62
View File
@@ -1,20 +1,17 @@
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.39.0) — the broken-state burn math must match lib/ exactly; Playwright resolves // v0.39.0 (ISS-069) — maintenance: expectations use the real balance constants.
// the repo's tsconfig paths, so importing beats mirroring a third copy of the formulas. import { REPAIR_COST } from '../lib/game/catalog';
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 // v0.39.0 (ISS-069), reworked in v0.45.0 (ISS-089) — maintenance visible flow against the live
// the fleet list; forcing it to 0 via the set-health hook shows the "broken" pill and makes a short // origin: health is shown per plane in the fleet list; forcing it to 0 via the set-health hook shows
// full-tank leg burn ×1.5 more fuel (asserted with the real lib helpers); clicking Repair deducts // the "broken" pill and blocks dispatch with a 409 that mutates nothing; clicking Repair deducts
// exactly REPAIR_COST, restores health and grounds the plane ("in repair" pill) until its gate // exactly REPAIR_COST and grounds the plane ("in repair" pill) while its health is still ~0 — the
// passes; shortening the gate via the hook brings it back to service on the next tick. // engine heals it gradually over the gate, landing on exactly 100 when the (hook-shortened) gate
// passes.
const SCALE = 1000; // fastest sim speed (v0.14.0 ladder top) — keeps flights to seconds of real time const SCALE = 1000; // test rate for fast-forwarding legs — keeps flights to seconds of real time
const WORLD = 24; // the fixed world rate (ISS-117) — must match lib/worldClock.ts
const HUB = 'EDDF'; // must match HUB_ICAO in lib/game/seed.ts const HUB = 'EDDF'; // must match HUB_ICAO in lib/game/seed.ts
const STARTER = 'plane-starter'; const STARTER = 'plane-starter';
@@ -39,7 +36,7 @@ interface CatalogEntry {
interface StateBody { interface StateBody {
account: { cash: number }; account: { cash: number };
simState: { clockMs: number; timeScale: number; paused: boolean }; simState: { clockMs: number }; // ISS-117: the snapshot carries only the game clock
planes: PlaneState[]; planes: PlaneState[];
catalog: CatalogEntry[]; catalog: CatalogEntry[];
} }
@@ -50,13 +47,13 @@ async function getState(page: Page): Promise<StateBody> {
return (await res.json()) as StateBody; return (await res.json()) as StateBody;
} }
// Set the time scale and VERIFY from the response that it stuck — a silent reset would otherwise // Test hook (FLIGHT_TEST_HOOKS=1): set the world rate and VERIFY from the response that it stuck —
// surface as an unexplained wait timeout. Also clears pause (ISS-041: landings slow to 1×). // a silent reset would otherwise surface as an unexplained wait timeout.
async function setScale(page: Page, scale: number): Promise<void> { async function setScale(page: Page, scale: number): Promise<void> {
const res = await page.request.post('/api/sim', { data: { timeScale: scale, paused: false } }); const res = await page.request.post('/api/test/set-timescale', { data: { timeScale: scale } });
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/test/set-timescale`).toBe(scale);
} }
// Test hook (FLIGHT_TEST_HOOKS=1): force a plane's health and/or shorten an active repair gate. // Test hook (FLIGHT_TEST_HOOKS=1): force a plane's health and/or shorten an active repair gate.
@@ -69,14 +66,6 @@ async function setHealth(
return (await res.json()) as StateBody; return (await res.json()) as StateBody;
} }
// Test hook (FLIGHT_TEST_HOOKS=1): top a plane's tank back to full — replaces the removed /refuel
// endpoint (ISS-082). Reads maxFuel from the snapshot so no catalog mirror is needed.
async function setFuelFull(page: Page, planeId: string): Promise<void> {
const s = await getState(page);
const p = s.planes.find((x) => x.id === planeId)!;
expect((await page.request.post('/api/test/set-fuel', { data: { planeId, fuel: p.maxFuel } })).status()).toBe(200);
}
// Mirrors haversineKm in lib/geo.ts (same formula → identical floats). // Mirrors haversineKm in lib/geo.ts (same formula → identical floats).
function haversineKm(lat1: number, lon1: number, lat2: number, lon2: number): number { 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;
@@ -88,8 +77,7 @@ function haversineKm(lat1: number, lon1: number, lat2: number, lon2: number): nu
return 2 * 6371 * Math.asin(Math.min(1, Math.sqrt(a))); 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. // Closest airport to EDDF from /api/airports — any known destination for the blocked-dispatch check.
// 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 }> { 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 { 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 } }>;
@@ -138,7 +126,7 @@ async function healStarterHome(page: Page): Promise<void> {
starter = s.planes.find((p) => p.id === STARTER)!; starter = s.planes.find((p) => p.id === STARTER)!;
} }
if (starter.locationIcao !== HUB) { if (starter.locationIcao !== HUB) {
// ISS-041: the landing above slowed the sim to 1× — setScale restores it for the return leg. // Make sure the world rate is back at SCALE for the return leg.
await setScale(page, SCALE); await setScale(page, SCALE);
// ISS-082: dispatch auto-refuels, so a grounded plane can always start any full-tank-flyable // ISS-082: dispatch auto-refuels, so a grounded plane can always start any full-tank-flyable
// leg when cash allows — no top-up retry needed. // leg when cash allows — no top-up retry needed.
@@ -165,16 +153,15 @@ async function restoreStarter(page: Page): Promise<void> {
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
// On exit (success or failure): restore scale=1 / unpaused for the other specs (house convention) // On exit (success or failure): restore the fixed world rate for the other specs (house convention)
// and leave a clean starter — full health, no active repair gate. // and leave a clean starter — full health, no active repair gate.
test.afterEach(async ({ page }) => { test.afterEach(async ({ page }) => {
const res = await page.request.post('/api/sim', { data: { timeScale: 1, paused: false } }); await setScale(page, WORLD);
expect(res.status()).toBe(200);
await restoreStarter(page); await restoreStarter(page);
}); });
test('health shown → broken burn ×1.5 → repair grounds for 3 sim days → back to service', async ({ page }) => { test('health shown → broken cannot fly → gradual repair → back to service', async ({ page }) => {
test.setTimeout(300_000); // self-heal + one short leg at 1000× + the shortened repair gate wait test.setTimeout(300_000); // self-heal + the shortened repair gate wait (no flight leg since v0.45.0)
await healStarterHome(page); // deterministic: the starter sits grounded at EDDF await healStarterHome(page); // deterministic: the starter sits grounded at EDDF
@@ -188,41 +175,25 @@ test('health shown → broken burn ×1.5 → repair grounds for 3 sim days → b
await expect(page.getByTestId(`plane-health-${STARTER}`), 'health readout shows the forced value') await expect(page.getByTestId(`plane-health-${STARTER}`), 'health readout shows the forced value')
.toContainText('42%'); .toContainText('42%');
// (b) Force broken — the pill shows, and a short full-tank leg burns ×1.5 more fuel. // (b) Force broken — the pill shows, and dispatch is blocked with a 409 that mutates nothing.
s = await setHealth(page, { planeId: STARTER, health: 0 }); s = await setHealth(page, { planeId: STARTER, health: 0 });
expect(s.planes.find((p) => p.id === STARTER)!.health).toBe(0); expect(s.planes.find((p) => p.id === STARTER)!.health).toBe(0);
await expect(page.getByTestId(`plane-broken-${STARTER}`), 'the broken pill appears') await expect(page.getByTestId(`plane-broken-${STARTER}`), 'the broken pill appears')
.toHaveText('broken', { timeout: 15_000 }); .toHaveText('broken', { timeout: 15_000 });
// Full tank for the flight (ISS-082 hook — dispatch would only top up to 150% of the leg, not full)… // The destination is irrelevant — a broken plane must be rejected BEFORE any refuel math (v0.45.0).
await setFuelFull(page, STARTER); const nearest = await nearestToHub(page);
const fuelBefore = s.planes.find((p) => p.id === STARTER)!.fuel;
const flyRes = await page.request.post(`/api/planes/${STARTER}/fly`, { data: { destIcao: nearest.icao } });
expect(flyRes.status()).toBe(409); // broken planes cannot take off…
expect(((await flyRes.json()) as { error: string }).error).toMatch(/broken \(health 0\)/);
s = await getState(page); s = await getState(page);
const starter = s.planes.find((p) => p.id === STARTER)!; const starter = s.planes.find((p) => p.id === STARTER)!;
expect(starter.fuel).toBe(starter.maxFuel); expect(starter.status).toBe('available'); // still grounded…
expect(starter.fuel).toBe(fuelBefore); // …and the auto-refuel never ran — tank untouched
// …then fly the short fixed leg HUB → nearest via the API (the UI under test is maintenance, not dispatch). // (c) Repair through the UI: exact cash drop + "in repair" pill, health still ~0 (gradual heal).
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 cashBefore = s.account.cash;
const repairBtn = page.getByTestId(`repair-${STARTER}`); const repairBtn = page.getByTestId(`repair-${STARTER}`);
await expect(repairBtn, 'the Repair button shows for a grounded worn plane').toBeVisible({ timeout: 15_000 }); await expect(repairBtn, 'the Repair button shows for a grounded worn plane').toBeVisible({ timeout: 15_000 });
@@ -237,7 +208,7 @@ test('health shown → broken burn ×1.5 → repair grounds for 3 sim days → b
expect(post.account.cash).toBeCloseTo(cashBefore - REPAIR_COST, 6); // exactly REPAIR_COST expect(post.account.cash).toBeCloseTo(cashBefore - REPAIR_COST, 6); // exactly REPAIR_COST
const repaired = post.planes.find((p) => p.id === STARTER)!; const repaired = post.planes.find((p) => p.id === STARTER)!;
expect(repaired.status).toBe('repairing'); expect(repaired.status).toBe('repairing');
expect(repaired.health).toBe(100); // restored to full… expect(repaired.health).toBeLessThan(5); // still ~0 — the engine heals it gradually over the gate (v0.45.0)…
expect(repaired.repairUntilSimTime).not.toBeNull(); // …with the gate set (3 sim days out) 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') await expect(page.getByTestId(`plane-repairing-${STARTER}`), 'the "in repair" pill appears')
+27 -25
View File
@@ -14,7 +14,8 @@ const HUB = 'EDDF'; // must match HUB_ICAO in lib/game/seed.ts
// land in ~2 s — shorter than the map poll + click pipeline). Round-trip burn (~4970 L) fits // land in ~2 s — shorter than the map poll + click pipeline). Round-trip burn (~4970 L) fits
// the 6300 L tank, so a full-tank departure always makes it home. // the 6300 L tank, so a full-tank departure always makes it home.
const DEST = 'LEMD'; const DEST = 'LEMD';
const SCALE = 1000; // fastest sim speed (v0.14.0 ladder top) const SCALE = 1000; // test rate for fast-forwarding legs
const WORLD = 24; // the fixed world rate (ISS-117) — must match lib/worldClock.ts
interface PlaneState { interface PlaneState {
id: string; id: string;
@@ -40,7 +41,7 @@ interface PackageState {
interface StateBody { interface StateBody {
account: { cash: number; reputation: number }; account: { cash: number; reputation: number };
simState: { clockMs: number; timeScale: number; paused: boolean }; simState: { clockMs: number }; // ISS-117: the snapshot carries only the game clock
planes: PlaneState[]; planes: PlaneState[];
packages: PackageState[]; packages: PackageState[];
} }
@@ -64,24 +65,18 @@ async function getState(page: Page): Promise<StateBody> {
return (await res.json()) as StateBody; return (await res.json()) as StateBody;
} }
async function postSim(page: Page, body: unknown) { // Test hook (FLIGHT_TEST_HOOKS=1): set the world rate and VERIFY from the response that it stuck —
return page.request.post('/api/sim', { data: body }); // a silent reset would otherwise surface as an unexplained landing-wait timeout.
}
// Set the time scale and VERIFY from the response that it stuck — a silent reset would
// 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, paused: false } }); const res = await page.request.post('/api/test/set-timescale', { data: { timeScale: scale } });
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/test/set-timescale`).toBe(scale);
} }
// Leave the live instance in a sane state for the other specs (scale 1, running). // Leave the live instance in a sane state for the other specs (the fixed world rate).
test.afterEach(async ({ page }) => { test.afterEach(async ({ page }) => {
const res = await postSim(page, { timeScale: 1, paused: false }); await setScale(page, WORLD);
expect(res.status()).toBe(200);
}); });
// Test hook (FLIGHT_TEST_HOOKS=1): create an open package on a known route. // Test hook (FLIGHT_TEST_HOOKS=1): create an open package on a known route.
@@ -235,7 +230,7 @@ test('plane markers move while flying and the plane popup shows status + route',
// 2) Fly the plane and speed up — its current leg must appear as a route line. // 2) Fly the plane and speed up — its current leg must appear as a route line.
const fly = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: DEST } }); const fly = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: DEST } });
expect(fly.status()).toBe(200); expect(fly.status()).toBe(200);
await postSim(page, { timeScale: SCALE }); await setScale(page, SCALE);
await expect await expect
.poll(async () => { .poll(async () => {
@@ -258,23 +253,30 @@ test('plane markers move while flying and the plane popup shows status + route',
// The arc itself stays covered by unit tests: lib/mapData.test.ts.) // The arc itself stays covered by unit tests: lib/mapData.test.ts.)
// 4) Click the flying plane → status popup with model, status, fuel and the leg. // 4) Click the flying plane → status popup with model, status, fuel and the leg.
// Freeze the sim so the click can't miss a fast-moving marker: at scale 1000 the rendered // Freeze the sim so the click can't miss a moving marker: at scale 1000 the rendered marker lags
// marker lags our fresh /api/state fetch by up to one map poll (~1 s) — tens of px of drift // our fresh /api/state fetch by up to one map poll (~1 s). At scale 1 (and this continent-level
// on this leg. Pausing makes the position static; we resume right after the popup checks. // view) the per-second drift is sub-pixel, so the position is static for all practical purposes;
await postSim(page, { paused: true }); // we restore SCALE right after the popup checks.
await setScale(page, 1);
// At scale 1 a flying plane still advances every tick (the ATR72 cruises ~0.14 km/s of real time),
// so exact lat/lon equality between polls can never hold — "settled" means slow enough to click:
// two consecutive snapshots within EPS of each other. Per-tick advance is clamped to
// MAX_TICK_ELAPSED_MS (2 s) × scale, so at scale 1 the worst-case per-poll drift is ~0.5 km even
// for the fastest catalog plane; 5 km sits far above that and far below what a still-fast clock
// would show (~km per poll pair).
let prevPos: { lat: number; lon: number } | null = null; let prevPos: { lat: number; lon: number } | null = null;
await expect await expect
.poll(async () => { .poll(async () => {
const p = (await getState(page)).planes.find((x) => x.id === 'plane-starter'); const p = (await getState(page)).planes.find((x) => x.id === 'plane-starter');
if (!p || p.status !== 'flying') return false; if (!p || p.status !== 'flying') return false;
if (prevPos && prevPos.lat === p.lat && prevPos.lon === p.lon) return true; if (prevPos && haversineKm(prevPos.lat, prevPos.lon, p.lat, p.lon) < 5) return true;
prevPos = { lat: p.lat, lon: p.lon }; prevPos = { lat: p.lat, lon: p.lon };
return false; return false;
}, { timeout: 15_000, message: 'waiting for the paused clock to freeze the plane position' }) }, { timeout: 15_000, message: 'waiting for the scale-1 clock to slow the plane to click-safe drift' })
.toBe(true); .toBe(true);
// Click the RENDERED marker position, not a fresh /api/state fetch. With the sim paused // Click the RENDERED marker position, not a fresh /api/state fetch. With the clock at scale 1
// above the server's analytic position is static — but the map renders from its own ~1 s // above the server's analytic position barely moves — but the map renders from its own ~1 s
// poll, so right after the freeze confirmation the last rendered frame can still be up to // poll, so right after the freeze confirmation the last rendered frame can still be up to
// one poll interval stale (tens of px at scale 1000 on this leg) and a click projected // one poll interval stale (tens of px at scale 1000 on this leg) and a click projected
// from a fresh fetch misses the small circle layer (CI run #37). Poll for the plane's // from a fresh fetch misses the small circle layer (CI run #37). Poll for the plane's
@@ -314,8 +316,8 @@ test('plane markers move while flying and the plane popup shows status + route',
// The popup's route line is the plane's own leg: origin name → destination name. // The popup's route line is the plane's own leg: origin name → destination name.
await expect(page.getByTestId('plane-route')).toContainText(`${live.locationName} → ${live.destName}`); await expect(page.getByTestId('plane-route')).toContainText(`${live.locationName} → ${live.destName}`);
// Resume before step 5 — the landing wait needs a running clock. // Restore SCALE before step 5 — the landing wait needs a fast clock.
await postSim(page, { timeScale: SCALE, paused: false }); await setScale(page, SCALE);
// 5) Let the flight finish, then fly home so the live instance stays in its // 5) Let the flight finish, then fly home so the live instance stays in its
// onboarding state (starter grounded at EDDF) for the other specs. The loaded // onboarding state (starter grounded at EDDF) for the other specs. The loaded
+107 -72
View File
@@ -1,20 +1,16 @@
import { test, expect, type Page } from '@playwright/test'; import { test, expect, type Page } from '@playwright/test';
// v0.9.0 — single-screen 3-pane shell + left Assets pane against the live origin. // v0.9.0 — single-screen 3-pane shell + left Assets pane against the live origin.
// The panes render from the shell's single /api/state poll (lib/useGameState.ts); time // The panes render from the shell's single /api/state poll (lib/useGameState.ts).
// controls POST to /api/sim and refetch immediately, so UI assertions settle within a tick.
const SCALE = 1000; // fastest sim speed — top of the v0.14.0 ladder (same convention as e2e/sim.spec.ts) const WORLD = 24; // the fixed world rate (ISS-117) — must match lib/worldClock.ts
const SCALE = 1000; // test rate for fast-forwarding legs (self-heal only)
interface SimState { const HUB = 'EDDF'; // must match HUB_ICAO in lib/game/seed.ts
clockMs: number; const STARTER = 'plane-starter';
timeScale: number;
paused: boolean;
}
interface StateBody { interface StateBody {
account: { cash: number; reputation: number }; account: { cash: number; reputation: number };
simState: SimState; simState: { clockMs: number }; // ISS-117: the snapshot carries only the game clock
planes: Array<Record<string, unknown>>; planes: Array<Record<string, unknown>>;
packages: Array<Record<string, unknown>>; packages: Array<Record<string, unknown>>;
} }
@@ -25,11 +21,70 @@ async function getState(page: Page): Promise<StateBody> {
return (await res.json()) as StateBody; return (await res.json()) as StateBody;
} }
// Leave the live instance in a sane state for the other specs (scale 1, running) — same // Test hook (FLIGHT_TEST_HOOKS=1): set the world rate and VERIFY from the response that it stuck.
async function setScale(page: Page, scale: number): Promise<void> {
const res = await page.request.post('/api/test/set-timescale', { data: { timeScale: scale } });
expect(res.status()).toBe(200);
const body = (await res.json()) as { timeScale: number };
expect(body.timeScale, `timeScale should be ${scale} after POST /api/test/set-timescale`).toBe(scale);
}
async function setHealth(page: Page, planeId: string, fields: Record<string, unknown>): Promise<void> {
const res = await page.request.post('/api/test/set-health', { data: { planeId, ...fields } });
expect(res.status()).toBe(200);
}
// Idle-pause (ISS-117) freezes a fully grounded fleet — put the starter in repair so the clock
// advances for tests that need it. The €1.5k repair cost is irrelevant to every spec's relative
// cash assertions; deactivateWorld restores full health and no gate afterwards.
async function activateWorld(page: Page): Promise<void> {
await setHealth(page, STARTER, { health: 50 });
expect((await page.request.post(`/api/planes/${STARTER}/repair`)).status()).toBe(200);
}
// Shorten the repair gate to "now" — the next advancing tick flips the plane back to available.
async function deactivateWorld(page: Page): Promise<void> {
await setHealth(page, STARTER, { health: 100, repairUntilMs: 0 });
await expect
.poll(async () => ((await getState(page)).planes.find((p) => p.id === STARTER) as { status: string } | undefined)?.status ?? '', {
timeout: 30_000,
message: 'waiting for the starter to flip back to available',
})
.toBe('available');
}
// Self-heal helper (same pattern as e2e/actions.spec.ts): let any in-flight leg finish and
// reposition plane-starter home via the API — an interrupted earlier run can leave it mid-flight,
// and repairPlane 409s on non-available planes, which would break activateWorld.
async function flyHomeViaApi(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 from an interrupted run before acting on the starter.
expect((await page.request.post('/api/test/set-health', { data: { planeId: 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 === STARTER) ?? {}) as { status?: string; locationIcao?: string };
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 === STARTER)?.status ?? ''), { timeout: 240_000 }).toBe('available');
s = await getState(page);
plane = (s.planes.find((p) => p.id === STARTER) ?? {}) as { status?: string; locationIcao?: string };
}
if (plane.locationIcao !== HUB) {
// Make sure the world rate is back at SCALE so the return leg fast-forwards.
await setScale(page, SCALE);
const res = await page.request.post(`/api/planes/${STARTER}/fly`, { data: { destIcao: HUB } });
expect(res.ok()).toBeTruthy(); // 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 }).toBe(HUB);
}
}
// Leave the live instance in a sane state for the other specs (the fixed world rate) — same
// pattern as e2e/sim.spec.ts. // pattern as e2e/sim.spec.ts.
test.afterEach(async ({ page }) => { test.afterEach(async ({ page }) => {
const res = await page.request.post('/api/sim', { data: { timeScale: 1, paused: false } }); await setScale(page, WORLD);
expect(res.status()).toBe(200);
}); });
test('left pane renders wallet, reputation and the starter fleet at Frankfurt', async ({ page }) => { test('left pane renders wallet, reputation and the starter fleet at Frankfurt', async ({ page }) => {
@@ -79,71 +134,51 @@ test('left pane renders wallet, reputation and the starter fleet at Frankfurt',
// Refuel heading or row anymore. // Refuel heading or row anymore.
await expect(page.getByTestId('left-pane').getByRole('heading', { name: 'Refuel', exact: true })).toHaveCount(0); await expect(page.getByTestId('left-pane').getByRole('heading', { name: 'Refuel', exact: true })).toHaveCount(0);
await expect(page.getByTestId('right-pane').getByRole('heading', { name: 'Refuel', exact: true })).toHaveCount(0); await expect(page.getByTestId('right-pane').getByRole('heading', { name: 'Refuel', exact: true })).toHaveCount(0);
// ISS-117 (v0.52.0): the time controls are gone — no pause button or scale slider in the left pane.
await expect(page.getByTestId('pause-toggle')).toHaveCount(0);
await expect(page.getByTestId('time-scale-slider')).toHaveCount(0);
}); });
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 at the fixed world rate (ISS-007, ISS-117)', async ({ page }) => {
await page.goto('/'); test.setTimeout(300_000); // self-heal (worst case ~260 s at 1000×) + clock sampling
const clock = page.getByTestId('sim-clock'); // Self-heal first: an interrupted earlier run can leave the starter mid-flight, and repairPlane
await expect(clock).toBeVisible({ timeout: 15_000 }); // 409s on non-available planes — it must be grounded before activateWorld.
await flyHomeViaApi(page);
await setScale(page, WORLD); // self-heal may have left the fast test rate behind
// Idle-pause freezes a grounded fleet — put the starter in repair so the clock advances.
await activateWorld(page);
try {
await page.goto('/');
const clock = page.getByTestId('sim-clock');
await expect(clock).toBeVisible({ timeout: 15_000 });
// Fastest scale so game seconds move visibly inside the sampling window. // "YYYY-MM-DD HH:MM:SS UTC" → epoch ms (UTC).
const post = await page.request.post('/api/sim', { data: { timeScale: SCALE, paused: false } }); const sample = async () => {
expect(post.status()).toBe(200); const text = ((await clock.textContent()) ?? '').trim();
expect(text, `clock format: ${text}`).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} UTC$/);
return Date.parse(text.replace(' ', 'T').replace(' UTC', 'Z'));
};
// "YYYY-MM-DD HH:MM:SS UTC" → epoch ms (UTC). While running there is no "(paused)" suffix. // Three samples ~1.3 s apart real time: at the fixed 24× world rate each gap is ≈31 game seconds,
const sample = async () => { // so the displayed clock must strictly advance between CONSECUTIVE samples — with poll-only
const text = ((await clock.textContent()) ?? '').trim(); // rendering a sample pair could straddle zero snapshot updates and show no change at all.
expect(text, `clock format: ${text}`).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} UTC$/); const t0 = await sample();
return Date.parse(text.replace(' ', 'T').replace(' UTC', 'Z')); await new Promise((r) => setTimeout(r, 1_300));
}; const t1 = await sample();
await new Promise((r) => setTimeout(r, 1_300));
const t2 = await sample();
// Three samples ~1.3 s apart real time: at scale 1000 each gap is ≈1300 game seconds, so the expect(t1).toBeGreaterThan(t0);
// displayed clock must strictly advance between CONSECUTIVE samples — with the old poll-only expect(t2).toBeGreaterThan(t1);
// rendering a sample pair could straddle zero snapshot updates and show no change at all. // Sanity: ~2.6 s real × 24 ≈ 62 game seconds — generous bounds keep slow CI boxes safe while
const t0 = await sample(); // still catching a frozen or wildly mis-scaled clock.
await new Promise((r) => setTimeout(r, 1_300)); const total = t2 - t0;
const t1 = await sample(); expect(total).toBeGreaterThanOrEqual(50_000);
await new Promise((r) => setTimeout(r, 1_300)); expect(total).toBeLessThanOrEqual(200_000);
const t2 = await sample(); } finally {
await deactivateWorld(page);
expect(t1).toBeGreaterThan(t0);
expect(t2).toBeGreaterThan(t1);
// Sanity: ~2.6 s real × 1000 ≈ 2.6 M game ms — generous bounds keep slow CI boxes safe while
// still catching a frozen or wildly mis-scaled clock.
const total = t2 - t0;
expect(total).toBeGreaterThanOrEqual(1_000_000);
expect(total).toBeLessThanOrEqual(6_000_000);
});
test('slider commit keeps the chosen scale on screen until confirmed — no snap-back (ISS-001)', async ({ page }) => {
test.setTimeout(60_000);
await page.goto('/');
const slider = page.getByTestId('time-scale-slider');
await expect(slider).toBeVisible({ timeout: 15_000 });
// Commit to max via keyboard (input + change → immediate commitNow POST)…
await slider.focus();
await page.keyboard.press('End');
// …and sample the displayed value rapidly across the whole commit→confirm window. With the
// ISS-001 fix the optimistic value stays until a snapshot confirms it, so EVERY sample must
// be '1000×' — never snapping back to the previous scale for one poll cycle (the old bug).
const seen: string[] = [];
for (let i = 0; i < 30; i++) {
seen.push(((await page.getByTestId('time-scale-value').textContent()) ?? '').trim());
await new Promise((r) => setTimeout(r, 25));
} }
expect(
seen.every((v) => v === '1000×'),
`displayed scale must stay 1000× during the commit window (saw: ${[...new Set(seen)].join(', ')})`,
).toBe(true);
// …and the server confirms it.
await expect.poll(async () => (await getState(page)).simState.timeScale, { timeout: 10_000 }).toBe(1000);
// Restore to min for later tests (afterEach restores scale anyway).
await page.keyboard.press('Home');
await expect.poll(async () => (await getState(page)).simState.timeScale, { timeout: 10_000 }).toBe(1);
}); });
test('right pane placeholder exists with the Jobs & Market heading', async ({ page }) => { test('right pane placeholder exists with the Jobs & Market heading', async ({ page }) => {
+170 -19
View File
@@ -1,4 +1,11 @@
import { test, expect, type Page } from '@playwright/test'; import { test, expect, type Page } from '@playwright/test';
// Real helpers (v0.46.0) — the refuel expectations must match lib/ exactly; Playwright resolves
// the repo's tsconfig paths, so importing beats mirroring a fourth copy of the formulas.
import { loadAirportsGeoJson } from '../lib/airports';
import { FUEL_DISCOUNT_PER_REP, FUEL_PRICE_PER_L, MAX_FUEL_DISCOUNT, PLANE_TYPES, repRate } from '../lib/game/catalog';
import { haversineKm, initialBearingDeg } from '../lib/geo';
import { weatherFor } from '../lib/weather';
import { fuelAtWeighted, requiredFuelFor, windBurnFactor } from '../lib/sim/movement';
// v0.26.0 — ISS-043 part 2 (UI): the flight queue. A plane's not-yet-started legs show up in a // v0.26.0 — ISS-043 part 2 (UI): the flight queue. A plane's not-yet-started legs show up in a
// Queue sub-section of its on-board block (cancel per leg; adding happens via fly-to / Relocate // Queue sub-section of its on-board block (cancel per leg; adding happens via fly-to / Relocate
@@ -7,14 +14,17 @@ import { test, expect, type Page } from '@playwright/test';
// dest → queue[0] → … — the active leg stays in the solid `routes-legs` layer. Landing auto-starts // dest → queue[0] → … — the active leg stays in the solid `routes-legs` layer. Landing auto-starts
// the next queued leg server-side (part 1); test 3 exercises that chain end-to-end through the API. // the next queued leg server-side (part 1); test 3 exercises that chain end-to-end through the API.
const SCALE = 1000; // fastest sim speed (v0.14.0 ladder top) — keeps flights to seconds of real time const SCALE = 1000; // test rate for fast-forwarding legs — keeps flights to seconds of real time
const WORLD = 24; // the fixed world rate (ISS-117) — must match lib/worldClock.ts
const HUB = 'EDDF'; // must match HUB_ICAO in lib/game/seed.ts 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 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 const QUEUE = 'EHAM'; // Amsterdam — a third airport, distinct from hub and destination
interface PlaneState { interface PlaneState {
id: string; id: string;
model: string;
status: 'available' | 'flying'; status: 'available' | 'flying';
fuel: number;
maxFuel: number; maxFuel: number;
locationIcao: string; locationIcao: string;
destIcao: string | null; destIcao: string | null;
@@ -31,7 +41,7 @@ interface PackageState {
interface StateBody { interface StateBody {
account: { cash: number; reputation: number }; account: { cash: number; reputation: number };
simState: { clockMs: number; timeScale: number; paused: boolean }; simState: { clockMs: number }; // ISS-117: the snapshot carries only the game clock
planes: PlaneState[]; planes: PlaneState[];
packages: PackageState[]; packages: PackageState[];
} }
@@ -42,23 +52,17 @@ async function getState(page: Page): Promise<StateBody> {
return (await res.json()) as StateBody; return (await res.json()) as StateBody;
} }
async function postSim(page: Page, body: unknown) { // Test hook (FLIGHT_TEST_HOOKS=1): set the world rate and VERIFY from the response that it stuck.
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> { async function setScale(page: Page, scale: number): Promise<void> {
const res = await page.request.post('/api/sim', { data: { timeScale: scale, paused: false } }); const res = await page.request.post('/api/test/set-timescale', { data: { timeScale: scale } });
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/test/set-timescale`).toBe(scale);
} }
// Leave the live instance in a sane state for the other specs (scale 1, running). // Leave the live instance in a sane state for the other specs (the fixed world rate).
test.afterEach(async ({ page }) => { test.afterEach(async ({ page }) => {
const res = await postSim(page, { timeScale: 1, paused: false }); await setScale(page, WORLD);
expect(res.status()).toBe(200);
}); });
// Test hook (FLIGHT_TEST_HOOKS=1): create an open package on a known route. // Test hook (FLIGHT_TEST_HOOKS=1): create an open package on a known route.
@@ -78,6 +82,17 @@ async function setFuelFull(page: Page, planeId: string): Promise<void> {
expect((await page.request.post('/api/test/set-fuel', { data: { planeId, fuel: p.maxFuel } })).status()).toBe(200); expect((await page.request.post('/api/test/set-fuel', { data: { planeId, fuel: p.maxFuel } })).status()).toBe(200);
} }
// Test hook (FLIGHT_TEST_HOOKS=1): set a plane's tank to an exact level — the engine computes the
// landed fuel from the row value at arrival, so draining mid-flight stages a known landing state.
async function setFuel(page: Page, planeId: string, fuel: number): Promise<void> {
expect((await page.request.post('/api/test/set-fuel', { data: { planeId, fuel } })).status()).toBe(200);
}
// Test hook (FLIGHT_TEST_HOOKS=1): set the account's cash to an exact value (v0.46.0).
async function setCash(page: Page, amount: number): Promise<void> {
expect((await page.request.post('/api/test/set-cash', { data: { amount } })).status()).toBe(200);
}
// Self-heal: let any in-flight leg finish, reposition plane-starter home via the API, and abandon // 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. // leftover cargo so every test starts from the canonical state even after an interrupted run.
async function flyHomeViaApi(page: Page): Promise<void> { async function flyHomeViaApi(page: Page): Promise<void> {
@@ -99,7 +114,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-041: the landing above paused the sim — setScale clears pause so the return leg fast-forwards. // Make sure the world rate is back at SCALE so the return leg fast-forwards.
await setScale(page, SCALE); await setScale(page, SCALE);
const res = await page.request.post(`/api/planes/${plane.id}/fly`, { data: { destIcao: HUB } }); const res = await page.request.post(`/api/planes/${plane.id}/fly`, { data: { destIcao: HUB } });
expect(res.status()).toBe(200); // repositioning needs no package (v0.12.0 manual flight) expect(res.status()).toBe(200); // repositioning needs no package (v0.12.0 manual flight)
@@ -239,7 +254,7 @@ test('queue display + cancel via UI (ISS-043 part 2)', async ({ page }) => {
const p = (await getState(page)).planes.find((x) => x.id === 'plane-starter'); const p = (await getState(page)).planes.find((x) => x.id === 'plane-starter');
return !!p && p.status === 'flying' && p.destIcao === DEST; 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); }, { timeout: 15_000, message: 'waiting for the fly click to take effect (status "flying" + dest set)' }).toBe(true);
await postSim(page, { paused: true }); // pause-sim pattern: deterministic window for UI clicks await setScale(page, 1); // scale-1 freeze pattern: deterministic window for UI clicks
// ISS-065: re-queuing the CURRENT leg's destination while flying must be rejected with 409. // 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 } }); const dupQueue = await page.request.post('/api/planes/plane-starter/queue', { data: { destIcao: DEST } });
@@ -266,7 +281,7 @@ test('queue display + cancel via UI (ISS-043 part 2)', async ({ page }) => {
return !!p && p.status === 'available' && p.locationIcao === DEST; return !!p && p.status === 'available' && p.locationIcao === DEST;
}, { timeout: 120_000, message: `waiting for plane-starter to land at ${DEST}` }).toBe(true); }, { 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. // Re-set SCALE for the return leg.
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);
@@ -296,8 +311,8 @@ test('dotted queue line renders the queued path on the map (ISS-043 part 2)', as
const flyBody = (await flyRes.json()) as StateBody; const flyBody = (await flyRes.json()) as StateBody;
expect(flyBody.planes.find((p) => p.id === 'plane-starter')?.status).toBe('flying'); 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. // Freeze mid-flight (scale 1) so the ~10 s leg can't land under us while we assert.
await postSim(page, { paused: true }); await setScale(page, 1);
await page.goto('/'); await page.goto('/');
await waitForAirportCount(page); await waitForAirportCount(page);
@@ -347,7 +362,7 @@ test('dotted queue line renders the queued path on the map (ISS-043 part 2)', as
return !!p && p.status === 'available' && p.locationIcao === DEST; return !!p && p.status === 'available' && p.locationIcao === DEST;
}, { timeout: 120_000, message: `waiting for plane-starter to land at ${DEST}` }).toBe(true); }, { 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. // Re-set SCALE for the return leg.
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);
@@ -450,3 +465,139 @@ test('landing auto-starts the next queued leg (ISS-043 chain end-to-end)', async
return !!p && p.status === 'available' && p.locationIcao === HUB; return !!p && p.status === 'available' && p.locationIcao === HUB;
}, { timeout: 120_000, message: `waiting for the chained leg to land back at ${HUB}` }).toBe(true); }, { timeout: 120_000, message: `waiting for the chained leg to land back at ${HUB}` }).toBe(true);
}); });
// --- v0.46.0 (ISS-088): queue consumption on dispatch + refuel between legs ---
test('three queued legs fly in FIFO order across landings (v0.46.0)', async ({ page }) => {
test.setTimeout(300_000); // self-heal + three chained legs at scale 1000
await flyHomeViaApi(page);
await setFuelFull(page, 'plane-starter');
// Pre-build a 3-leg route while grounded: LEMD → EHAM → back to the hub.
for (const icao of [DEST, QUEUE, HUB]) {
expect((await page.request.post('/api/planes/plane-starter/queue', { data: { destIcao: icao } })).status()).toBe(200);
}
// Start leg 1 — the response snapshot must show queue[0] CONSUMED (v0.46.0): no phantom self-hop on landing.
const flyRes = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: DEST } });
expect(flyRes.status()).toBe(200);
const p0 = ((await flyRes.json()) as StateBody).planes.find((x) => x.id === 'plane-starter')!;
expect(p0.status).toBe('flying');
expect(p0.destIcao).toBe(DEST);
expect(p0.queue, 'queue[0] consumed on dispatch — the rest stays in order').toEqual([QUEUE, HUB]);
// Landing at DEST auto-chains EHAM (leg 2)…
await setScale(page, SCALE);
await expect.poll(async () => {
const x = (await getState(page)).planes.find((y) => y.id === 'plane-starter');
return !!x && x.status === 'flying' && x.destIcao === QUEUE;
}, { timeout: 120_000, message: `waiting for the auto-chained leg ${DEST} → ${QUEUE}` }).toBe(true);
// …and landing at EHAM chains the return to HUB (leg 3).
await expect.poll(async () => {
const x = (await getState(page)).planes.find((y) => y.id === 'plane-starter');
return !!x && x.status === 'flying' && x.destIcao === HUB;
}, { timeout: 120_000, message: `waiting for the auto-chained leg ${QUEUE} → ${HUB}` }).toBe(true);
await expect.poll(async () => {
const x = (await getState(page)).planes.find((y) => y.id === 'plane-starter');
return !!x && x.status === 'available' && x.locationIcao === HUB;
}, { timeout: 120_000, message: `waiting for the chained leg to land back at ${HUB}` }).toBe(true);
expect((await getState(page)).planes.find((x) => x.id === 'plane-starter')!.queue).toEqual([]); // all three consumed in order
});
test('a drained tank is topped up before a chained leg at exactly the rep-discounted cost (v0.46.0)', async ({ page }) => {
test.setTimeout(300_000); // self-heal + two legs at scale 1000
await flyHomeViaApi(page);
const s0 = await getState(page);
const type = PLANE_TYPES.atr72; // the starter model (STARTER_MODEL)
expect(s0.planes.find((x) => x.id === 'plane-starter')!.model).toBe('atr72'); // sanity: the catalog mirror is valid
// Full tank → leg 1 dispatch is a free no-op top-up, so only the CHAIN refuel moves cash. Stage an
// exact balance (v0.46.0 hook) so the expectation holds regardless of what earlier tests earned.
await setFuelFull(page, 'plane-starter');
const STAGED_CASH = 20_000;
await setCash(page, STAGED_CASH);
const q = await page.request.post('/api/planes/plane-starter/queue', { data: { destIcao: QUEUE } });
expect(q.status()).toBe(200);
const flyRes = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: DEST } });
expect(flyRes.status()).toBe(200);
const cashBeforeChain = ((await flyRes.json()) as StateBody).account.cash;
expect(cashBeforeChain).toBe(STAGED_CASH); // dispatch was a free no-op — the staged balance is intact
// Drain the tank mid-flight — the engine computes the landed fuel from this row value at arrival.
await setFuel(page, 'plane-starter', 0);
// Expected chain refuel from the SAME helpers the engine uses (wind-adjusted, v0.36.0): leg 1 lands
// empty; leg DEST→QUEUE needs a top-up to min(maxFuel, max(landed, 1.5 × required)).
const fc = await loadAirportsGeoJson();
const hubP = fc.features.find((f) => f.properties.icao === HUB)!.properties;
const destP = fc.features.find((f) => f.properties.icao === DEST)!.properties;
const queueP = fc.features.find((f) => f.properties.icao === QUEUE)!.properties;
const km1 = haversineKm(hubP.lat, hubP.lon, destP.lat, destP.lon);
const wHub = weatherFor(HUB);
const burn1 = type.burnPerKm * windBurnFactor(wHub.windKt, wHub.windDirDeg, initialBearingDeg(hubP.lat, hubP.lon, destP.lat, destP.lon));
const landedFuel = fuelAtWeighted(0, burn1, type.emptyWeightKg, type.maxFuel, km1, 1); // drained → lands empty
expect(landedFuel).toBeCloseTo(0, 6);
const km2 = haversineKm(destP.lat, destP.lon, queueP.lat, queueP.lon);
const wDest = weatherFor(DEST); // the chained leg departs from the arrival airport
const burn2 = type.burnPerKm * windBurnFactor(wDest.windKt, wDest.windDirDeg, initialBearingDeg(destP.lat, destP.lon, queueP.lat, queueP.lon));
const required2 = requiredFuelFor(burn2, type.emptyWeightKg, type.maxFuel, km2);
expect(required2).toBeGreaterThan(landedFuel); // the top-up path genuinely runs
const target = Math.min(type.maxFuel, Math.max(landedFuel, required2 * 1.5));
const liters = Math.max(0, target - landedFuel);
const rate = repRate(s0.account.reputation, FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT);
const expectedCost = Math.round(liters * FUEL_PRICE_PER_L * (1 - rate) * 100) / 100;
// Landing at DEST auto-chains QUEUE with the top-up…
await setScale(page, SCALE);
await expect.poll(async () => {
const x = (await getState(page)).planes.find((y) => y.id === 'plane-starter');
return !!x && x.status === 'flying' && x.destIcao === QUEUE;
}, { timeout: 120_000, message: `waiting for the auto-chained leg ${DEST} → ${QUEUE}` }).toBe(true);
const s = await getState(page);
// Exact fuel equality is impossible — this snapshot lands after leg 2's departure burn; the exact top-up level × rep-discounted price is pinned by the cash assertion below.
const fuel = s.planes.find((x) => x.id === 'plane-starter')!.fuel;
expect(fuel).toBeLessThanOrEqual(target); // can only have burned since departure…
expect(fuel).toBeGreaterThan(1.4 * required2); // …and a wrong top-up level (≤ required2, or none at all) lands below this bound
expect(s.account.cash).toBeCloseTo(cashBeforeChain - expectedCost, 6); // at exactly the rep-discounted cost
// Let leg 2 finish so the next test starts from a known airport.
await expect.poll(async () => {
const x = (await getState(page)).planes.find((y) => y.id === 'plane-starter');
return !!x && x.status === 'available' && x.locationIcao === QUEUE;
}, { timeout: 120_000, message: `waiting for the chained leg to land at ${QUEUE}` }).toBe(true);
});
test('cancelling the only queued leg mid-flight leaves a plain landing (v0.46.0)', async ({ page }) => {
test.setTimeout(300_000); // self-heal + one leg at scale 1000
await flyHomeViaApi(page);
await setFuelFull(page, 'plane-starter');
const q = await page.request.post('/api/planes/plane-starter/queue', { data: { destIcao: QUEUE } });
expect(q.status()).toBe(200);
const flyRes = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: DEST } });
expect(flyRes.status()).toBe(200);
// Cancel the queued leg while airborne — landing at DEST must be a plain grounding, no auto-chain.
const cancel = await page.request.post('/api/planes/plane-starter/queue/cancel', { data: { index: 0 } });
expect(cancel.status()).toBe(200);
await setScale(page, SCALE);
await expect.poll(async () => {
const x = (await getState(page)).planes.find((y) => y.id === 'plane-starter');
return !!x && x.status === 'available' && x.locationIcao === DEST;
}, { timeout: 120_000, message: `waiting for plane-starter to land at ${DEST}` }).toBe(true);
const p = (await getState(page)).planes.find((x) => x.id === 'plane-starter')!;
expect(p.destIcao).toBeNull(); // no chained leg started…
expect(p.queue).toEqual([]); // …and the queue is empty
});
+92 -19
View File
@@ -5,17 +5,19 @@ import { expect, test } from '@playwright/test';
// Two UI surfaces drive the EXISTING POST /api/planes/{id}/fly endpoint (pure repositioning has // Two UI surfaces drive the EXISTING POST /api/planes/{id}/fly endpoint (pure repositioning has
// been allowed since v0.12.0 — no API change): the left-pane Relocate section (ISS-023, v0.21.0) // been allowed since v0.12.0 — no API change): the left-pane Relocate section (ISS-023, v0.21.0)
// (relocate-plane / relocate-dest / relocate-go) and the Relocate row inside the airport popup on // (relocate-plane / relocate-dest / relocate-go) and the Relocate row inside the airport popup on
// the map (airport-popup-plane / airport-popup-relocate). Marker-size regression is covered by // the map (airport-popup-plane / airport-popup-relocate). Since v0.49.0 (ISS-096) the popup row also
// the existing e2e/map.spec.ts click tests, not here. // works for FLYING planes — it then appends to POST /api/planes/{id}/queue instead of flying now.
// Marker-size regression is covered by the existing e2e/map.spec.ts click tests, not here.
const SCALE = 1000; // fastest sim speed (v0.14.0 ladder top) — keeps cleanup flights to seconds of real time const SCALE = 1000; // test rate for fast-forwarding legs — keeps cleanup flights to seconds of real time
const WORLD = 24; // the fixed world rate (ISS-117) — must match lib/worldClock.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)
// LFRS Nantes Atlantique — same isolated marker as e2e/map.spec.ts: its nearest neighbour is ~78 px // LFRS Nantes Atlantique — same isolated marker as e2e/map.spec.ts: its nearest neighbour is ~78 px
// away at the initial zoom, so a click on its projected center cannot hit another circle. // away at the initial zoom, so a click on its projected center cannot hit another circle.
const POPUP_AIRPORT = { icao: 'LFRS', lon: -1.61073, lat: 47.153198 }; const POPUP_AIRPORT = { icao: 'LFRS', lon: -1.61073, lat: 47.153198 };
interface StateBody { interface StateBody {
planes: Array<{ id: string; status: string; maxFuel: number; locationIcao: string; destIcao: string | null }>; planes: Array<{ id: string; status: string; maxFuel: number; locationIcao: string; destIcao: string | null; queue: string[] }>;
} }
async function getState(page: Page): Promise<StateBody> { async function getState(page: Page): Promise<StateBody> {
@@ -32,20 +34,13 @@ async function setFuelFull(page: Page, planeId: string): Promise<void> {
expect((await page.request.post('/api/test/set-fuel', { data: { planeId, fuel: p.maxFuel } })).status()).toBe(200); expect((await page.request.post('/api/test/set-fuel', { data: { planeId, fuel: p.maxFuel } })).status()).toBe(200);
} }
async function postSim(page: Page, body: Record<string, unknown>): Promise<void> { // Test hook (FLIGHT_TEST_HOOKS=1): set the world rate and VERIFY from the response that it stuck —
const res = await page.request.post('/api/sim', { data: body }); // if something external ever resets the live instance's controls mid-test, fail loudly here instead of timing out later.
expect(res.ok()).toBeTruthy();
}
// 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.
// 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, paused: false } }); const res = await page.request.post('/api/test/set-timescale', { data: { timeScale: scale } });
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/test/set-timescale`).toBe(scale);
} }
// Closest airport to the hub from /api/airports → shortest possible leg (fuel trivial). // Closest airport to the hub from /api/airports → shortest possible leg (fuel trivial).
@@ -82,8 +77,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-041: the landing above paused the sim — setScale clears pause so the return leg // Make sure the world rate is back at SCALE so the return leg fast-forwards.
// fast-forwards instead of taking real-time hours.
await setScale(page, SCALE); await setScale(page, SCALE);
const res = await page.request.post(`/api/planes/${plane.id}/fly`, { data: { destIcao: HUB } }); const res = await page.request.post(`/api/planes/${plane.id}/fly`, { data: { destIcao: HUB } });
expect(res.ok()).toBeTruthy(); // repositioning needs no package (v0.12.0 manual flight) expect(res.ok()).toBeTruthy(); // repositioning needs no package (v0.12.0 manual flight)
@@ -92,10 +86,10 @@ async function flyHomeViaApi(page: Page): Promise<void> {
} }
// Self-heal in CLEANUP (afterEach): the tests deliberately leave plane-starter mid-flight, so // Self-heal in CLEANUP (afterEach): the tests deliberately leave plane-starter mid-flight, so
// fast-forward + reposition home here; then restore scale=1 / unpaused for later specs. // fast-forward + reposition home here; then restore the fixed world rate for later specs.
test.afterEach(async ({ page }) => { test.afterEach(async ({ page }) => {
await flyHomeViaApi(page); await flyHomeViaApi(page);
await postSim(page, { timeScale: 1, paused: false }); await setScale(page, WORLD);
}); });
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
@@ -195,3 +189,82 @@ test('airport popup offers Relocate options for grounded planes', async ({ page
return p.status === 'flying' && p.destIcao === POPUP_AIRPORT.icao; return p.status === 'flying' && p.destIcao === POPUP_AIRPORT.icao;
}, { timeout: 15_000, message: 'waiting for the popup relocate click to take effect (status "flying" + dest set)' }).toBe(true); }, { timeout: 15_000, message: 'waiting for the popup relocate click to take effect (status "flying" + dest set)' }).toBe(true);
}); });
test('airport popup Relocate queues a leg for a flying plane', async ({ page }) => {
test.setTimeout(300_000); // self-heal (worst case ~20 s at 1000×) + one short leg, fast-forwarded in cleanup
await flyHomeViaApi(page);
// Full tank before departure — deterministic baseline (ISS-082 hook; dispatch would auto-refuel anyway).
await setFuelFull(page, 'plane-starter');
// ISS-096: the popup must offer Relocate for a FLYING plane too. Dispatch the starter on a leg via
// the API, then slow the sim to 1x so the leg cannot land during the UI steps (at 1x even the
// shortest leg takes minutes of real time) — "still flying its original destination" below is
// deterministic regardless of how fast the UI moves.
const nearest = await nearestToHub(page);
expect(nearest.icao).not.toBe(POPUP_AIRPORT.icao); // popup click target must differ from the active leg
await setScale(page, 1);
const flyRes = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: nearest.icao } });
expect(flyRes.ok()).toBeTruthy();
await expect.poll(async () => {
const b = await getState(page);
const p = b.planes.find((x) => x.id === 'plane-starter')!;
return p.status === 'flying' && p.destIcao === nearest.icao;
}, { timeout: 15_000, message: 'waiting for the API dispatch to take effect (status "flying" + dest set)' }).toBe(true);
await page.goto('/');
// Same map-ready waits as the grounded popup test above: airport count rendered + a snapshot with
// the plane in the planes source — the popup's Relocate row is built from that same latest snapshot.
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;
}, { timeout: 15_000 }).toBeGreaterThan(0);
await expect.poll(async () =>
page.evaluate(() => {
const m = (window as any).__flightMap;
if (!m || !m.getSource('planes')) return false;
try {
return m.querySourceFeatures('planes').some((f: { properties?: Record<string, unknown> }) => f.properties?.id === 'plane-starter');
} catch {
return false; // source not ready yet — keep polling
}
}),
{ timeout: 15_000 },
).toBe(true);
const pt = await page.evaluate(({ lon, lat }) => {
const map = (window as any).__flightMap;
const p = map.project([lon, lat]);
const rect = map.getContainer().getBoundingClientRect();
return { x: rect.left + p.x, y: rect.top + p.y };
}, POPUP_AIRPORT);
await page.mouse.click(pt.x, pt.y);
const popup = page.getByTestId('airport-popup');
await expect(popup).toBeVisible();
await expect(popup).toContainText(POPUP_AIRPORT.icao);
// The Relocate row now lists ALL planes (ISS-096) — the flying starter is selectable even though it
// is not grounded; while flying its option shows the DEPARTURE airport.
const planeSel = page.getByTestId('airport-popup-plane');
await expect(planeSel).toBeVisible();
await expect(planeSel.locator('option[value="plane-starter"]')).toHaveText(/ATR 72-600 — EDDF/);
const goBtn = page.getByTestId('airport-popup-relocate');
await expect(goBtn).toBeDisabled(); // placeholder selected
await planeSel.selectOption('plane-starter');
await expect(goBtn).toBeEnabled();
await goBtn.click();
// The click fires the queue POST asynchronously — poll until it took effect: the leg is APPENDED to
// the flight queue while the plane keeps flying its original destination.
await expect.poll(async () => {
const b = await getState(page);
const p = b.planes.find((x) => x.id === 'plane-starter')!;
return p.status === 'flying' && p.destIcao === nearest.icao && p.queue.includes(POPUP_AIRPORT.icao);
}, { timeout: 15_000, message: 'waiting for the popup relocate click to take effect (queue contains LFRS, still flying original dest)' }).toBe(true);
});
+34 -38
View File
@@ -4,9 +4,9 @@ import { expect, test } from '@playwright/test';
// the repo's tsconfig paths, so importing beats mirroring a third copy of the formulas. // the repo's tsconfig paths, so importing beats mirroring a third copy of the formulas.
import { initialBearingDeg } from '../lib/geo'; import { initialBearingDeg } from '../lib/geo';
import { weatherFor } from '../lib/weather'; import { weatherFor } from '../lib/weather';
import { fuelAtWeighted, windBurnFactor } from '../lib/sim/movement'; import { fuelAtWeighted, guaranteedRangeKm, windBurnFactor } from '../lib/sim/movement';
// v0.38.0 (ISS-072) — reputation discounts: expectations computed from the LIVE snapshot's rep with the real helpers. // v0.38.0 (ISS-072) — reputation discounts: expectations computed from the LIVE snapshot's rep with the real helpers.
import { conditionBurnMultiplier, FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT, MAX_PLANE_DISCOUNT, PLANE_DISCOUNT_PER_REP, repRate } from '../lib/game/catalog'; 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'; 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
@@ -18,7 +18,8 @@ import { STARTING_REPUTATION } from '../lib/game/seed';
// 150% of the leg requirement at exactly its rep-discounted cost (ISS-082). Packages are created via // 150% of the leg requirement at exactly its rep-discounted cost (ISS-082). Packages are created via
// POST /api/test/create-package (FLIGHT_TEST_HOOKS=1), same pattern as e2e/actions.spec.ts. // POST /api/test/create-package (FLIGHT_TEST_HOOKS=1), same pattern as e2e/actions.spec.ts.
const SCALE = 1000; // fastest sim speed (v0.14.0 ladder top) — keeps flights to seconds of real time const SCALE = 1000; // test rate for fast-forwarding legs — keeps flights to seconds of real time
const WORLD = 24; // the fixed world rate (ISS-117) — must match lib/worldClock.ts
const HUB = 'EDDF'; // must match HUB_ICAO in lib/game/seed.ts const HUB = 'EDDF'; // must match HUB_ICAO in lib/game/seed.ts
interface PlaneState { interface PlaneState {
@@ -27,7 +28,7 @@ interface PlaneState {
status: 'available' | 'flying'; status: 'available' | 'flying';
fuel: number; fuel: number;
maxFuel: number; maxFuel: number;
health: number; // v0.39.0 (ISS-069) — feeds conditionBurnMultiplier in the auto-refuel math health: number; // v0.39.0 (ISS-069) — structural health; flyHomeViaApi restores it to 100 before each test
locationIcao: string; locationIcao: string;
destIcao: string | null; destIcao: string | null;
} }
@@ -57,7 +58,7 @@ interface CatalogEntry {
interface StateBody { interface StateBody {
account: { cash: number; reputation: number }; account: { cash: number; reputation: number };
simState: { clockMs: number; timeScale: number; paused: boolean }; simState: { clockMs: number }; // ISS-117: the snapshot carries only the game clock
planes: PlaneState[]; planes: PlaneState[];
packages: PackageState[]; packages: PackageState[];
catalog: CatalogEntry[]; catalog: CatalogEntry[];
@@ -74,24 +75,18 @@ async function getState(page: Page): Promise<StateBody> {
return (await res.json()) as StateBody; return (await res.json()) as StateBody;
} }
async function postSim(page: Page, body: unknown) { // Test hook (FLIGHT_TEST_HOOKS=1): set the world rate and VERIFY from the response that it stuck —
return page.request.post('/api/sim', { data: body }); // a silent reset would otherwise surface as an unexplained landing-wait timeout.
}
// Set the time scale and VERIFY from the response that it stuck — a silent reset would
// 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, paused: false } }); const res = await page.request.post('/api/test/set-timescale', { data: { timeScale: scale } });
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/test/set-timescale`).toBe(scale);
} }
// Leave the live instance in a sane state for the other specs (scale 1, running). // Leave the live instance in a sane state for the other specs (the fixed world rate).
test.afterEach(async ({ page }) => { test.afterEach(async ({ page }) => {
const res = await postSim(page, { timeScale: 1, paused: false }); await setScale(page, WORLD);
expect(res.status()).toBe(200);
}); });
// Mirrors haversineKm in lib/geo.ts (same formula → identical floats). // Mirrors haversineKm in lib/geo.ts (same formula → identical floats).
@@ -137,12 +132,6 @@ function requiredFuelFor(burnPerKm: number, emptyWeightKg: number, maxFuel: numb
return (emptyWeightKg / FUEL_DENSITY_KG_PER_L) * (Math.exp(k * FUEL_DENSITY_KG_PER_L * distanceKm) - 1); return (emptyWeightKg / FUEL_DENSITY_KG_PER_L) * (Math.exp(k * FUEL_DENSITY_KG_PER_L * distanceKm) - 1);
} }
// Mirrors maxRangeKm in lib/sim/movement.ts (v0.28.0): full-tank range under the weighted model.
function maxRangeKm(burnPerKm: number, emptyWeightKg: number, maxFuel: number): number {
const rho = FUEL_DENSITY_KG_PER_L;
return Math.log(1 + (rho * maxFuel) / emptyWeightKg) * ((emptyWeightKg + rho * maxFuel) / (rho * burnPerKm));
}
// Test hook (FLIGHT_TEST_HOOKS=1): create an open package on a known route. Omitting `special` // 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. // 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> { async function createPackage(page: Page, fromIcao: string, toIcao: string, deadlineOffsetMs?: number, weightKg?: number, special?: string | null): Promise<PackageState> {
@@ -174,9 +163,8 @@ async function flyHomeViaApi(page: Page): Promise<void> {
.toBe(true); .toBe(true);
} }
if (starter.locationIcao !== HUB) { if (starter.locationIcao !== HUB) {
// ISS-041: the landing above paused the sim — setScale clears pause so the return leg // Make sure the world rate is back at SCALE so the return leg fast-forwards. ISS-082: dispatch
// fast-forwards instead of taking real-time hours. ISS-082: dispatch auto-refuels, so a // auto-refuels, so a grounded plane can always start any full-tank-flyable leg when cash allows — no top-up retry needed.
// grounded plane can always start any full-tank-flyable leg when cash allows — no top-up retry needed.
await setScale(page, SCALE); await setScale(page, SCALE);
const flyHome = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } }); const 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) expect(flyHome.status()).toBe(200); // repositioning needs no package (v0.12.0 manual flight)
@@ -210,6 +198,8 @@ test('open packages render with names, reward and deadline; Load is enabled at a
await expect(rows.first(), 'an open package row appears').toBeVisible({ timeout: 15_000 }); await expect(rows.first(), 'an open package row appears').toBeVisible({ timeout: 15_000 });
await expect(rows.first()).toContainText(/→/); await expect(rows.first()).toContainText(/→/);
await expect(page.locator('[data-testid^="open-package-reward-"]').first()).toContainText('€'); await expect(page.locator('[data-testid^="open-package-reward-"]').first()).toContainText('€');
// v0.48.0 (ISS-095): leg distance on the job row — integer km, no space before "km".
await expect(page.locator('[data-testid^="open-package-distance-"]').first()).toHaveText(/^\d+km$/);
// 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);
@@ -220,6 +210,8 @@ test('open packages render with names, reward and deadline; Load is enabled at a
// 4 t is within the starter ATR's 4.8 t maxPayloadKg, so Load stays enabled below. // 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-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));
// v0.48.0 (ISS-095): the hook-created package's row shows its leg distance too.
await expect(page.getByTestId(`open-package-distance-${pkg.id}`)).toHaveText(/^\d+km$/);
const loadBtn = page.getByTestId(`load-${pkg.id}`); const loadBtn = page.getByTestId(`load-${pkg.id}`);
await expect(loadBtn).toBeEnabled(); await expect(loadBtn).toBeEnabled();
@@ -276,9 +268,15 @@ test('on-board list: fly-to-destination + abandon with penalty preview; flying p
await expect(onboardRow, 'loaded package appears in the on-board list').toBeVisible({ timeout: 15_000 }); await expect(onboardRow, 'loaded package appears in the on-board list').toBeVisible({ timeout: 15_000 });
// The abandon button previews the exact penalty at the snapshot's clock (same formula as the server). // The abandon button previews the exact penalty at the snapshot's clock (same formula as the server).
// Freeze the clock first so every snapshot — mine and the UI's last poll — shares one clock value, // The fleet is grounded here (the starter just loaded; workers:1 and every spec's cleanup leave no
// making the preview deterministic instead of racing the 1 s poll loop. // other planes), so idle-pause (ISS-117) freezes the clock — verify it actually did, so every
await postSim(page, { paused: true }); // snapshot (mine and the UI's last poll) shares one clock value instead of racing the 1 s poll loop.
const frozenClock = (await getState(page)).simState.clockMs;
await new Promise((r) => setTimeout(r, 1_500)); // > one background-tick interval
expect(
(await getState(page)).simState.clockMs,
'world should be frozen while the fleet is idle (ISS-117)',
).toBe(frozenClock);
const state = await getState(page); const state = await getState(page);
const expectedPenalty = penaltyFor(pkg.reward, pkg.createdSimTime, pkg.deadlineSimTime, state.simState.clockMs); const expectedPenalty = penaltyFor(pkg.reward, pkg.createdSimTime, pkg.deadlineSimTime, state.simState.clockMs);
await expect(page.getByTestId(`abandon-penalty-${pkg.id}`)).toContainText(eur(expectedPenalty)); await expect(page.getByTestId(`abandon-penalty-${pkg.id}`)).toContainText(eur(expectedPenalty));
@@ -299,8 +297,7 @@ test('on-board list: fly-to-destination + abandon with penalty preview; flying p
expect(body.planes.find((p) => p.id === 'plane-starter')!.status).toBe('flying'); expect(body.planes.find((p) => p.id === 'plane-starter')!.status).toBe('flying');
expect(body.planes.find((p) => p.id === 'plane-starter')!.destIcao).toBe(nearest.icao); expect(body.planes.find((p) => p.id === 'plane-starter')!.destIcao).toBe(nearest.icao);
// Resume and fast-forward to arrival (poll the authoritative state). // Fast-forward to arrival (poll the authoritative state) — the takeoff above unfroze the world.
await postSim(page, { paused: false });
await setScale(page, SCALE); await setScale(page, SCALE);
await expect await expect
.poll(async () => { .poll(async () => {
@@ -315,8 +312,7 @@ test('on-board list: fly-to-destination + abandon with penalty preview; flying p
expect(body.packages.find((p) => p.id === pkg.id)?.status).toBe('delivered'); expect(body.packages.find((p) => p.id === pkg.id)?.status).toBe('delivered');
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 at SCALE so the live instance stays in its onboarding state for the next run.
// 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);
@@ -389,11 +385,11 @@ test('dispatch auto-refuels: a low tank tops up to 150% of the leg requirement a
expect(starter.fuel).toBe(LOW_FUEL); // the hook stuck expect(starter.fuel).toBe(LOW_FUEL); // the hook stuck
// Expected top-up from the snapshot constants — mirrors flyPlane's math (lib/game/actions.ts): // Expected top-up from the snapshot constants — mirrors flyPlane's math (lib/game/actions.ts):
// wind-adjusted requirement × condition multiplier, 150% margin capped at tank capacity. // wind-adjusted requirement, 150% margin capped at tank capacity.
const km = haversineKm(nearest.hubLat, nearest.hubLon, nearest.lat, nearest.lon); const km = haversineKm(nearest.hubLat, nearest.hubLon, nearest.lat, nearest.lon);
const wHub = weatherFor(HUB); const wHub = weatherFor(HUB);
const required = requiredFuelFor( const required = requiredFuelFor(
entry.burnPerKm * windBurnFactor(wHub.windKt, wHub.windDirDeg, initialBearingDeg(nearest.hubLat, nearest.hubLon, nearest.lat, nearest.lon)) * conditionBurnMultiplier(starter.health), entry.burnPerKm * windBurnFactor(wHub.windKt, wHub.windDirDeg, initialBearingDeg(nearest.hubLat, nearest.hubLon, nearest.lat, nearest.lon)),
entry.emptyWeightKg, entry.emptyWeightKg,
entry.maxFuel, entry.maxFuel,
km, km,
@@ -424,7 +420,7 @@ test('dispatch auto-refuels: a low tank tops up to 150% of the leg requirement a
const landed = await getState(page); const landed = await getState(page);
expect(landed.planes.find((p) => p.id === 'plane-starter')!.fuel).toBeCloseTo( expect(landed.planes.find((p) => p.id === 'plane-starter')!.fuel).toBeCloseTo(
fuelAtWeighted(target, entry.burnPerKm * windBurnFactor(wHub.windKt, wHub.windDirDeg, initialBearingDeg(nearest.hubLat, nearest.hubLon, nearest.lat, nearest.lon)) * conditionBurnMultiplier(starter.health), entry.emptyWeightKg, entry.maxFuel, km, 1), fuelAtWeighted(target, entry.burnPerKm * windBurnFactor(wHub.windKt, wHub.windDirDeg, initialBearingDeg(nearest.hubLat, nearest.hubLon, nearest.lat, nearest.lon)), entry.emptyWeightKg, entry.maxFuel, km, 1),
6, 6,
); );
@@ -479,11 +475,11 @@ test('Planes market tab lists all plane types with tech specs', async ({ page })
await page.getByTestId('tab-planes-market').click(); 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). // ATR card: name + guaranteed full-tank range (worst-case headwind) from lib/sim/movement.ts (v0.51.0).
const atr = body.catalog.find((e) => e.model === 'atr72')!; 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'), 'the ATR card appears').toBeVisible({ timeout: 15_000 });
await expect(page.getByTestId('plane-type-atr72')).toContainText(atr.label); await expect(page.getByTestId('plane-type-atr72')).toContainText(atr.label);
const atrRange = Math.round(maxRangeKm(atr.burnPerKm, atr.emptyWeightKg, atr.maxFuel)); const atrRange = Math.round(guaranteedRangeKm(atr.burnPerKm, atr.emptyWeightKg, atr.maxFuel));
await expect(page.getByTestId('plane-type-range-atr72')).toContainText(`${atrRange} km`); 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. // Ladder sanity: the most expensive type (Boeing 777-300ER) is present with its €9M price.
+123 -103
View File
@@ -1,157 +1,177 @@
import { test, expect, type Page } from '@playwright/test'; import { test, expect, type Page } from '@playwright/test';
// v0.7.0 sim engine against the live origin: tick-on-read clock advance at a // v0.52.0 (ISS-117) fixed world clock against the live origin: idle-pause freezing a grounded
// raised time scale, pause freezing, package expiry (via the FLIGHT_TEST_HOOKS // fleet, background-tick advance at a raised rate while something is active, package expiry via
// hook enabled in docker-compose.yml), and market top-up. // the FLIGHT_TEST_HOOKS hooks (enabled in docker-compose.yml), and market top-up.
// Must match lib/sim/engine.ts — kept in sync manually (same pattern as MIN_ZOOM/MAX_ZOOM in map.spec.ts). // Must match lib/sim/engine.ts — kept in sync manually (same pattern as MIN_ZOOM/MAX_ZOOM in map.spec.ts).
const MAX_TICK_ELAPSED_MS = 2_000; const MAX_TICK_ELAPSED_MS = 2_000;
const SCALE = 1000; // fastest sim speed — top of the v0.14.0 ladder const SCALE = 1000; // test rate for fast-forwarding legs
const WORLD = 24; // the fixed world rate (ISS-117) — must match lib/worldClock.ts
const MAX_DELTA_PER_TICK = MAX_TICK_ELAPSED_MS * SCALE; // 2_000_000 game ms per tick at scale 1000 const MAX_DELTA_PER_TICK = MAX_TICK_ELAPSED_MS * SCALE; // 2_000_000 game ms per tick at scale 1000
// Must match MARKET_TARGET_OPEN_PACKAGES in lib/game/seed.ts. // Must match MARKET_TARGET_OPEN_PACKAGES in lib/game/seed.ts.
const MARKET_TARGET = 10; const MARKET_TARGET = 10;
const STARTER = 'plane-starter';
interface SimState {
clockMs: number;
timeScale: number;
paused: boolean;
}
interface StateBody { interface StateBody {
account: { cash: number; reputation: number }; account: { cash: number; reputation: number };
simState: SimState; simState: { clockMs: number }; // ISS-117: the snapshot carries only the game clock
planes: Array<Record<string, unknown>>; planes: Array<{ id: string; status: string }>;
packages: Array<{ id: string; status: string; deadlineSimTime: number }>; packages: Array<{ id: string; status: string; deadlineSimTime: number }>;
} }
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
async function postSim(page: Page, body: unknown) {
return page.request.post('/api/sim', { data: body });
}
async function getState(page: Page): Promise<StateBody> { async function getState(page: Page): Promise<StateBody> {
const res = await page.request.get('/api/state'); const res = await page.request.get('/api/state');
expect(res.status()).toBe(200); expect(res.status()).toBe(200);
return (await res.json()) as StateBody; return (await res.json()) as StateBody;
} }
// Leave the live instance in a sane state for the other specs (scale 1, running). // Test hook (FLIGHT_TEST_HOOKS=1): set the world rate and VERIFY from the response that it stuck.
test.afterEach(async ({ page }) => { async function setScale(page: Page, scale: number): Promise<void> {
const res = await postSim(page, { timeScale: 1, paused: false }); const res = await page.request.post('/api/test/set-timescale', { data: { timeScale: scale } });
expect(res.status()).toBe(200); expect(res.status()).toBe(200);
const body = (await res.json()) as { timeScale: number };
expect(body.timeScale, `timeScale should be ${scale} after POST /api/test/set-timescale`).toBe(scale);
}
async function setHealth(page: Page, planeId: string, fields: Record<string, unknown>): Promise<void> {
const res = await page.request.post('/api/test/set-health', { data: { planeId, ...fields } });
expect(res.status()).toBe(200);
}
// Idle-pause (ISS-117) freezes a fully grounded fleet — put the starter in repair so the clock
// advances for tests that need it. The €1.5k repair cost is irrelevant to every spec's relative
// cash assertions; deactivateWorld restores full health and no gate afterwards.
async function activateWorld(page: Page): Promise<void> {
await setHealth(page, STARTER, { health: 50 });
expect((await page.request.post(`/api/planes/${STARTER}/repair`)).status()).toBe(200);
}
// Shorten the repair gate to "now" — the next advancing tick flips the plane back to available.
async function deactivateWorld(page: Page): Promise<void> {
await setHealth(page, 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 starter to flip back to available',
})
.toBe('available');
}
// Leave the live instance in a sane state for the other specs (the fixed world rate).
test.afterEach(async ({ page }) => {
await setScale(page, WORLD);
}); });
test('timeScale 1000 advances the clock by roughly real-time × 1000 between polls', async ({ page }) => { test('a fully grounded fleet freezes the clock exactly across polls (idle-pause)', async ({ page }) => {
const post = await postSim(page, { timeScale: SCALE }); const s = await getState(page);
expect(post.status()).toBe(200); expect(s.planes.every((p) => p.status === 'available'), 'fleet should be grounded at spec start').toBe(true);
const s1 = (await post.json()) as SimState; const frozen = s.simState.clockMs;
expect(s1.timeScale).toBe(SCALE);
expect(s1.paused).toBe(false);
let prev = s1.clockMs;
for (let i = 0; i < 3; i++) { for (let i = 0; i < 3; i++) {
await sleep(1_000); // one real second ≈ 1000 game seconds at scale 1000 — well under the clamp await sleep(500);
const body = await getState(page); expect((await getState(page)).simState.clockMs, 'clock must not advance while the fleet is idle').toBe(frozen);
const now = body.simState.clockMs;
expect(now).toBeGreaterThan(prev); // strictly advances while actively polled
expect(now - prev).toBeGreaterThanOrEqual(500_000); // ≥ ~500 ms real × 1000
expect(now - prev).toBeLessThanOrEqual(MAX_DELTA_PER_TICK + 60); // clamp bounds each tick
prev = now;
} }
}); });
test('paused freezes the clock exactly across polls', async ({ page }) => { test('timeScale 1000 advances the clock by roughly real-time × 1000 between polls', async ({ page }) => {
const post = await postSim(page, { timeScale: SCALE, paused: true }); await activateWorld(page);
expect(post.status()).toBe(200); try {
const s1 = (await post.json()) as SimState; await setScale(page, SCALE);
expect(s1.paused).toBe(true);
for (let i = 0; i < 3; i++) { let prev = (await getState(page)).simState.clockMs;
await sleep(500); for (let i = 0; i < 3; i++) {
const body = await getState(page); await sleep(1_000); // one real second ≈ 1000 game seconds at scale 1000 — well under the clamp
expect(body.simState.clockMs).toBe(s1.clockMs); // frozen — no advance while paused const now = (await getState(page)).simState.clockMs;
expect(now).toBeGreaterThan(prev); // strictly advances while something is active
expect(now - prev).toBeGreaterThanOrEqual(500_000); // ≥ ~500 ms real × 1000
expect(now - prev).toBeLessThanOrEqual(MAX_DELTA_PER_TICK + 60); // clamp bounds each tick
prev = now;
}
} finally {
await deactivateWorld(page);
} }
}); });
test('an open package past its short deadline expires and the market stays topped up', async ({ page }) => { test('an open package past its short deadline expires and the market stays topped up', async ({ page }) => {
test.setTimeout(60_000); test.setTimeout(60_000);
await activateWorld(page);
try {
const before = await getState(page);
const victim = before.packages.find((p) => p.status === 'open');
expect(victim).toBeDefined(); // steady state always has open packages
const before = await getState(page); // Test-only hook (FLIGHT_TEST_HOOKS=1 in docker-compose): move an existing open
const victim = before.packages.find((p) => p.status === 'open'); // package's deadline ~10 game-seconds out so expiry is observable at scale 1000.
expect(victim).toBeDefined(); // steady state always has open packages const hook = await page.request.post('/api/test/expire-package', { data: { packageId: victim!.id } });
expect(hook.status()).toBe(200);
const moved = (await hook.json()) as { id: string; deadlineSimTime: number };
expect(moved.id).toBe(victim!.id);
// Test-only hook (FLIGHT_TEST_HOOKS=1 in docker-compose): move an existing open await setScale(page, SCALE);
// package's deadline ~10 game-seconds out so expiry is observable at scale 1000.
const hook = await page.request.post('/api/test/expire-package', { data: { packageId: victim!.id } });
expect(hook.status()).toBe(200);
const moved = (await hook.json()) as { id: string; deadlineSimTime: number };
expect(moved.id).toBe(victim!.id);
await postSim(page, { timeScale: SCALE }); let expiredBody: StateBody | undefined;
await expect
.poll(async () => {
const body = await getState(page);
if (body.packages.find((p) => p.id === moved.id)?.status === 'expired') expiredBody = body;
return !!expiredBody;
}, { timeout: 30_000, message: 'waiting for the short-deadline package to expire' })
.toBe(true);
let expiredBody: StateBody | undefined; const after = expiredBody!;
await expect expect(after.packages.find((p) => p.id === moved.id)?.status).toBe('expired');
.poll(async () => { // Expired packages leave the available set…
const body = await getState(page); expect(after.packages.filter((p) => p.status === 'open').some((c) => c.id === moved.id)).toBe(false);
if (body.packages.find((p) => p.id === moved.id)?.status === 'expired') expiredBody = body; // …and spawn tops the market back up to the target — never below it.
return !!expiredBody; const openCount = after.packages.filter((p) => p.status === 'open').length;
}, { timeout: 30_000, message: 'waiting for the short-deadline package to expire' }) expect(openCount).toBeGreaterThanOrEqual(MARKET_TARGET);
.toBe(true); } finally {
await deactivateWorld(page);
const after = expiredBody!; }
expect(after.packages.find((p) => p.id === moved.id)?.status).toBe('expired');
// Expired packages leave the available set…
expect(after.packages.filter((p) => p.status === 'open').some((c) => c.id === moved.id)).toBe(false);
// …and spawn tops the market back up to the target — never below it.
const openCount = after.packages.filter((p) => p.status === 'open').length;
expect(openCount).toBeGreaterThanOrEqual(MARKET_TARGET);
}); });
test('the hook can also create a fresh short-deadline package, which then expires', async ({ page }) => { test('the hook can also create a fresh short-deadline package, which then expires', async ({ page }) => {
test.setTimeout(60_000); test.setTimeout(60_000);
await activateWorld(page);
try {
const created = await page.request.post('/api/test/expire-package');
expect(created.status()).toBe(201);
const c = (await created.json()) as { id: string; status: string };
expect(c.status).toBe('open');
const created = await page.request.post('/api/test/expire-package'); await setScale(page, SCALE);
expect(created.status()).toBe(201);
const c = (await created.json()) as { id: string; status: string };
expect(c.status).toBe('open');
await postSim(page, { timeScale: SCALE }); let expiredBody: StateBody | undefined;
await expect
.poll(async () => {
const body = await getState(page);
if (body.packages.find((p) => p.id === c.id)?.status === 'expired') expiredBody = body;
return !!expiredBody;
}, { timeout: 30_000, message: 'waiting for the hook-created package to expire' })
.toBe(true);
let expiredBody: StateBody | undefined; const openCount = expiredBody!.packages.filter((p) => p.status === 'open').length;
await expect expect(openCount).toBeGreaterThanOrEqual(MARKET_TARGET); // top-up held after this expiry too
.poll(async () => { } finally {
const body = await getState(page); await deactivateWorld(page);
if (body.packages.find((p) => p.id === c.id)?.status === 'expired') expiredBody = body; }
return !!expiredBody;
}, { timeout: 30_000, message: 'waiting for the hook-created package to expire' })
.toBe(true);
const openCount = expiredBody!.packages.filter((p) => p.status === 'open').length;
expect(openCount).toBeGreaterThanOrEqual(MARKET_TARGET); // top-up held after this expiry too
}); });
test('POST /api/sim validates and clamps its input', async ({ page }) => { test('POST /api/test/set-timescale validates its input', async ({ page }) => {
const clampHigh = await postSim(page, { timeScale: 2000 }); const ok = await page.request.post('/api/test/set-timescale', { data: { timeScale: 500 } });
expect(clampHigh.status()).toBe(200); expect(ok.status()).toBe(200);
expect(((await clampHigh.json()) as SimState).timeScale).toBe(1000); // top of the ladder (v0.14.0) expect(((await ok.json()) as { timeScale: number }).timeScale).toBe(500); // echoed back for assertions
const inRange = await postSim(page, { timeScale: 500 }); for (const body of [{ timeScale: 'fast' }, { timeScale: 1e9 }, { bogus: 1 }]) {
expect(inRange.status()).toBe(200); const res = await page.request.post('/api/test/set-timescale', { data: body });
expect(((await inRange.json()) as SimState).timeScale).toBe(500); // ladder value passes through unclamped
const clampLow = await postSim(page, { timeScale: 0.1 });
expect(clampLow.status()).toBe(200);
expect(((await clampLow.json()) as SimState).timeScale).toBe(1);
for (const body of [{ timeScale: 'fast' }, { paused: 'yes' }, { bogus: 1 }]) {
const res = await postSim(page, body);
expect(res.status()).toBe(400); expect(res.status()).toBe(400);
} }
const notJson = await page.request.post('/api/sim', { data: 'nope{' }); const notJson = await page.request.post('/api/test/set-timescale', { data: 'nope{' });
expect(notJson.status()).toBe(400); expect(notJson.status()).toBe(400);
// Empty body is a valid no-op (just ticks + returns current state). // Empty body is a 400 — the hook requires an explicit rate (unlike the old /api/sim no-op).
const empty = await postSim(page, {}); const empty = await page.request.post('/api/test/set-timescale', { data: {} });
expect(empty.status()).toBe(200); expect(empty.status()).toBe(400);
}); });
-141
View File
@@ -1,141 +0,0 @@
import type { Page } from '@playwright/test';
import { expect, test } from '@playwright/test';
// v0.30.0 — ISS-057: when a landing leaves any plane on the ground the sim slows to 1× server-side
// WITHOUT pausing (pause is manual-only now), and starting a flight (POST /api/planes/{id}/fly)
// restores the last MANUALLY selected speed once no plane remains grounded. The cycle: dispatch at
// 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 HUB = 'EDDF'; // Frankfurt Main — the seeded starter plane's home base (lib/game/seed.ts)
interface SimState {
clockMs: number;
timeScale: number;
paused: boolean;
}
interface StateBody {
simState: SimState;
planes: Array<{ id: string; status: string; locationIcao: string; destIcao: string | null }>;
}
async function getState(page: Page): Promise<StateBody> {
const res = await page.request.get('/api/state');
expect(res.ok()).toBeTruthy();
return (await res.json()) as StateBody;
}
async function postSim(page: Page, body: Record<string, unknown>): Promise<void> {
const res = await page.request.post('/api/sim', { data: body });
expect(res.status()).toBe(200);
}
// 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.
// 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> {
const res = await page.request.post('/api/sim', { data: { timeScale: scale, paused: false } });
expect(res.status()).toBe(200);
const body = (await res.json()) as SimState;
expect(body.timeScale, `timeScale should be ${scale} after POST /api/sim`).toBe(scale);
}
// Closest airport to the hub from /api/airports → shortest possible leg (fastest e2e flight).
async function nearestToHub(page: Page): Promise<{ icao: string }> {
const res = await page.request.get('/api/airports');
expect(res.ok()).toBeTruthy();
const fc = (await res.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;
const d = Math.hypot(f.properties.lat - hub.lat, f.properties.lon - hub.lon);
const dn = Math.hypot(nearest.lat - hub.lat, nearest.lon - hub.lon);
if (d < dn) nearest = f.properties;
}
return { icao: nearest.icao };
}
// Self-heal helper (same pattern as e2e/relocation.spec.ts): let any in-flight leg finish and
// reposition plane-starter home via the API so every test starts from the canonical state even
// after an interrupted previous 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') {
// Let the in-flight leg finish before repositioning. While flying, locationIcao stays at the
// DEPARTURE airport — re-read state after landing before deciding whether a repositioning
// flight is needed.
await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')!.status), { timeout: 240_000 }).toBe('available');
s = await getState(page);
plane = s.planes.find((p) => p.id === 'plane-starter')!;
}
if (plane.locationIcao !== HUB) {
// ISS-057: the landing above slowed the sim to 1× — setScale restores 1000× (and records it as
// lastSelectedTimeScale) so the return leg fast-forwards instead of taking real-time hours.
await setScale(page, SCALE);
// ISS-082: dispatch auto-refuels, so a grounded plane can always start any full-tank-flyable
// leg when cash allows — no top-up retry needed.
const 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);
}
}
// Self-heal in CLEANUP (afterEach): the test deliberately leaves plane-starter at the far end, so
// fast-forward + reposition home here; then restore scale=1 / unpaused for later specs.
test.afterEach(async ({ page }) => {
await flyHomeViaApi(page);
await postSim(page, { timeScale: 1, paused: false });
});
test.describe.configure({ mode: 'serial' }); // shared live DB — order matters within this file
test('landing slows the sim to 1× without pausing; dispatch restores the last selected speed (ISS-057)', async ({ page }) => {
test.setTimeout(300_000); // self-heal (worst case ~20 s at 1000×) + two short legs
await flyHomeViaApi(page); // deterministic: the starter sits grounded at EDDF
const nearest = await nearestToHub(page);
// Set 1000× via the API and verify it stuck (also records lastSelectedTimeScale=1000).
await setScale(page, SCALE);
// Dispatch plane-starter on the shortest leg from its hub (repositioning — no package needed).
const flyRes = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: nearest.icao } });
expect(flyRes.status()).toBe(200);
// Wait for the landing…
let landedBody: StateBody | undefined;
await expect
.poll(async () => {
const b = await getState(page);
const p = b.planes.find((x) => x.id === 'plane-starter')!;
if (p.status === 'available' && p.locationIcao === nearest.icao) landedBody = b;
return !!landedBody;
}, { timeout: 120_000, message: `waiting for plane-starter to land at ${nearest.icao}` })
.toBe(true);
// …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);
// A subsequent /fly dispatch restores the last selected speed — asserted on the endpoint's OWN
// full post-action snapshot, so no poll race with the client's next tick. setScale posted 1000
// above, so lastSelectedTimeScale is 1000 and this dispatch (sole grounded plane → airborne)
// resumes it.
const fly2 = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
expect(fly2.status()).toBe(200);
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');
});
+62 -13
View File
@@ -10,7 +10,7 @@ const composeArgs = process.env.E2E_COMPOSE_ARGS ?? '';
interface StateBody { interface StateBody {
account: { cash: number; reputation: number }; account: { cash: number; reputation: number };
simState: { clockMs: number; timeScale: number; paused: boolean }; simState: { clockMs: number }; // ISS-117: the snapshot carries only the game clock
planes: Array<Record<string, unknown>>; planes: Array<Record<string, unknown>>;
packages: Array<Record<string, unknown>>; packages: Array<Record<string, unknown>>;
} }
@@ -24,9 +24,7 @@ test('GET /api/state returns a seeded snapshot', async ({ page }) => {
expect(typeof body.account.cash).toBe('number'); expect(typeof body.account.cash).toBe('number');
expect(typeof body.account.reputation).toBe('number'); expect(typeof body.account.reputation).toBe('number');
expect(typeof body.simState.clockMs).toBe('number'); expect(typeof body.simState.clockMs).toBe('number'); // ISS-117: time controls are gone — clock only
expect(typeof body.simState.timeScale).toBe('number');
expect(body.simState.paused).toBe(false);
expect(Array.isArray(body.planes)).toBe(true); expect(Array.isArray(body.planes)).toBe(true);
expect(body.planes.length).toBeGreaterThanOrEqual(1); expect(body.planes.length).toBeGreaterThanOrEqual(1);
@@ -44,6 +42,17 @@ test('GET /api/state returns a seeded snapshot', async ({ page }) => {
test('state survives a container restart (sim clock + account persisted)', async ({ page }) => { test('state survives a container restart (sim clock + account persisted)', async ({ page }) => {
test.setTimeout(180_000); test.setTimeout(180_000);
// Self-heal to a fully grounded fleet first — idle-pause (ISS-117) then freezes the clock, so the
// post-restart snapshot must match byte-for-byte instead of within one tick bound.
await expect
.poll(async () => {
const res = await page.request.get('/api/state');
if (res.status() !== 200) return 'down';
const body = (await res.json()) as StateBody;
return body.planes.every((p) => p.status === 'available') ? 'idle' : 'busy';
}, { timeout: 120_000, message: 'waiting for the fleet to be fully grounded' })
.toBe('idle');
const resBefore = await page.request.get('/api/state'); const resBefore = await page.request.get('/api/state');
expect(resBefore.status()).toBe(200); expect(resBefore.status()).toBe(200);
const before = (await resBefore.json()) as StateBody; const before = (await resBefore.json()) as StateBody;
@@ -68,17 +77,57 @@ test('state survives a container restart (sim clock + account persisted)', async
const a = after!; const a = after!;
// The DB file on the volume must be loaded, not re-seeded (PLAN.md: "sim clock // The DB file on the volume must be loaded, not re-seeded (PLAN.md: "sim clock
// persisted across sessions"): account and entity ids are byte-identical. // persisted across sessions"): account and entity ids are byte-identical. With the fleet
// v0.7.0 tick-on-read caveat: every GET advances the clock by at most one // grounded above, idle-pause keeps the clock frozen across the restart — exact equality.
// clamped tick (MAX_TICK_ELAPSED_MS × timeScale, see lib/sim/engine.ts), so the expect(a.simState.clockMs).toBe(before.simState.clockMs);
// post-restart clock may be ahead — but never by more than that bound.
expect(a.simState.clockMs).toBeGreaterThanOrEqual(before.simState.clockMs);
const MAX_TICK_ELAPSED_MS = 2_000; // must match lib/sim/engine.ts
expect(a.simState.clockMs - before.simState.clockMs).toBeLessThanOrEqual(
MAX_TICK_ELAPSED_MS * a.simState.timeScale,
);
expect(a.account.cash).toBe(before.account.cash); expect(a.account.cash).toBe(before.account.cash);
expect(a.account.reputation).toBe(before.account.reputation); expect(a.account.reputation).toBe(before.account.reputation);
expect(a.planes.map((p) => p.id).sort()).toEqual(before.planes.map((p) => p.id).sort()); expect(a.planes.map((p) => p.id).sort()).toEqual(before.planes.map((p) => p.id).sort());
expect(a.packages.map((p) => p.id).sort()).toEqual(before.packages.map((p) => p.id).sort()); expect(a.packages.map((p) => p.id).sort()).toEqual(before.packages.map((p) => p.id).sort());
}); });
test('queued legs survive a container restart (v0.46.0)', async ({ page }) => {
test.setTimeout(180_000);
// Self-heal: the suite runs serially and every spec leaves plane-starter grounded, but an
// interrupted run may not — wait it out before staging a queue (queueLeg works while flying too).
await expect
.poll(async () => {
const res = await page.request.get('/api/state');
if (res.status() !== 200) return 'down';
const body = (await res.json()) as StateBody;
return String(body.planes.find((p) => p.id === 'plane-starter')?.status ?? 'missing');
}, { timeout: 120_000, message: 'waiting for plane-starter to be grounded' })
.toBe('available');
// Stage a two-leg queue on the starter…
const q1 = await page.request.post('/api/planes/plane-starter/queue', { data: { destIcao: 'LEMD' } });
expect(q1.status()).toBe(200);
const q2 = await page.request.post('/api/planes/plane-starter/queue', { data: { destIcao: 'EHAM' } });
expect(q2.status()).toBe(200);
execSync(`docker compose ${composeArgs} restart flight`, { cwd: repoRoot, stdio: 'pipe' });
// Wait for the app to come back, capturing the post-restart snapshot.
let after: StateBody | undefined;
await expect
.poll(async () => {
try {
const res = await page.request.get('/api/state');
if (res.status() === 200) after = (await res.json()) as StateBody;
return !!after;
} catch {
return false; // connection refused / 502 while the container starts
}
}, { timeout: 120_000, message: 'waiting for /api/state after docker compose restart' })
.toBe(true);
const p = after!.planes.find((x) => x.id === 'plane-starter')!;
expect(p.queue).toEqual(['LEMD', 'EHAM']); // the queue persisted byte-identical, in order…
// Cleanup: cancel both legs so the starter is left with an empty queue.
for (let i = 0; i < 2; i++) {
const c = await page.request.post('/api/planes/plane-starter/queue/cancel', { data: { index: 0 } });
expect(c.status()).toBe(200);
}
});
+33 -47
View File
@@ -10,14 +10,15 @@ import { windBurnFactor } from '../lib/sim/movement';
// if both planes are on the same airport") was UX-only: LoadControls' multi-plane path rendered a // 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 // 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 // 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 // spec covers every two-plane flow: load (preselect + picker), parallel flights, relocate,
// slow/resume semantics, relocate, per-plane queues (scoped testids) and sell. // per-plane queues (scoped testids) and sell.
// //
// Each test buys its own second plane via the UI (after a cash grant from the new // 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 // 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. // 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 SCALE = 1000; // test rate for fast-forwarding legs — keeps flights to seconds of real time
const WORLD = 24; // the fixed world rate (ISS-117) — must match lib/worldClock.ts
const HUB = 'EDDF'; // must match HUB_ICAO in lib/game/seed.ts 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 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 const BUY_MODEL = 'atr72'; // cheapest catalog entry — the second plane every test buys
@@ -54,7 +55,7 @@ interface CatalogEntry {
interface StateBody { interface StateBody {
account: { cash: number; reputation: number }; account: { cash: number; reputation: number };
simState: { clockMs: number; timeScale: number; paused: boolean }; simState: { clockMs: number }; // ISS-117: the snapshot carries only the game clock
planes: PlaneState[]; planes: PlaneState[];
packages: PackageState[]; packages: PackageState[];
catalog: CatalogEntry[]; catalog: CatalogEntry[];
@@ -71,18 +72,12 @@ async function getState(page: Page): Promise<StateBody> {
return (await res.json()) as StateBody; return (await res.json()) as StateBody;
} }
async function postSim(page: Page, body: unknown) { // Test hook (FLIGHT_TEST_HOOKS=1): set the world rate and VERIFY from the response that it stuck.
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> { async function setScale(page: Page, scale: number): Promise<void> {
const res = await page.request.post('/api/sim', { data: { timeScale: scale, paused: false } }); const res = await page.request.post('/api/test/set-timescale', { data: { timeScale: scale } });
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/test/set-timescale`).toBe(scale);
} }
// Mirrors haversineKm in lib/geo.ts (same formula → identical floats). // Mirrors haversineKm in lib/geo.ts (same formula → identical floats).
@@ -230,17 +225,14 @@ async function healPlaneHome(page: Page, planeId: string): Promise<void> {
await clearQueueViaApi(page, planeId); await clearQueueViaApi(page, planeId);
let p = (await getState(page)).planes.find((x) => x.id === planeId); let p = (await getState(page)).planes.find((x) => x.id === planeId);
if (!p) return; // already sold — nothing to heal 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 // Make sure the world rate is at SCALE so any in-flight leg completes fast.
// completes fast. Re-applied inside the poll below, since a sibling's landing mid-wait slows it again.
await setScale(page, SCALE); await setScale(page, SCALE);
if (p.status === 'flying') { if (p.status === 'flying') {
// While flying, locationIcao stays at the DEPARTURE airport — re-read state after landing. // While flying, locationIcao stays at the DEPARTURE airport — re-read state after landing.
await expect await expect
.poll(async () => { .poll(async () => {
const s = await getState(page); const s = await getState(page);
const plane = s.planes.find((x) => x.id === planeId); return s.planes.find((x) => x.id === planeId)?.status ?? '';
if (plane?.status === 'flying' && s.simState.timeScale !== SCALE) await setScale(page, SCALE); // sibling landing re-slowed to 1×
return plane?.status ?? '';
}, { }, {
timeout: 240_000, timeout: 240_000,
message: `waiting for an in-flight ${planeId} to land`, message: `waiting for an in-flight ${planeId} to land`,
@@ -249,9 +241,8 @@ async function healPlaneHome(page: Page, planeId: string): Promise<void> {
p = (await getState(page)).planes.find((x) => x.id === planeId)!; p = (await getState(page)).planes.find((x) => x.id === planeId)!;
} }
if (p.locationIcao !== HUB) { if (p.locationIcao !== HUB) {
// ISS-057: the landing above slowed the sim to 1× — setScale restores 1000× for the return leg. // Make sure the world rate is back at SCALE for the return leg. ISS-082: dispatch auto-refuels,
// ISS-082: dispatch auto-refuels, so a grounded plane can always start any full-tank-flyable // so a grounded plane can always start any full-tank-flyable leg when cash allows — no top-up retry needed.
// leg when cash allows — no top-up retry needed.
await setScale(page, SCALE); await setScale(page, SCALE);
const flyHome = await page.request.post(`/api/planes/${planeId}/fly`, { data: { destIcao: HUB } }); const flyHome = await page.request.post(`/api/planes/${planeId}/fly`, { data: { destIcao: HUB } });
expect(flyHome.status()).toBe(200); expect(flyHome.status()).toBe(200);
@@ -295,8 +286,7 @@ test.beforeEach(async ({ page }) => {
test.afterEach(async ({ page }) => { test.afterEach(async ({ page }) => {
await sellNonStarters(page); // a failure mid-test may have left the second plane behind (even in flight) 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 }); await setScale(page, WORLD);
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('two grounded planes: Load is one-click with a preselected plane; the picker routes to the other', async ({ page }) => {
@@ -348,7 +338,7 @@ test('two grounded planes: Load is one-click with a preselected plane; the picke
await expect(page.getByTestId(`plane-${secondId}`), 'the second plane 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('two planes fly in parallel and both deliver their packages', async ({ page }) => {
test.setTimeout(300_000); // self-heal + one long leg each way at scale 1000 test.setTimeout(300_000); // self-heal + one long leg each way at scale 1000
const secondId = await buySecondPlaneViaUi(page); const secondId = await buySecondPlaneViaUi(page);
@@ -358,7 +348,7 @@ test('two planes fly in parallel; the last landing slows to 1× and the next dis
// Two very different leg lengths: the starter takes the SHORTEST hop, the second plane the longest // 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 // 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). // the same tick (a shared-tick arrival would race the first-landing assert).
const [nearest] = await twoNearestToHub(page); const [nearest] = await twoNearestToHub(page);
const farthest = await farthestFlyableToHub(page); const farthest = await farthestFlyableToHub(page);
@@ -388,8 +378,6 @@ test('two planes fly in parallel; the last landing slows to 1× and the next dis
}) })
.toBe(true); .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 await expect
.poll(async () => ((await getState(page)).planes.every((p) => p.status === 'available')), { .poll(async () => ((await getState(page)).planes.every((p) => p.status === 'available')), {
timeout: 240_000, timeout: 240_000,
@@ -397,10 +385,7 @@ test('two planes fly in parallel; the last landing slows to 1× and the next dis
}) })
.toBe(true); .toBe(true);
// ISS-057: the LAST landing left a grounded plane → the sim slowed to 1× WITHOUT pausing.
const landed = await getState(page); 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). // 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 === pkgA.id)?.status).toBe('delivered');
expect(landed.packages.find((p) => p.id === pkgB.id)?.status).toBe('delivered'); expect(landed.packages.find((p) => p.id === pkgB.id)?.status).toBe('delivered');
@@ -408,17 +393,9 @@ test('two planes fly in parallel; the last landing slows to 1× and the next dis
// The outbound to the range edge left the tank nearly empty — top it back up (ISS-082 hook). // The outbound to the range edge left the tank nearly empty — top it back up (ISS-082 hook).
await setFuelFull(page, secondId); await setFuelFull(page, secondId);
// Resume semantics: with the starter still grounded, dispatching the second plane does NOT resume… // Cleanup: fly both home so the live instance stays in its onboarding state for later specs.
const flyB2 = await page.request.post(`/api/planes/${secondId}/fly`, { data: { destIcao: HUB } }); expect((await page.request.post(`/api/planes/${secondId}/fly`, { data: { destIcao: HUB } })).status()).toBe(200);
expect(flyB2.status()).toBe(200); expect((await page.request.post(`/api/planes/${STARTER}/fly`, { data: { destIcao: HUB } })).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
}); });
// ISS-082 (v0.42.0): the "refuel section lists both planes" test is gone with the manual refuel UI — // ISS-082 (v0.42.0): the "refuel section lists both planes" test is gone with the manual refuel UI —
@@ -495,21 +472,30 @@ test('per-plane queues are independent; landing auto-chains the next queued leg'
await expect(page.getByTestId(`queue-leg-${secondId}-0`), 'the cancelled leg is gone').toHaveCount(0, { timeout: 15_000 }); 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); await expect(starterLeg0, 'the starter queue is untouched by the other plane cancel').toContainText(a.icao);
// Fly the STARTER to its first queued destination… // Fly the STARTER to its first queued destination — v0.46.0 consumes queue[0] on dispatch, so the
// response snapshot must already show only [b] left (previously the leg stayed queued and the
// landing chained a zero-distance self-hop a→a before reaching b).
const flyRes = await page.request.post(`/api/planes/${STARTER}/fly`, { data: { destIcao: a.icao } }); const flyRes = await page.request.post(`/api/planes/${STARTER}/fly`, { data: { destIcao: a.icao } });
expect(flyRes.status()).toBe(200); expect(flyRes.status()).toBe(200);
const flyBody = (await flyRes.json()) as StateBody;
expect(flyBody.planes.find((x) => x.id === STARTER)!.queue, 'queue[0] consumed on dispatch').toEqual([b.icao]);
// …fast-forward; on landing at a the server AUTO-CHAINS queue[0] (b) in the same tick — but with // …the outbound leg flies at the fixed world rate (24×) — slow enough that polls can observe the
// the second plane still grounded, ISS-057 slows the sim to 1× on that landing. Wait for the chain… // landing and the server's AUTO-CHAIN of queue[0] (b) in the same tick. Wait for the chain…
await setScale(page, SCALE); const seen: Array<{ locationIcao: string; destIcao: string | null }> = [];
await expect await expect
.poll(async () => { .poll(async () => {
const p = (await getState(page)).planes.find((x) => x.id === STARTER); const p = (await getState(page)).planes.find((x) => x.id === STARTER);
if (p && p.status === 'flying') seen.push({ locationIcao: p.locationIcao, destIcao: p.destIcao });
return !!p && p.status === 'flying' && p.destIcao === b.icao; return !!p && p.status === 'flying' && p.destIcao === b.icao;
}, { timeout: 240_000, message: `waiting for the auto-chained leg ${a} → ${b} to start` }) }, { timeout: 120_000, message: `waiting for the auto-chained leg ${a} → ${b} to start` })
.toBe(true); .toBe(true);
// …then restore speed so the chained leg fast-forwards too. // …and NO snapshot may show the phantom self-hop (location a with dest a) — every observed state
// is either the outbound leg (HUB→a) or the chained leg (a→b).
expect(seen.filter((s) => s.locationIcao === a.icao && s.destIcao === a.icao), 'phantom self-hop a → a').toEqual([]);
// …fast-forward the chained leg to its landing.
await setScale(page, SCALE); await setScale(page, SCALE);
await expect await expect
.poll(async () => { .poll(async () => {
+9
View File
@@ -0,0 +1,9 @@
import { startBackgroundTick } from '@/lib/sim/engine';
// ISS-117 (v0.52.0): the world clock advances on a ~1 s background tick even with nobody polling —
// started once at server boot (Next ≥15 instrumentation hook; runs under `next dev` and `next start`).
export function register() {
if (process.env.NEXT_RUNTIME === 'nodejs') {
startBackgroundTick();
}
}
+5 -2
View File
@@ -3,6 +3,8 @@ import os from 'node:os';
import path from 'node:path'; import path from 'node:path';
import initSqlJs from 'sql.js'; import initSqlJs from 'sql.js';
import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { WORLD_TIME_SCALE } from '@/lib/worldClock';
import { STARTING_CLOCK_MS } from '@/lib/game/seed';
import { openGameDb, persist } from './client'; import { openGameDb, persist } from './client';
import * as schema from './schema'; import * as schema from './schema';
@@ -111,8 +113,9 @@ describe('openGameDb', () => {
const account = (await inst.db.select().from(schema.account))[0]; const account = (await inst.db.select().from(schema.account))[0];
expect(account?.cash).toBeGreaterThan(0); expect(account?.cash).toBeGreaterThan(0);
const sim = (await inst.db.select().from(schema.simState))[0]; const sim = (await inst.db.select().from(schema.simState))[0];
expect(sim?.paused).toBe(false); // ISS-117: fresh worlds start at the year-2000 game clock with the fixed world rate.
expect(sim?.timeScale).toBe(1); expect(sim?.clockMs).toBe(STARTING_CLOCK_MS);
expect(sim?.timeScale).toBe(WORLD_TIME_SCALE);
// The seed was durably written: the file exists and is non-empty. // The seed was durably written: the file exists and is non-empty.
const st = await fs.stat(file); const st = await fs.stat(file);
+1
View File
@@ -165,6 +165,7 @@ async function open(file: string): Promise<GameDb> {
// First-run seed: only when the DB is empty (no account row yet). // First-run seed: only when the DB is empty (no account row yet).
const res = raw.exec('SELECT COUNT(*) FROM account'); const res = raw.exec('SELECT COUNT(*) FROM account');
if (Number(res[0]?.values?.[0]?.[0] ?? 0) === 0) { if (Number(res[0]?.values?.[0]?.[0] ?? 0) === 0) {
console.warn(`flight db: no existing world at ${file} — seeding a fresh one`);
await seedEmpty(instance); await seedEmpty(instance);
} }
+1 -1
View File
@@ -27,7 +27,7 @@ export const planes = sqliteTable('planes', {
// departureSimTime; on landing the engine auto-chains queue[0] if fuel covers it. // departureSimTime; on landing the engine auto-chains queue[0] if fuel covers it.
queue: text('queue').notNull().default('[]'), queue: text('queue').notNull().default('[]'),
// Structural health in [0, 100] (v0.39.0, ISS-069) — wears down by WEAR_PER_KM per km of // 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. // landed legs; at 0 the plane is broken and cannot fly until repaired (v0.45.0, ISS-089).
health: real('health').notNull().default(100), health: real('health').notNull().default(100),
// Sim-clock epoch ms when an active repair finishes (v0.39.0, ISS-069) — set with status // 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. // 'repairing', cleared by the engine's flip-back pass; NULL = not in repair.
+6 -5
View File
@@ -36,9 +36,10 @@ const basePackage = (id: string): PackageState => ({
reward: 1234.5, reward: 1234.5,
cargoType: 'mail', cargoType: 'mail',
cargoCount: 2, cargoCount: 2,
weightKg: 600, weightKg: 600,
special: null, // v0.37.0 (ISS-073) — no special status by default in fixtures special: null, // v0.37.0 (ISS-073) — no special status by default in fixtures
deadlineSimTime: 0, distanceKm: 1839, // v0.48.0 (ISS-095) — EDDF→LEMD great-circle distance
deadlineSimTime: 0,
status: 'open', status: 'open',
planeId: null, planeId: null,
createdSimTime: 0, createdSimTime: 0,
@@ -46,7 +47,7 @@ const basePackage = (id: string): PackageState => ({
const snapshot = (over: Partial<StateSnapshot> = {}): StateSnapshot => ({ const snapshot = (over: Partial<StateSnapshot> = {}): StateSnapshot => ({
account: { cash: 20_000, reputation: 10 }, account: { cash: 20_000, reputation: 10 },
simState: { clockMs: 0, timeScale: 1, paused: false }, simState: { clockMs: 0 },
planes: [basePlane('plane-starter')], planes: [basePlane('plane-starter')],
packages: [], packages: [],
catalog: CATALOG, catalog: CATALOG,
@@ -58,7 +59,7 @@ const snapshot = (over: Partial<StateSnapshot> = {}): StateSnapshot => ({
describe('diffSnapshots', () => { describe('diffSnapshots', () => {
it('returns [] for identical snapshots (only clock/cash drift)', () => { it('returns [] for identical snapshots (only clock/cash drift)', () => {
const prev = snapshot(); const prev = snapshot();
const next = snapshot({ account: { cash: 21_000, reputation: 11 }, simState: { clockMs: 60_000, timeScale: 1, paused: false } }); const next = snapshot({ account: { cash: 21_000, reputation: 11 }, simState: { clockMs: 60_000 } });
expect(diffSnapshots(prev, next)).toEqual([]); expect(diffSnapshots(prev, next)).toEqual([]);
}); });
+9 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { formatCargo, formatCash, formatDeadlineRelative, formatDuration, formatFuel, formatSimClock } from './format'; import { formatCargo, formatCash, formatDeadlineRelative, formatDuration, formatFuel, formatKm, 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', () => {
@@ -30,6 +30,14 @@ describe('formatCargo', () => {
}); });
}); });
describe('formatKm', () => {
it('rounds to integer km with no space before "km" (v0.48.0, ISS-095)', () => {
expect(formatKm(554)).toBe('554km');
expect(formatKm(214.6)).toBe('215km');
expect(formatKm(0)).toBe('0km');
});
});
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');
+5
View File
@@ -16,6 +16,11 @@ export function formatCargo(cargoType: string, weightKg: number): string {
return `${cargoType} ${(weightKg / 1000).toFixed(1)}t`; return `${cargoType} ${(weightKg / 1000).toFixed(1)}t`;
} }
// Leg distance for contract rows (v0.48.0, ISS-095): "554km" — integer km, no space before "km".
export function formatKm(km: number): string {
return `${Math.round(km)}km`;
}
// Special-status badge label (v0.37.0, ISS-073): "priority" → "Priority". // Special-status badge label (v0.37.0, ISS-073): "priority" → "Priority".
export function formatSpecialLabel(special: string): string { export function formatSpecialLabel(special: string): string {
return special.charAt(0).toUpperCase() + special.slice(1); return special.charAt(0).toUpperCase() + special.slice(1);
+69 -65
View File
@@ -10,7 +10,6 @@ import { haversineKm, initialBearingDeg } from '@/lib/geo';
import { weatherFor } from '@/lib/weather'; import { weatherFor } from '@/lib/weather';
import { requiredFuelFor, windBurnFactor } from '@/lib/sim/movement'; import { requiredFuelFor, windBurnFactor } from '@/lib/sim/movement';
import { import {
BROKEN_BURN_MULTIPLIER,
FUEL_DISCOUNT_PER_REP, FUEL_DISCOUNT_PER_REP,
FUEL_PRICE_PER_L, FUEL_PRICE_PER_L,
MAX_FUEL_DISCOUNT, MAX_FUEL_DISCOUNT,
@@ -22,7 +21,7 @@ import {
repRate, repRate,
SELL_FRACTION, SELL_FRACTION,
} from './catalog'; } from './catalog';
import { HUB_ICAO } from './seed'; import { HUB_ICAO, STARTING_CLOCK_MS } from './seed';
import { import {
abandonPackage, abandonPackage,
buyPlane, buyPlane,
@@ -174,7 +173,7 @@ describe('flyPlane', () => {
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');
expect(plane.destIcao).toBe('LEMD'); expect(plane.destIcao).toBe('LEMD');
expect(plane.departureSimTime).toBe(T0); // seeded clock, no tick in direct calls expect(plane.departureSimTime).toBe(STARTING_CLOCK_MS); // seeded game clock (ISS-117), no tick in direct calls
const fc = await loadAirportsGeoJson(); const fc = await loadAirportsGeoJson();
const origin = fc.features.find((f) => f.properties.icao === HUB_ICAO)!.properties; const origin = fc.features.find((f) => f.properties.icao === HUB_ICAO)!.properties;
expect(plane.lat).toBe(origin.lat); expect(plane.lat).toBe(origin.lat);
@@ -183,34 +182,6 @@ 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)
@@ -230,36 +201,28 @@ describe('flyPlane', () => {
await expectActionError(flyPlane(inst, 'plane-starter', 'ZZZZ'), 400, /unknown airport/); await expectActionError(flyPlane(inst, 'plane-starter', 'ZZZZ'), 400, /unknown airport/);
}); });
it('uses the ×1.5 broken burn for the dispatch auto-top-up when health is 0 (v0.39.0, ISS-069)', async () => { it('rejects a broken plane (health 0) with 409 and mutates nothing (v0.45.0, ISS-089)', async () => {
const inst = await freshDb('fly-broken.db'); // EDDF → LEMD const inst = await freshDb('fly-broken.db'); // EDDF → LEMD
const fc = await loadAirportsGeoJson(); const fuelBefore = PLANE_TYPES.atr72.maxFuel; // seeded full tank
const from = fc.features.find((f) => f.properties.icao === HUB_ICAO)!.properties; await inst.db.update(schema.planes).set({ health: 0 }).where(eq(schema.planes.id, 'plane-starter'));
const to = fc.features.find((f) => f.properties.icao === 'LEMD')!.properties; const cashBefore = (await inst.db.select().from(schema.account))[0].cash;
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's 150% top-up target is higher.
const healthyRequired = requiredFuelFor(type.burnPerKm * f, type.emptyWeightKg, type.maxFuel, km);
await inst.db.update(schema.planes)
.set({ health: 0, fuel: healthyRequired })
.where(eq(schema.planes.id, 'plane-starter'));
await flyPlane(inst, 'plane-starter', 'LEMD'); // auto-refuel covers the extra burn — no 409 await expectActionError(flyPlane(inst, 'plane-starter', 'LEMD'), 409, /broken \(health 0\)/);
const plane = (await inst.db.select().from(schema.planes))[0];
expect(plane.status).toBe('available'); // still grounded…
expect(plane.fuel).toBe(fuelBefore); // …no auto-refuel ran — the tank is untouched…
expect((await inst.db.select().from(schema.account))[0].cash).toBe(cashBefore); // …and no cash left the account
});
it('still flies a worn-but-not-broken plane (health > 0) with plain burn', async () => {
const inst = await freshDb('fly-worn.db'); // EDDF → LEMD
await inst.db.update(schema.planes).set({ health: 1 }).where(eq(schema.planes.id, 'plane-starter'));
await flyPlane(inst, 'plane-starter', 'LEMD'); // no 409 — worn is not broken
const brokenRequired = requiredFuelFor(type.burnPerKm * f * BROKEN_BURN_MULTIPLIER, type.emptyWeightKg, type.maxFuel, km);
expect(brokenRequired).toBeGreaterThan(healthyRequired); // sanity: the multiplier genuinely raises the requirement
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');
expect(plane.fuel).toBeCloseTo(Math.min(type.maxFuel, Math.max(healthyRequired, brokenRequired * 1.5)), 6); // topped up for the BROKEN burn…
// …while the same tank at full health tops up to the LOWER healthy target — the multiplier is what raised it.
const inst2 = await freshDb('fly-broken-healthy.db');
await inst2.db.update(schema.planes).set({ fuel: healthyRequired }).where(eq(schema.planes.id, 'plane-starter'));
await flyPlane(inst2, 'plane-starter', 'LEMD'); // must not throw
const plane2 = (await inst2.db.select().from(schema.planes))[0];
expect(plane2.fuel).toBeCloseTo(Math.min(type.maxFuel, Math.max(healthyRequired, healthyRequired * 1.5)), 6);
expect(plane.fuel).toBeGreaterThan(plane2.fuel); // the broken plane genuinely got more fuel
}); });
// --- Auto-refuel on dispatch (ISS-082, v0.42.0) --- // --- Auto-refuel on dispatch (ISS-082, v0.42.0) ---
@@ -340,6 +303,34 @@ describe('flyPlane', () => {
await expectActionError(flyPlane(inst, 'plane-starter', 'LPPD'), 409, /insufficient fuel/i); await expectActionError(flyPlane(inst, 'plane-starter', 'LPPD'), 409, /insufficient fuel/i);
}); });
// --- Queue consumption on dispatch (v0.46.0, ISS-088) ---
it('consumes queue[0] when flying to the first queued leg — no phantom self-hop on landing', async () => {
const inst = await freshDb('fly-consume.db');
await queueLeg(inst, 'plane-starter', 'LEMD');
await queueLeg(inst, 'plane-starter', 'EHAM');
await flyPlane(inst, 'plane-starter', 'LEMD'); // the first queued leg
const plane = (await inst.db.select().from(schema.planes))[0];
expect(plane.status).toBe('flying');
expect(plane.destIcao).toBe('LEMD');
expect(JSON.parse(plane.queue)).toEqual(['EHAM']); // queue[0] consumed, the rest kept in order
});
it('keeps the queue intact when skipping ahead to a non-first queued leg (explicit override)', async () => {
const inst = await freshDb('fly-skipahead.db');
await queueLeg(inst, 'plane-starter', 'LEMD');
await queueLeg(inst, 'plane-starter', 'EHAM');
await flyPlane(inst, 'plane-starter', 'EHAM'); // not the first queued leg — an override
const plane = (await inst.db.select().from(schema.planes))[0];
expect(plane.status).toBe('flying');
expect(plane.destIcao).toBe('EHAM');
expect(JSON.parse(plane.queue)).toEqual(['LEMD', 'EHAM']); // untouched — FIFO order preserved
});
}); });
describe('queueLeg (v0.25.0, ISS-043)', () => { describe('queueLeg (v0.25.0, ISS-043)', () => {
@@ -443,14 +434,14 @@ describe('cancelQueuedLeg (v0.25.0, ISS-043)', () => {
}); });
describe('abandonPackage', () => { describe('abandonPackage', () => {
// A loaded package created at T0 with a 10h window; the seeded sim clock is T0. // A loaded package created at the seeded game clock with a 10h window (ISS-117).
const REWARD = 1_000; const REWARD = 1_000;
const WINDOW_MS = 10 * 3_600_000; const WINDOW_MS = 10 * 3_600_000;
async function setupLoaded(name: string, status: 'loaded' | 'expired' = 'loaded') { async function setupLoaded(name: string, status: 'loaded' | 'expired' = 'loaded') {
const inst = await freshDb(name); const inst = await freshDb(name);
// Always insert OPEN and load it through the real action… // Always insert OPEN and load it through the real action…
await insertOpenPackage(inst, 'p-ab', { reward: REWARD, createdSimTime: T0, deadlineSimTime: T0 + WINDOW_MS }); await insertOpenPackage(inst, 'p-ab', { reward: REWARD, createdSimTime: STARTING_CLOCK_MS, deadlineSimTime: STARTING_CLOCK_MS + WINDOW_MS });
await loadPackage(inst, 'p-ab', 'plane-starter'); await loadPackage(inst, 'p-ab', 'plane-starter');
// …then flip to expired in place (what the tick would do once the deadline passes). // …then flip to expired in place (what the tick would do once the deadline passes).
if (status === 'expired') { if (status === 'expired') {
@@ -475,7 +466,7 @@ describe('abandonPackage', () => {
it('scales to exactly 37.5% at the midpoint of the window', async () => { it('scales to exactly 37.5% at the midpoint of the window', async () => {
const inst = await setupLoaded('ab-mid.db'); const inst = await setupLoaded('ab-mid.db');
await inst.db.update(schema.simState).set({ clockMs: T0 + WINDOW_MS / 2 }); // runAction ticks first in prod await inst.db.update(schema.simState).set({ clockMs: STARTING_CLOCK_MS + WINDOW_MS / 2 }); // runAction ticks first in prod
const { penalty } = await abandonPackage(inst, 'p-ab'); const { penalty } = await abandonPackage(inst, 'p-ab');
@@ -484,8 +475,8 @@ describe('abandonPackage', () => {
it('caps at exactly 60% at and past the deadline', async () => { it('caps at exactly 60% at and past the deadline', async () => {
for (const [name, clock] of [ for (const [name, clock] of [
['ab-deadline.db', T0 + WINDOW_MS], ['ab-deadline.db', STARTING_CLOCK_MS + WINDOW_MS],
['ab-pastdeadline.db', T0 + WINDOW_MS * 2], // clamped — a not-yet-ticked loaded package past its deadline ['ab-pastdeadline.db', STARTING_CLOCK_MS + WINDOW_MS * 2], // clamped — a not-yet-ticked loaded package past its deadline
] as const) { ] as const) {
const inst = await setupLoaded(name); const inst = await setupLoaded(name);
await inst.db.update(schema.simState).set({ clockMs: clock }); await inst.db.update(schema.simState).set({ clockMs: clock });
@@ -680,6 +671,19 @@ describe('sellPlane', () => {
expect(ids).toContain('plane-extra'); // row untouched — the cargo stays bound to a real plane expect(ids).toContain('plane-extra'); // row untouched — the cargo stays bound to a real plane
}); });
it('rejects selling a grounded plane with queued legs with 409 (v0.46.0, ISS-088)', async () => {
const inst = await freshDb('sell-queue.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 queueLeg(inst, 'plane-extra', 'LEMD');
await expectActionError(sellPlane(inst, 'plane-extra'), 409, /cancel queued legs first/);
const plane = (await inst.db.select().from(schema.planes).where(eq(schema.planes.id, 'plane-extra')))[0];
expect(JSON.parse(plane.queue)).toEqual(['LEMD']); // row untouched — the queue stays with 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/);
@@ -691,8 +695,8 @@ describe('sellPlane', () => {
}); });
}); });
describe('repairPlane (v0.39.0, ISS-069)', () => { describe('repairPlane (v0.39.0, ISS-069; gradual since v0.45.0, ISS-089)', () => {
it('charges exactly REPAIR_COST, restores health to 100 and grounds the plane for REPAIR_DURATION_MS', async () => { it('charges exactly REPAIR_COST and grounds the plane for REPAIR_DURATION_MS without touching health yet', async () => {
const inst = await freshDb('repair-ok.db'); const inst = await freshDb('repair-ok.db');
await inst.db.update(schema.planes).set({ health: 42 }).where(eq(schema.planes.id, 'plane-starter')); 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 cashBefore = (await inst.db.select().from(schema.account))[0].cash;
@@ -702,8 +706,8 @@ describe('repairPlane (v0.39.0, ISS-069)', () => {
expect(cost).toBe(REPAIR_COST); expect(cost).toBe(REPAIR_COST);
const plane = (await inst.db.select().from(schema.planes))[0]; const plane = (await inst.db.select().from(schema.planes))[0];
expect(plane.status).toBe('repairing'); expect(plane.status).toBe('repairing');
expect(plane.health).toBe(100); // restored to full… expect(plane.health).toBe(42); // NOT restored yet — the engine's finishRepairs pass heals it gradually…
expect(plane.repairUntilSimTime).toBe(T0 + REPAIR_DURATION_MS); // …with the gate set from the seeded clock (no tick in direct calls) expect(plane.repairUntilSimTime).toBe(STARTING_CLOCK_MS + REPAIR_DURATION_MS); // …with the gate set from the seeded game clock (ISS-117; no tick in direct calls)
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 - REPAIR_COST, 6); expect(account.cash).toBeCloseTo(cashBefore - REPAIR_COST, 6);
}); });
+26 -17
View File
@@ -9,7 +9,6 @@ import { requiredFuelFor, windBurnFactor } from '@/lib/sim/movement';
import { runAction } from '@/lib/sim/engine'; import { runAction } from '@/lib/sim/engine';
import { getStateSnapshot, parseIcaoQueue } from './state'; import { getStateSnapshot, parseIcaoQueue } from './state';
import { import {
conditionBurnMultiplier,
FUEL_DISCOUNT_PER_REP, FUEL_DISCOUNT_PER_REP,
FUEL_PRICE_PER_L, FUEL_PRICE_PER_L,
MAX_FUEL_DISCOUNT, MAX_FUEL_DISCOUNT,
@@ -106,7 +105,8 @@ export async function loadPackage(inst: GameDb, packageId: string, planeId: stri
// dispatch AUTO-refuels first — the tank is topped up to min(maxFuel, max(fuel, 1.5 × required)) // dispatch AUTO-refuels first — the tank is topped up to min(maxFuel, max(fuel, 1.5 × required))
// at FUEL_PRICE_PER_L per liter minus the rep-scaled discount, deducted from cash; a leg that // at FUEL_PRICE_PER_L per liter minus the rep-scaled discount, deducted from cash; a leg that
// doesn't fit even a full tank still 409s (insufficient fuel), and an unaffordable top-up 409s // doesn't fit even a full tank still 409s (insufficient fuel), and an unaffordable top-up 409s
// with nothing mutated. // with nothing mutated. v0.45.0 (ISS-089): a broken plane (health ≤ 0) cannot fly at all — it
// 409s before any refuel math, so fuel and cash stay untouched.
export async function flyPlane(inst: GameDb, planeId: string, destIcao: string): Promise<void> { export async function flyPlane(inst: GameDb, planeId: string, destIcao: string): Promise<void> {
const planeRows = await inst.db.select().from(schema.planes).where(eq(schema.planes.id, planeId)); const planeRows = await inst.db.select().from(schema.planes).where(eq(schema.planes.id, planeId));
const plane = planeRows[0]; const plane = planeRows[0];
@@ -114,6 +114,9 @@ export async function flyPlane(inst: GameDb, planeId: string, destIcao: string):
if (plane.status !== 'available') { if (plane.status !== 'available') {
throw new GameActionError(409, `plane is ${plane.status}, only grounded planes can fly`); throw new GameActionError(409, `plane is ${plane.status}, only grounded planes can fly`);
} }
if (plane.health <= 0) {
throw new GameActionError(409, 'the plane is broken (health 0) — repair it first');
}
const fc = await loadAirportsGeoJson(); const fc = await loadAirportsGeoJson();
const destFeature = fc.features.find((f) => f.properties.icao === destIcao); const destFeature = fc.features.find((f) => f.properties.icao === destIcao);
@@ -125,9 +128,8 @@ export async function flyPlane(inst: GameDb, planeId: string, destIcao: string):
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; wind- // Weighted burn model (v0.16.0): the leg needs enough fuel to cover it arriving empty; wind-
// adjusted (v0.36.0) by departure weather vs route heading — every call site uses this same factor. // 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 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 burnPerKm = type.burnPerKm * windBurnFactor(w.windKt, w.windDirDeg, initialBearingDeg(origin.lat, origin.lon, destFeature.properties.lat, destFeature.properties.lon));
const requiredL = requiredFuelFor(burnPerKm, type.emptyWeightKg, type.maxFuel, distanceKm); const requiredL = requiredFuelFor(burnPerKm, type.emptyWeightKg, type.maxFuel, distanceKm);
// ISS-082 (v0.42.0): dispatch auto-refuels — top up to 150% of the requirement (capped at tank // ISS-082 (v0.42.0): dispatch auto-refuels — top up to 150% of the requirement (capped at tank
@@ -151,6 +153,12 @@ export async function flyPlane(inst: GameDb, planeId: string, destIcao: string):
const sim = (await inst.db.select().from(schema.simState))[0]; const sim = (await inst.db.select().from(schema.simState))[0];
if (!sim) throw new Error('sim_state row missing'); if (!sim) throw new Error('sim_state row missing');
// v0.46.0 (ISS-088): flying to the FIRST queued leg consumes it — otherwise the engine would
// chain a zero-distance self-hop on landing (queue[0] === the airport just reached). Skip-ahead
// to a non-first queued dest is an explicit override: the queue stays intact, FIFO preserved.
const queue = parseIcaoQueue(plane.queue);
if (queue.length > 0 && queue[0] === destIcao) queue.shift();
await inst.db.update(schema.planes) await inst.db.update(schema.planes)
.set({ .set({
status: 'flying', status: 'flying',
@@ -159,19 +167,12 @@ export async function flyPlane(inst: GameDb, planeId: string, destIcao: string):
lat: origin.lat, lat: origin.lat,
lon: origin.lon, lon: origin.lon,
fuel: target, // ISS-082: the post-top-up level (unchanged when no top-up was needed) fuel: target, // ISS-082: the post-top-up level (unchanged when no top-up was needed)
queue: JSON.stringify(queue),
}) })
.where(eq(schema.planes.id, planeId)); .where(eq(schema.planes.id, planeId));
if (cost > 0) { if (cost > 0) {
await inst.db.update(schema.account).set({ cash: Math.round((account.cash - cost) * 100) / 100 }); await inst.db.update(schema.account).set({ cash: Math.round((account.cash - cost) * 100) / 100 });
} }
// 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); await persist(inst);
} }
@@ -338,6 +339,11 @@ export async function sellPlane(inst: GameDb, planeId: string): Promise<{ payout
throw new GameActionError(409, `cannot sell a plane carrying cargo`); throw new GameActionError(409, `cannot sell a plane carrying cargo`);
} }
// v0.46.0 (ISS-088): selling a plane with queued legs would silently drop them — force an explicit cancel first.
if (parseIcaoQueue(plane.queue).length > 0) {
throw new GameActionError(409, 'the plane has queued legs — cancel queued legs first');
}
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');
@@ -355,10 +361,12 @@ export async function sellPlane(inst: GameDb, planeId: string): Promise<{ payout
// --- Repair (POST /api/planes/:planeId/repair) — v0.39.0 (ISS-069) --- // --- 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' // Ground a worn-out plane in 'repairing' status for the flat REPAIR_COST (v0.39.0, ISS-069).
// status until sim.clockMs + REPAIR_DURATION_MS, when the engine's tick flips it back to // Since v0.45.0 (ISS-089) the repair is GRADUAL: health stays where it is and climbs back to 100
// 'available'. Only GROUNDED planes can be repaired (409 otherwise); an already-full-health // over REPAIR_DURATION_MS of sim time — the engine's finishRepairs pass applies the curve each tick
// repair is a free no-op. 404 unknown plane; 409 not grounded / insufficient funds. // and flips the plane to 'available' exactly when it reaches full health (the gate). Only GROUNDED
// planes can be repaired (409 otherwise); an already-full-health repair is a free no-op. 404 unknown
// plane; 409 not grounded / insufficient funds.
export async function repairPlane(inst: GameDb, planeId: string): Promise<{ cost: number }> { 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 rows = await inst.db.select().from(schema.planes).where(eq(schema.planes.id, planeId));
const plane = rows[0]; const plane = rows[0];
@@ -377,8 +385,9 @@ export async function repairPlane(inst: GameDb, planeId: string): Promise<{ cost
throw new GameActionError(409, `insufficient funds: repair costs ${REPAIR_COST}, you have ${Math.round(account.cash)}`); throw new GameActionError(409, `insufficient funds: repair costs ${REPAIR_COST}, you have ${Math.round(account.cash)}`);
} }
// Health is NOT set here — the engine's finishRepairs pass heals it gradually along the gate.
await inst.db.update(schema.planes) await inst.db.update(schema.planes)
.set({ health: 100, status: 'repairing', repairUntilSimTime: sim.clockMs + REPAIR_DURATION_MS }) .set({ status: 'repairing', repairUntilSimTime: sim.clockMs + REPAIR_DURATION_MS })
.where(eq(schema.planes.id, planeId)); .where(eq(schema.planes.id, planeId));
await inst.db.update(schema.account).set({ cash: Math.round((account.cash - REPAIR_COST) * 100) / 100 }); await inst.db.update(schema.account).set({ cash: Math.round((account.cash - REPAIR_COST) * 100) / 100 });
await persist(inst); await persist(inst);
+21 -16
View File
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest'; 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 { 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'; import { STARTING_REPUTATION } from './seed';
import { guaranteedRangeKm, MAX_WIND_BURN_FACTOR, maxRangeKm, requiredFuelFor } from '@/lib/sim/movement';
// v0.28.0 (ISS-047) — catalog sanity: the progression ladder must stay well-formed as types are added. // v0.28.0 (ISS-047) — catalog sanity: the progression ladder must stay well-formed as types are added.
describe('PLANE_TYPES', () => { describe('PLANE_TYPES', () => {
@@ -44,20 +45,24 @@ describe('repRate', () => {
}); });
}); });
// v0.39.0 (ISS-069) — the broken-state burn factor: ×BROKEN_BURN_MULTIPLIER at/below zero health, exactly 1 above it. // v0.51.0 (ISS-099) — max range consistency: for EVERY catalog type the displayed guaranteed range
describe('conditionBurnMultiplier', () => { // must be flyable on a full tank under worst-case headwind burn (the same requiredFuelFor ≤ maxFuel
it('is exactly 1 for any positive health', () => { // rule as flyPlane's fuel guard), and tight — not padded. The closed form inverts exactly, so the
expect(conditionBurnMultiplier(100)).toBe(1); // "≤" side allows only float noise (~1e-11 L → relative 1e-9 tolerance).
expect(conditionBurnMultiplier(42)).toBe(1); describe('max range consistency (v0.51.0, ISS-099)', () => {
expect(conditionBurnMultiplier(0.5)).toBe(1); // worn but not broken for (const t of Object.values(PLANE_TYPES)) {
}); it(`${t.id}: guaranteed range is flyable in worst-case headwind and not padded`, () => {
const r = guaranteedRangeKm(t.burnPerKm, t.emptyWeightKg, t.maxFuel);
expect(requiredFuelFor(t.burnPerKm * MAX_WIND_BURN_FACTOR, t.emptyWeightKg, t.maxFuel, r))
.toBeLessThanOrEqual(t.maxFuel * (1 + 1e-9));
// Tight: 5% beyond the displayed range no longer fits a full tank.
expect(requiredFuelFor(t.burnPerKm * MAX_WIND_BURN_FACTOR, t.emptyWeightKg, t.maxFuel, 1.05 * r))
.toBeGreaterThan(t.maxFuel);
});
it('is exactly BROKEN_BURN_MULTIPLIER at and below zero health', () => { it(`${t.id}: the no-wind closed form inverts requiredFuelFor exactly`, () => {
expect(conditionBurnMultiplier(0)).toBe(BROKEN_BURN_MULTIPLIER); const r = maxRangeKm(t.burnPerKm, t.emptyWeightKg, t.maxFuel);
expect(conditionBurnMultiplier(-3)).toBe(BROKEN_BURN_MULTIPLIER); // defensive — wear clamps at 0, but the gate is <= 0 expect(requiredFuelFor(t.burnPerKm, t.emptyWeightKg, t.maxFuel, r)).toBeLessThanOrEqual(t.maxFuel * (1 + 1e-9));
}); });
}
it('pins the broken multiplier at ×1.5', () => {
expect(BROKEN_BURN_MULTIPLIER).toBe(1.5);
});
}); });
+5 -9
View File
@@ -97,6 +97,11 @@ export const PLANE_TYPES: Record<string, PlaneType> = {
}, },
}; };
// v0.54.0 (ISS-119): fastest cruise speed in the catalog — a generated job must be finishable by
// the best available aircraft, so seed's expiry floor references this, not the starter's 510 km/h
// (that would wreck long-job economics). Data-driven: adding a faster plane tightens the floor.
export const FASTEST_CATALOG_SPEED_KMH = Math.max(...Object.values(PLANE_TYPES).map((t) => t.speedKmh));
// The plane the player starts with. // The plane the player starts with.
export const STARTER_MODEL = 'atr72'; export const STARTER_MODEL = 'atr72';
@@ -128,15 +133,6 @@ export const MAX_FUEL_DISCOUNT = 0.2;
// ponytail: balance-tunable — health lost per km of landed legs (~130 mid-range EDDF↔LEMD hops to break from full; 1/10× slower since v0.43.0, ISS-085). // ponytail: balance-tunable — health lost per km of landed legs (~130 mid-range EDDF↔LEMD hops to break from full; 1/10× slower since v0.43.0, ISS-085).
export const WEAR_PER_KM = 0.0005; export const WEAR_PER_KM = 0.0005;
// 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 dispatch top-up + backstop, 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). // ponytail: balance-tunable — flat repair cost in € (per-type if balance demands it).
export const REPAIR_COST = 1_500; export const REPAIR_COST = 1_500;
// ponytail: balance-tunable — how long a repair grounds the plane, sim ms (3 game days). // ponytail: balance-tunable — how long a repair grounds the plane, sim ms (3 game days).
+100 -10
View File
@@ -1,10 +1,13 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { WORLD_TIME_SCALE } from '@/lib/worldClock';
import { haversineKm } from '@/lib/geo'; import { haversineKm } from '@/lib/geo';
import { PLANE_TYPES, repRate, STARTER_MODEL } from './catalog'; import { travelDurationMs } from '@/lib/sim/movement';
import { FASTEST_CATALOG_SPEED_KMH, PLANE_TYPES, repRate, STARTER_MODEL } from './catalog';
import { SPECIALS } from './specials'; import { SPECIALS } from './specials';
import { import {
BASE_REWARD, BASE_REWARD,
buildSeed, buildSeed,
EXPIRY_BUFFER_MS,
JOB_BONUS_PER_REP, JOB_BONUS_PER_REP,
MAX_JOB_BONUS, MAX_JOB_BONUS,
PACKAGE_COUNT_MAX, PACKAGE_COUNT_MAX,
@@ -18,6 +21,7 @@ import {
REWARD_PER_KM, REWARD_PER_KM,
rewardFor, rewardFor,
STARTING_CASH, STARTING_CASH,
STARTING_CLOCK_MS,
STARTING_REPUTATION, STARTING_REPUTATION,
type Rng, type Rng,
type SeedAirport, type SeedAirport,
@@ -73,12 +77,12 @@ describe('buildSeed', () => {
expect(c.originIcao).not.toBe(c.destIcao); expect(c.originIcao).not.toBe(c.destIcao);
expect(c.status).toBe('open'); expect(c.status).toBe('open');
expect(c.planeId).toBeNull(); expect(c.planeId).toBeNull();
expect(c.createdSimTime).toBe(NOW); expect(c.createdSimTime).toBe(STARTING_CLOCK_MS); // game-clock origin, not wall time (ISS-117)
} }
expect(new Set(seed.packages.map((c) => c.id)).size).toBe(seed.packages.length); expect(new Set(seed.packages.map((c) => c.id)).size).toBe(seed.packages.length);
}); });
it('applies the reward formula: round(500 + 2.5 * km + 0.5 * weightKg), × special multiplier', () => { it('applies the reward formula (BASE_REWARD + REWARD_PER_KM * km + REWARD_PER_KG * 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)!;
@@ -101,22 +105,23 @@ describe('buildSeed', () => {
} }
}); });
it('sets deadlines inside the 6–72h game-time window after now (scaled by any special status)', () => { it('sets deadlines inside the 6–72h game-time window after the game-clock origin (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) {
// v0.37.0 (ISS-073): a special's deadlineScale tightens the drawn window (e.g. priority → 3–36h). // v0.37.0 (ISS-073): a special's deadlineScale tightens the drawn window (e.g. priority → 3–36h).
// ISS-117: the window is anchored at STARTING_CLOCK_MS, not wall time.
const scale = c.special ? SPECIALS[c.special]?.deadlineScale ?? 1 : 1; const scale = c.special ? SPECIALS[c.special]?.deadlineScale ?? 1 : 1;
expect(c.deadlineSimTime).toBeGreaterThan(NOW + 6 * 3_600_000 * scale - 1); expect(c.deadlineSimTime).toBeGreaterThan(STARTING_CLOCK_MS + 6 * 3_600_000 * scale - 1);
expect(c.deadlineSimTime).toBeLessThanOrEqual(NOW + 72 * 3_600_000 * scale); expect(c.deadlineSimTime).toBeLessThanOrEqual(STARTING_CLOCK_MS + 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; // ISS-117 (v0.52.0): game clock starts at year 2000 with the fixed world rate; lastRealTickMs is
// lastSelectedTimeScale mirrors the seeded speed (v0.30.0, ISS-057). // the REAL seed moment so the first tick-on-read advances ~0. paused/lastSelectedTimeScale are legacy columns.
expect(seed.simState).toEqual({ clockMs: NOW, timeScale: 1, paused: false, lastRealTickMs: NOW, lastSelectedTimeScale: 1 }); expect(seed.simState).toEqual({ clockMs: STARTING_CLOCK_MS, timeScale: WORLD_TIME_SCALE, paused: false, lastRealTickMs: NOW, lastSelectedTimeScale: WORLD_TIME_SCALE });
}); });
it('is fully deterministic for a fixed RNG and inputs', () => { it('is fully deterministic for a fixed RNG and inputs', () => {
@@ -134,6 +139,9 @@ describe('buildSeed', () => {
it('rewardFor applies the documented formula', () => { it('rewardFor applies the documented formula', () => {
expect(rewardFor(0, 0)).toBe(BASE_REWARD); expect(rewardFor(0, 0)).toBe(BASE_REWARD);
expect(rewardFor(1000, 2_000)).toBe(Math.round(BASE_REWARD + REWARD_PER_KM * 1000 + REWARD_PER_KG * 2_000)); expect(rewardFor(1000, 2_000)).toBe(Math.round(BASE_REWARD + REWARD_PER_KM * 1000 + REWARD_PER_KG * 2_000));
// v0.47.0 (ISS-090): literal pins so future retunes are deliberate — weight must dominate payoff.
expect(rewardFor(1000, 500)).toBe(4_000);
expect(rewardFor(1000, 4_000)).toBe(11_000);
}); });
}); });
@@ -183,7 +191,8 @@ describe('generatePackage (shared seed/spawn builder)', () => {
const count = PACKAGE_COUNT_MIN + Math.floor(rngA() * (PACKAGE_COUNT_MAX - PACKAGE_COUNT_MIN + 1)); const count = PACKAGE_COUNT_MIN + Math.floor(rngA() * (PACKAGE_COUNT_MAX - PACKAGE_COUNT_MIN + 1));
const expected: string[] = []; const expected: string[] = [];
for (let i = 0; i < count; i++) { for (let i = 0; i < count; i++) {
expected.push(JSON.stringify(generatePackage({ id: `package-${i + 1}`, airports: AIRPORTS, rng: rngA, nowMs: NOW }))); // ISS-117: buildSeed anchors packages at the game-clock origin, not the wall-time input.
expected.push(JSON.stringify(generatePackage({ id: `package-${i + 1}`, airports: AIRPORTS, rng: rngA, nowMs: STARTING_CLOCK_MS })));
} }
const seed = buildSeed({ airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW }); const seed = buildSeed({ airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW });
expect(seed.packages.map((c) => JSON.stringify(c))).toEqual(expected); expect(seed.packages.map((c) => JSON.stringify(c))).toEqual(expected);
@@ -430,3 +439,84 @@ describe('distance cap (v0.44.0, ISS-079)', () => {
} }
}); });
}); });
describe('expiry floor (v0.54.0, ISS-119)', () => {
// Pre-v0.54.0 deadline for a forced-endpoint + forced-special package: replays the fixed draw order
// (cargoType, cargoCount, weight jitter, window — forced inputs consume no draws) so short-leg
// packages can be pinned byte-identical to the old behavior.
const oldDeadlineFor = (seed: number, scale: number): number => {
const rng = mulberry32(seed);
rng(); // cargoType
rng(); // cargoCount
rng(); // weight jitter
const w = rng(); // deadline window
return Math.round(NOW + (6 + w * (72 - 6)) * 3_600_000 * scale);
};
it('pins the constants: fastest catalog speed (not the starter) and a flat 1 h buffer', () => {
expect(FASTEST_CATALOG_SPEED_KMH).toBe(Math.max(...Object.values(PLANE_TYPES).map((t) => t.speedKmh)));
expect(FASTEST_CATALOG_SPEED_KMH).toBeGreaterThan(PLANE_TYPES[STARTER_MODEL].speedKmh);
expect(EXPIRY_BUFFER_MS).toBe(3_600_000);
});
it('every generated package on the real dataset satisfies deadline ≥ now + travel time at max speed + buffer', async () => {
const { loadAirportsGeoJson } = await import('@/lib/airports');
const fc = await loadAirportsGeoJson();
const airports: SeedAirport[] = fc.features.map((f) => ({ icao: f.properties.icao, name: f.properties.name, lat: f.properties.lat, lon: f.properties.lon }));
for (let seed = 1; seed <= 200; seed++) {
const c = generatePackage({ id: `c-floor-${seed}`, airports, rng: mulberry32(seed), nowMs: NOW }); // special rolls naturally (~15% priority)
const o = airports.find((a) => a.icao === c.originIcao)!;
const d = airports.find((a) => a.icao === c.destIcao)!;
const floorMs = travelDurationMs(haversineKm(o.lat, o.lon, d.lat, d.lon), FASTEST_CATALOG_SPEED_KMH) + EXPIRY_BUFFER_MS;
expect(c.deadlineSimTime).toBeGreaterThanOrEqual(Math.round(NOW + floorMs));
}
});
it('long legs near MAX_JOB_LEG_KM are clamped up to the floor for every special (real dataset)', async () => {
const { loadAirportsGeoJson } = await import('@/lib/airports');
const fc = await loadAirportsGeoJson();
const airports: SeedAirport[] = fc.features.map((f) => ({ icao: f.properties.icao, name: f.properties.name, lat: f.properties.lat, lon: f.properties.lon }));
// Farthest pair within the cap — a leg as long as any generated job can be.
let bestI = -1;
let bestJ = -1;
let bestKm = 0;
for (let i = 0; i < airports.length; i++) {
for (let j = i + 1; j < airports.length; j++) {
const km = haversineKm(airports[i].lat, airports[i].lon, airports[j].lat, airports[j].lon);
if (km <= MAX_JOB_LEG_KM && km > bestKm) {
bestI = i;
bestJ = j;
bestKm = km;
}
}
}
expect(bestKm).toBeGreaterThan(8_000); // the dataset really spans near-cap legs
const floorMs = travelDurationMs(bestKm, FASTEST_CATALOG_SPEED_KMH) + EXPIRY_BUFFER_MS;
for (const special of ['priority', null] as const) {
let clamped = 0;
for (let seed = 1; seed <= 50; seed++) {
const c = generatePackage({ id: `c-long-${special ?? 'plain'}-${seed}`, airports, rng: mulberry32(seed), nowMs: NOW, originIcao: airports[bestI].icao, destIcao: airports[bestJ].icao, special });
expect(c.deadlineSimTime).toBeGreaterThanOrEqual(Math.round(NOW + floorMs)); // the invariant
if (c.deadlineSimTime === Math.round(NOW + floorMs)) clamped++; // landed exactly on the floor → the clamp fired
}
// Deterministic for these fixed seeds — proves the clamp actually engages, not just vacuously holds.
expect(clamped).toBeGreaterThan(0);
}
});
it('short legs are byte-identical to the pre-v0.54.0 draw (no economy regression)', () => {
// EDDF→EHAM ≈ 370 km → floor ≈ 1.4 h — below even the priority minimum window (3 h), so the clamp never fires here.
for (const seed of [1, 2, 3, 4, 5]) {
const plain = generatePackage({ id: 'c-short', airports: AIRPORTS, rng: mulberry32(seed), nowMs: NOW, originIcao: 'EDDF', destIcao: 'EHAM', special: null });
expect(plain.deadlineSimTime).toBe(oldDeadlineFor(seed, 1));
const prio = generatePackage({ id: 'c-short-p', airports: AIRPORTS, rng: mulberry32(seed), nowMs: NOW, originIcao: 'EDDF', destIcao: 'EHAM', special: 'priority' });
expect(prio.deadlineSimTime).toBe(oldDeadlineFor(seed, SPECIALS.priority.deadlineScale));
}
});
it('forced deadline offsets still skip the floor (e2e expiry hook)', () => {
const c = generatePackage({ id: 'c-floor-hook', airports: AIRPORTS, rng: mulberry32(1), nowMs: NOW, originIcao: 'EDDF', destIcao: 'EHAM', special: null, deadlineOffsetMs: 10_000 });
expect(c.deadlineSimTime).toBe(NOW + 10_000); // the hook's near-term expiry is untouched by the floor
});
});
+35 -11
View File
@@ -1,5 +1,7 @@
import { haversineKm } from '@/lib/geo'; import { haversineKm } from '@/lib/geo';
import { PLANE_TYPES, repRate, STARTER_MODEL } from './catalog'; import { travelDurationMs } from '@/lib/sim/movement';
import { WORLD_TIME_SCALE } from '@/lib/worldClock';
import { FASTEST_CATALOG_SPEED_KMH, PLANE_TYPES, repRate, STARTER_MODEL } from './catalog';
import { pickSpecial, SPECIALS } from './specials'; 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)
@@ -75,6 +77,10 @@ export interface SeedData {
export const STARTING_CASH = 20_000; export const STARTING_CASH = 20_000;
export const STARTING_REPUTATION = 10; export const STARTING_REPUTATION = 10;
// ISS-117 (v0.52.0): fresh worlds start at the top of year 2000 — a fixed game-time origin so
// every new save begins from the same moment. Existing saves keep their own clock (no migration).
export const STARTING_CLOCK_MS = Date.UTC(2000, 0, 1);
// The starter plane grounds at a large central-European hub. If the ICAO is // The starter plane grounds at a large central-European hub. If the ICAO is
// ever missing from the dataset we fall back to the first airport rather than // ever missing from the dataset we fall back to the first airport rather than
// failing first run. (EDDF = Frankfurt; EDDM is Munich — don't mix them up.) // failing first run. (EDDF = Frankfurt; EDDM is Munich — don't mix them up.)
@@ -90,9 +96,12 @@ export const MARKET_TARGET_OPEN_PACKAGES = 10;
// Reward formula (v0.6.0 distance part, deliberately simple; real revenue/cost // Reward formula (v0.6.0 distance part, deliberately simple; real revenue/cost
// modeling is a later increment; v0.27.0 weight part): reward = BASE_REWARD + // 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. // REWARD_PER_KM * great-circle km + REWARD_PER_KG * package weight kg.
// v0.47.0 (ISS-090) retune: REWARD_PER_KG 0.5 → 2.0 so the weight term dominates
// base+distance — heavier jobs must clearly pay more per kg to be worth taking
// (a 1000 km leg went from €3,250 @500 kg / €5,000 @4,000 kg to €4,000 / €11,000).
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; export const REWARD_PER_KG = 2.0;
// Job reward bonus (v0.38.0, ISS-072): reward ×= 1 + repRate(reputation, …) — better jobs at // Job reward bonus (v0.38.0, ISS-072): reward ×= 1 + repRate(reputation, …) — better jobs at
// higher reputation, exactly ×1 at the starting value. // higher reputation, exactly ×1 at the starting value.
@@ -115,6 +124,10 @@ const LEG_CAP_MAX_ATTEMPTS = 25;
const DEADLINE_MIN_HOURS = 6; const DEADLINE_MIN_HOURS = 6;
const DEADLINE_MAX_HOURS = 72; const DEADLINE_MAX_HOURS = 72;
// v0.54.0 (ISS-119): flat slack added to travel time in the expiry floor — a deadline must leave at
// least this much after the fastest plane lands, so dispatch + landing are never instant.
export const EXPIRY_BUFFER_MS = 3_600_000; // 1 h
const CARGO_TYPES = ['Passengers', 'Mail', 'Perishables', 'Electronics', 'Auto parts'] as const; const CARGO_TYPES = ['Passengers', 'Mail', 'Perishables', 'Electronics', 'Auto parts'] as const;
// ponytail: balance-tunable unit weights (kg per passenger/pallet). Chosen so every cargo // ponytail: balance-tunable unit weights (kg per passenger/pallet). Chosen so every cargo
@@ -149,6 +162,9 @@ export function rewardFor(km: number, weightKg: number): number {
// v0.38.0 (ISS-072): reputation optionally scales the reward (×(1 + repRate)) at the same spot // 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 // 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. // starting value so seed-time behavior is byte-identical.
// v0.54.0 (ISS-119): the drawn deadline window is clamped UP by an expiry floor (travel time at
// FASTEST_CATALOG_SPEED_KMH + EXPIRY_BUFFER_MS) after special scaling, so every package is
// finishable by the fastest catalog plane; short legs are byte-identical to before.
export function generatePackage(input: { export function generatePackage(input: {
id: string; id: string;
airports: SeedAirport[]; airports: SeedAirport[];
@@ -222,8 +238,15 @@ export function generatePackage(input: {
// forced value (including explicit null) skips it, same pattern as the other overrides. // forced value (including explicit null) skips it, same pattern as the other overrides.
const special = input.special !== undefined ? input.special : pickSpecial(rng); const special = input.special !== undefined ? input.special : pickSpecial(rng);
const mods = special !== null ? SPECIALS[special] : undefined; const mods = special !== null ? SPECIALS[special] : undefined;
// Forced deadlines skip the special scaling — consistent with overrides skipping draws. // v0.54.0 (ISS-119): expiry floor — every package must be finishable by the fastest catalog plane:
const deadlineMs = input.deadlineOffsetMs ?? drawnWindowMs * (mods?.deadlineScale ?? 1); // deadline ≥ now + travel time at FASTEST_CATALOG_SPEED_KMH + EXPIRY_BUFFER_MS. The drawn window is a
// single uniform value, so clamping it up AFTER special scaling covers both requirements: short legs
// (drawn × scale already above the floor) are byte-identical to before, and when the raw draw falls
// below the floor the deadline lands exactly on it — which stays ≤ the scaled ceiling for any pair of
// airports on Earth (max leg ~20000 km → floor ≈ 22.8 h < the 36 h priority ceiling), so no separate
// ceiling raise is needed. Forced offsets skip the floor, same as they skip the special scaling.
const expiryFloorMs = travelDurationMs(km, FASTEST_CATALOG_SPEED_KMH) + EXPIRY_BUFFER_MS;
const deadlineMs = input.deadlineOffsetMs ?? Math.max(drawnWindowMs * (mods?.deadlineScale ?? 1), expiryFloorMs);
// v0.38.0 (ISS-072): reputation bonus composed with the special multiplier, single round at the end. // 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); const repBonus = 1 + repRate(input.reputation ?? STARTING_REPUTATION, JOB_BONUS_PER_REP, MAX_JOB_BONUS);
@@ -288,20 +311,21 @@ export function buildSeed(input: {
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));
// Stable index-based ids for the seed set; runtime-generated entities must use // Stable index-based ids for the seed set; runtime-generated entities must use
// random UUIDs to avoid colliding with these. // random UUIDs to avoid colliding with these. Deadlines are drawn relative to the GAME clock
// origin (ISS-117), not the real wall time, so they stay meaningful at the fixed world rate.
const packages: SeedPackage[] = []; const packages: SeedPackage[] = [];
for (let i = 0; i < count; i++) { for (let i = 0; i < count; i++) {
packages.push(generatePackage({ id: `package-${i + 1}`, airports, rng, nowMs })); packages.push(generatePackage({ id: `package-${i + 1}`, airports, rng, nowMs: STARTING_CLOCK_MS }));
} }
return { return {
plane, plane,
packages, packages,
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 // ISS-117 (v0.52.0): the game clock starts at year 2000 with the fixed world rate; lastRealTickMs
// by ~0 instead of treating "time since install" as elapsed play time. // is the REAL seed moment so the first tick advances by ~0 instead of treating "time since
// lastSelectedTimeScale (v0.30.0, ISS-057) mirrors the seeded speed — nothing has been // install" as elapsed play time. paused/lastSelectedTimeScale are legacy columns kept for schema
// selected manually yet, so resume would restore 1× anyway. // stability — no game code reads them anymore.
simState: { clockMs: nowMs, timeScale: 1, paused: false, lastRealTickMs: nowMs, lastSelectedTimeScale: 1 }, simState: { clockMs: STARTING_CLOCK_MS, timeScale: WORLD_TIME_SCALE, paused: false, lastRealTickMs: nowMs, lastSelectedTimeScale: WORLD_TIME_SCALE },
}; };
} }
+17 -7
View File
@@ -3,7 +3,7 @@ import { haversineKm, initialBearingDeg } from '@/lib/geo';
import { weatherFor } from '@/lib/weather'; 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 { conditionBurnMultiplier, catalogEntries, FUEL_PRICE_PER_L, PLANE_TYPES, SELL_FRACTION, type CatalogEntry } from './catalog'; import { catalogEntries, FUEL_PRICE_PER_L, PLANE_TYPES, SELL_FRACTION, type CatalogEntry } from './catalog';
import { fuelAtWeighted, progressAt, positionAt, windBurnFactor } 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).
@@ -30,7 +30,8 @@ export interface PlaneState {
// leg is destIcao. The engine auto-chains queue[0] on landing when fuel covers it. // leg is destIcao. The engine auto-chains queue[0] on landing when fuel covers it.
queue: string[]; queue: string[];
// Structural health in [0, 100] (v0.39.0, ISS-069) — wears down with distance flown; at 0 the // 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. // plane is broken and cannot fly until repaired (v0.45.0, ISS-089). While 'repairing' this is
// also the repair progress: it climbs back to 100 over REPAIR_DURATION_MS of sim time.
health: number; health: number;
// Sim-clock epoch ms when an active repair finishes (v0.39.0, ISS-069); null when not in repair. // Sim-clock epoch ms when an active repair finishes (v0.39.0, ISS-069); null when not in repair.
repairUntilSimTime: number | null; repairUntilSimTime: number | null;
@@ -61,6 +62,8 @@ export interface PackageState {
weightKg: number; weightKg: number;
// Special status (v0.37.0, ISS-073) — key into lib/game/specials.ts SPECIALS; null = none. // Special status (v0.37.0, ISS-073) — key into lib/game/specials.ts SPECIALS; null = none.
special: string | null; special: string | null;
// Great-circle leg distance in km (v0.48.0, ISS-095) — computed from airport coords, not stored.
distanceKm: number;
deadlineSimTime: number; deadlineSimTime: number;
// 'ignored' (v0.29.0, ISS-047 part B) = a dismissed open job — terminal, leaves the market. // 'ignored' (v0.29.0, ISS-047 part B) = a dismissed open job — terminal, leaves the market.
status: 'open' | 'loaded' | 'delivered' | 'expired' | 'abandoned' | 'ignored'; status: 'open' | 'loaded' | 'delivered' | 'expired' | 'abandoned' | 'ignored';
@@ -70,7 +73,9 @@ export interface PackageState {
export interface StateSnapshot { export interface StateSnapshot {
account: { cash: number; reputation: number }; account: { cash: number; reputation: number };
simState: { clockMs: number; timeScale: number; paused: boolean }; // ISS-117 (v0.52.0): the world rate is a fixed constant (WORLD_TIME_SCALE), not per-save state —
// only the clock itself is reported here.
simState: { clockMs: number };
planes: PlaneState[]; planes: PlaneState[];
packages: PackageState[]; packages: PackageState[];
// Market reference data (v0.10.0) — static per build, sent with every snapshot so // Market reference data (v0.10.0) — static per build, sent with every snapshot so
@@ -99,6 +104,12 @@ export async function getStateSnapshot(): Promise<StateSnapshot> {
const nameByIcao = new Map(fc.features.map((f) => [f.properties.icao, f.properties.name])); const nameByIcao = new Map(fc.features.map((f) => [f.properties.icao, f.properties.name]));
const nameOf = (icao: string) => nameByIcao.get(icao) ?? icao; const nameOf = (icao: string) => nameByIcao.get(icao) ?? icao;
const coordsByIcao = new Map(fc.features.map((f) => [f.properties.icao, f.properties])); const coordsByIcao = new Map(fc.features.map((f) => [f.properties.icao, f.properties]));
// v0.48.0 (ISS-095): leg distance for the UI — same haversine as live-plane math; 0 if a coord is missing.
const legDistanceKm = (originIcao: string, destIcao: string) => {
const from = coordsByIcao.get(originIcao);
const to = coordsByIcao.get(destIcao);
return from && to ? haversineKm(from.lat, from.lon, to.lat, to.lon) : 0;
};
// Analytic movement (v0.7.0): a flying plane's stored lat/lon/fuel are its // Analytic movement (v0.7.0): a flying plane's stored lat/lon/fuel are its
// departure values — live position and fuel are pure functions of the clock. // departure values — live position and fuel are pure functions of the clock.
@@ -113,12 +124,10 @@ export async function getStateSnapshot(): Promise<StateSnapshot> {
const pos = positionAt(from.lat, from.lon, to.lat, to.lon, progress); const pos = positionAt(from.lat, from.lon, to.lat, to.lon, progress);
// Wind-adjusted burn (v0.36.0) — weather is a pure function of the departure ICAO, so this // 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. // 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); const w = weatherFor(p.locationIcao);
return { ...pos, fuel: fuelAtWeighted( return { ...pos, fuel: fuelAtWeighted(
p.fuel, p.fuel,
type.burnPerKm * windBurnFactor(w.windKt, w.windDirDeg, initialBearingDeg(from.lat, from.lon, to.lat, to.lon)) * conditionBurnMultiplier(p.health), type.burnPerKm * windBurnFactor(w.windKt, w.windDirDeg, initialBearingDeg(from.lat, from.lon, to.lat, to.lon)),
type.emptyWeightKg, type.emptyWeightKg,
type.maxFuel, type.maxFuel,
distanceKm, distanceKm,
@@ -128,7 +137,7 @@ export async function getStateSnapshot(): Promise<StateSnapshot> {
return { return {
account: { cash: accountRow.cash, reputation: accountRow.reputation }, account: { cash: accountRow.cash, reputation: accountRow.reputation },
simState: { clockMs: simRow.clockMs, timeScale: simRow.timeScale, paused: simRow.paused }, simState: { clockMs: simRow.clockMs },
catalog: catalogEntries(), catalog: catalogEntries(),
fuelPricePerL: FUEL_PRICE_PER_L, fuelPricePerL: FUEL_PRICE_PER_L,
sellFraction: SELL_FRACTION, sellFraction: SELL_FRACTION,
@@ -163,6 +172,7 @@ export async function getStateSnapshot(): Promise<StateSnapshot> {
cargoCount: p.cargoCount, cargoCount: p.cargoCount,
weightKg: p.weightKg, weightKg: p.weightKg,
special: p.special, special: p.special,
distanceKm: legDistanceKm(p.originIcao, p.destIcao), // v0.48.0 (ISS-095)
deadlineSimTime: p.deadlineSimTime, deadlineSimTime: p.deadlineSimTime,
status: p.status, status: p.status,
planeId: p.planeId, planeId: p.planeId,
+77 -1
View File
@@ -8,12 +8,30 @@ const AIRPORTS = new Map<string, AirportCoord>([
['EDDV', { lat: 52.46, lon: 9.68 }], ['EDDV', { lat: 52.46, lon: 9.68 }],
['EHAM', { lat: 52.31, lon: 4.76 }], ['EHAM', { lat: 52.31, lon: 4.76 }],
['LEMD', { lat: 40.47, lon: -3.56 }], // Madrid — ~1400 km from EDDF for the long-route test ['LEMD', { lat: 40.47, lon: -3.56 }], // Madrid — ~1400 km from EDDF for the long-route test
// ISS-120 (v0.53.0): real dataset pairs whose great circle crosses the antimeridian (±180).
['NZAA', { lat: -37.01199, lon: 174.786331 }], // Auckland — east of +170
['NFTF', { lat: -21.241438, lon: -175.149164 }], // Fua'amotu (Tonga) — west of −175; NZAA→NFTF crosses ±180 eastward
['NZCH', { lat: -43.489029, lon: 172.532065 }], // Christchurch — for the queue-chain joint test
]); ]);
// ISS-120: longitude difference on the circle — two lons are "equal" when they differ by a multiple of 360.
const modDiff = (a: number, b: number): number => {
const d = Math.abs(a - b) % 360;
return Math.min(d, 360 - d);
};
// ISS-120: every consecutive pair of samples must be < 180° apart in longitude — the property that
// keeps MapLibre from drawing a chord across the map.
function expectContinuousLons(coords: [number, number][]): void {
for (let i = 1; i < coords.length; i++) {
expect(Math.abs(coords[i]![0] - coords[i - 1]![0]), `jump between samples ${i - 1} and ${i}`).toBeLessThan(180);
}
}
function snapshot(overrides: Partial<StateSnapshot> = {}): StateSnapshot { function snapshot(overrides: Partial<StateSnapshot> = {}): StateSnapshot {
return { return {
account: { cash: 0, reputation: 0 }, account: { cash: 0, reputation: 0 },
simState: { clockMs: 0, timeScale: 1, paused: false }, simState: { clockMs: 0 },
planes: [], planes: [],
packages: [], packages: [],
catalog: [ catalog: [
@@ -320,3 +338,61 @@ describe('greatCircleArc (v0.12.2 — curved route traces)', () => {
expect(haversineKm(mid[1], mid[0], chordMid[1], chordMid[0])).toBeLessThan(5); // stays close to the chord expect(haversineKm(mid[1], mid[0], chordMid[1], chordMid[0])).toBeLessThan(5); // stays close to the chord
}); });
}); });
describe('greatCircleArc — antimeridian crossing (ISS-120 v0.53.0)', () => {
const from = AIRPORTS.get('NZAA')!; // Auckland, lon +174.8
const to = AIRPORTS.get('NFTF')!; // Fua'amotu, lon −175.1 — the short arc crosses ±180 EASTWARD
it('eastward crossing: continuous lons (|Δlon| < 180), endpoints exact mod 360, length ≈ haversine', () => {
const arc = greatCircleArc(from, to);
expect(arc.length).toBe(ROUTE_ARC_SEGMENTS + 1);
expectContinuousLons(arc); // no wrapped ±180 jump anywhere — the chord bug is gone
expect(arc[0]).toEqual([from.lon, from.lat]); // first sample = raw origin, exact
const last = arc[arc.length - 1]!;
expect(modDiff(last[0], to.lon)).toBeLessThan(1e-9); // last ≡ dest (mod 360) — here unwrapped to +184.8
expect(Math.abs(last[1] - to.lat)).toBeLessThan(1e-9);
expect(arc.some((c) => c[0] > 180)).toBe(true); // the crossing samples sit on the east world copy
const dist = haversineKm(from.lat, from.lon, to.lat, to.lon);
let sum = 0;
for (let i = 1; i < arc.length; i++) sum += haversineKm(arc[i - 1]![1], arc[i - 1]![0], arc[i]![1], arc[i]![0]);
expect(Math.abs(sum - dist) / dist).toBeLessThan(0.01); // sampled chord length ≈ true great-circle distance
});
it('westward crossing (reversed pair): continuous lons below −180, endpoints exact mod 360', () => {
const arc = greatCircleArc(to, from);
expectContinuousLons(arc);
expect(arc[0]).toEqual([to.lon, to.lat]); // raw origin, exact
const last = arc[arc.length - 1]!;
expect(modDiff(last[0], from.lon)).toBeLessThan(1e-9); // ≡ +174.8 (mod 360) — here unwrapped to −185.2
expect(Math.abs(last[1] - from.lat)).toBeLessThan(1e-9);
expect(arc.some((c) => c[0] < -180)).toBe(true); // the crossing samples sit on the west world copy
});
it('non-crossing routes are untouched: lons stay in [−180, 180]', () => {
const arc = greatCircleArc(AIRPORTS.get('EDDF')!, AIRPORTS.get('LEMD')!);
expectContinuousLons(arc);
for (const c of arc) expect(c[0]).toBeGreaterThanOrEqual(-180);
for (const c of arc) expect(c[0]).toBeLessThanOrEqual(180);
});
it('queuedRoutesGeoJson: a chain crossing the antimeridian stays continuous across the joint', () => {
// Grounded at NZAA with queue [NFTF, NZCH]: leg 1 crosses ±180 eastward (ends unwrapped at +184.8),
// leg 2 restarts at the RAW −175.1 — without the global re-unwrap the joint would jump ~360°.
const fc = queuedRoutesGeoJson(
[
{ id: 'p1', model: 'atr72', status: 'available', fuel: 1, maxFuel: 2, locationIcao: 'NZAA', locationName: 'A', destIcao: null, destName: null, departureSimTime: null, lat: -37.01, lon: 174.79, health: 100, repairUntilSimTime: null, queue: ['NFTF', 'NZCH'] },
],
AIRPORTS,
);
expect(fc.features).toHaveLength(1);
const coords = fc.features[0]!.geometry.coordinates;
expect(coords.length).toBe(2 * ROUTE_ARC_SEGMENTS + 1); // two arcs, joint dropped once
expectContinuousLons(coords); // the whole chain — including across the NFTF joint
expect(coords[0]).toEqual([from.lon, from.lat]); // starts at the plane's airport, raw
const last = coords[coords.length - 1]!;
const nzch = AIRPORTS.get('NZCH')!;
expect(modDiff(last[0], nzch.lon)).toBeLessThan(1e-9); // ends ≡ NZCH (mod 360)
expect(Math.abs(last[1] - nzch.lat)).toBeLessThan(1e-9);
});
});
+24 -3
View File
@@ -74,16 +74,34 @@ export interface AirportCoord {
// curved path the plane actually flies (same slerp as lib/sim/movement.ts). // curved path the plane actually flies (same slerp as lib/sim/movement.ts).
export const ROUTE_ARC_SEGMENTS = 24; export const ROUTE_ARC_SEGMENTS = 24;
// ISS-120 (v0.53.0): positionAt wraps each sample's longitude into [−180, 180], so an arc that
// crosses the antimeridian jumps +179.x → −179.x between consecutive samples — and MapLibre draws
// LineStrings as-is (no ±180 splitting), rendering a chord across the whole map. Unwrap the sample
// sequence into continuous longitudes: keep the first sample's raw lon, then shift each subsequent
// one by ±360 relative to the previous UNWRAPPED value until |Δlon| ≤ 180. One line stays one line;
// out-of-range values render on MapLibre's adjacent world copies (renderWorldCopies is default-on).
function unwrapLons(coords: [number, number][]): [number, number][] {
let prev = coords[0]![0];
for (let i = 1; i < coords.length; i++) {
let lon = coords[i]![0];
while (lon - prev > 180) lon -= 360;
while (prev - lon > 180) lon += 360;
coords[i]![0] = lon;
prev = lon;
}
return coords;
}
// Great-circle arc between two airports as [lon, lat] coordinates: positionAt at // Great-circle arc between two airports as [lon, lat] coordinates: positionAt at
// t = i/SEGMENTS for i in 0..SEGMENTS. Pure — endpoints are exactly the airport // t = i/SEGMENTS for i in 0..SEGMENTS. Pure — the first sample is exactly `from` and the last is
// coords (positionAt returns them verbatim at t=0 and t=1). // ≡ `to` (mod 360) (positionAt returns them verbatim at t=0 and t=1, unwrapped relative to from).
export function greatCircleArc(from: AirportCoord, to: AirportCoord): [number, number][] { export function greatCircleArc(from: AirportCoord, to: AirportCoord): [number, number][] {
const coords: [number, number][] = []; const coords: [number, number][] = [];
for (let i = 0; i <= ROUTE_ARC_SEGMENTS; i++) { for (let i = 0; i <= ROUTE_ARC_SEGMENTS; i++) {
const pos = positionAt(from.lat, from.lon, to.lat, to.lon, i / ROUTE_ARC_SEGMENTS); const pos = positionAt(from.lat, from.lon, to.lat, to.lon, i / ROUTE_ARC_SEGMENTS);
coords.push([pos.lon, pos.lat]); coords.push([pos.lon, pos.lat]);
} }
return coords; return unwrapLons(coords);
} }
// ISS-022/025/027 (v0.22.0): the hover route trace — one great-circle LineString between two // ISS-022/025/027 (v0.22.0): the hover route trace — one great-circle LineString between two
@@ -195,6 +213,9 @@ export function queuedRoutesGeoJson(
const arc = greatCircleArc(pts[i]!, pts[i + 1]!); const arc = greatCircleArc(pts[i]!, pts[i + 1]!);
coords.push(...(i === 0 ? arc : arc.slice(1))); // drop the duplicated joint point coords.push(...(i === 0 ? arc : arc.slice(1))); // drop the duplicated joint point
} }
// ISS-120: each leg's arc is unwrapped relative to its OWN start airport, so at a joint the
// sequence can jump ±360 (leg 1 ends at dest+360k, leg 2 restarts at the raw dest) — re-unify.
unwrapLons(coords);
features.push({ features.push({
type: 'Feature', type: 'Feature',
geometry: { type: 'LineString', coordinates: coords }, geometry: { type: 'LineString', coordinates: coords },
+4 -26
View File
@@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest';
import { openApiSpec } from './openapi'; import { openApiSpec } from './openapi';
describe('OpenAPI spec', () => { describe('OpenAPI spec', () => {
it('documents exactly the thirteen public endpoints (4 read + 9 player actions)', () => { it('documents exactly the twelve public endpoints (3 read + 9 player actions)', () => {
expect(Object.keys(openApiSpec.paths).sort()).toEqual([ expect(Object.keys(openApiSpec.paths).sort()).toEqual([
'/api/airports', '/api/airports',
'/api/market/buy-plane', '/api/market/buy-plane',
@@ -15,7 +15,6 @@ describe('OpenAPI spec', () => {
'/api/planes/{planeId}/queue', // v0.25.0 (ISS-043) '/api/planes/{planeId}/queue', // v0.25.0 (ISS-043)
'/api/planes/{planeId}/queue/cancel', // v0.25.0 (ISS-043) '/api/planes/{planeId}/queue/cancel', // v0.25.0 (ISS-043)
'/api/planes/{planeId}/repair', // v0.39.0 (ISS-069) '/api/planes/{planeId}/repair', // v0.39.0 (ISS-069)
'/api/sim',
'/api/state', '/api/state',
'/api/version', '/api/version',
]); ]);
@@ -136,6 +135,7 @@ describe('OpenAPI spec', () => {
'deadlineSimTime', 'deadlineSimTime',
'destIcao', 'destIcao',
'destName', 'destName',
'distanceKm', // v0.48.0 (ISS-095) — great-circle leg distance in km
'id', 'id',
'originIcao', 'originIcao',
'originName', 'originName',
@@ -146,36 +146,14 @@ describe('OpenAPI spec', () => {
'weightKg', // v0.27.0 (ISS-045) — package weight in kg 'weightKg', // v0.27.0 (ISS-045) — package weight in kg
]); ]);
// ISS-117 (v0.52.0): the snapshot carries only the game clock — time controls are gone.
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']);
const packageStatus = openApiSpec.components.schemas.PackageState.properties.status; const packageStatus = openApiSpec.components.schemas.PackageState.properties.status;
expect(packageStatus.enum.sort()).toEqual(['abandoned', 'delivered', 'expired', 'ignored', 'loaded', 'open']); // v0.29.0 (ISS-047 part B) 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', () => {
const post = openApiSpec.paths['/api/sim'].post;
expect(post.operationId).toBe('setSimControls');
const requestSchema = post.requestBody.content['application/json'].schema;
expect(requestSchema.$ref).toBe('#/components/schemas/SimControlsRequest');
const ok = post.responses[200].content['application/json'].schema;
expect(ok.$ref).toBe('#/components/schemas/SimState');
const bad = post.responses[400].content['application/json'].schema;
expect(bad.$ref).toBe('#/components/schemas/Error');
const controls = openApiSpec.components.schemas.SimControlsRequest.properties;
expect(Object.keys(controls).sort()).toEqual(['paused', 'timeScale']);
expect(controls.timeScale.type).toBe('number');
expect(controls.timeScale.minimum).toBe(1);
expect(controls.timeScale.maximum).toBe(1000);
expect(controls.paused.type).toBe('boolean');
const error = openApiSpec.components.schemas.Error.properties;
expect(Object.keys(error)).toEqual(['error']);
});
it('documents the eight action endpoints: snapshot on 200, Error on their 4xx paths', () => { it('documents the eight action endpoints: snapshot on 200, Error on their 4xx paths', () => {
// The spec object is a literal type; these helpers view it as plain JSON. // The spec object is a literal type; these helpers view it as plain JSON.
const post = (p: string) => const post = (p: string) =>
+10 -57
View File
@@ -37,7 +37,7 @@ export const openApiSpec = {
get: { get: {
summary: 'Game state snapshot', summary: 'Game state snapshot',
description: description:
'Full current game state for client polling — account, sim clock, planes and packages, plus the static market reference data (catalog + economy constants) so clients can compute buy/sell previews locally. Tick-on-read: each call first advances the sim clock by (real time since last read) × timeScale (clamped per tick), so expiry/delivery/spawn are always applied before the snapshot is returned.', 'Full current game state for client polling — account, sim clock, planes and packages, plus the static market reference data (catalog + economy constants) so clients can compute buy/sell previews locally. The world clock advances on a ~1 s background tick at a fixed rate (v0.52.0, ISS-117: no player-facing time controls), clamped per tick; while no plane is flying or in repair the clock stays frozen. Each call ticks first, so expiry/delivery/spawn are always applied before the snapshot is returned.',
operationId: 'getState', operationId: 'getState',
responses: { responses: {
200: { 200: {
@@ -51,40 +51,6 @@ export const openApiSpec = {
}, },
}, },
}, },
'/api/sim': {
post: {
summary: 'Set sim time controls',
description:
'Advance the sim clock (tick-on-read) and set the time scale / pause state. The clock is advanced first, so the returned simState already reflects current game time; package expiry, arrival delivery and market spawn run inside that tick.',
operationId: 'setSimControls',
requestBody: {
required: false,
content: {
'application/json': {
schema: { $ref: '#/components/schemas/SimControlsRequest' },
},
},
},
responses: {
200: {
description: 'Updated sim state',
content: {
'application/json': {
schema: { $ref: '#/components/schemas/SimState' },
},
},
},
400: {
description: 'Invalid body (not an object, unknown field, or wrong types)',
content: {
'application/json': {
schema: { $ref: '#/components/schemas/Error' },
},
},
},
},
},
},
'/api/version': { '/api/version': {
get: { get: {
summary: 'App version', summary: 'App version',
@@ -223,7 +189,7 @@ export const openApiSpec = {
}, },
409: { 409: {
description: description:
'Plane not grounded, or the leg does not fit even a FULL tank (v0.16.0 weight-proportional burn — the fuel required to cover the great-circle distance arriving empty; dispatch auto-refuels up to min(maxFuel, max(fuel, 1.5 × required)) first, ISS-082), or cash is insufficient for that top-up', 'Plane not grounded, plane broken (health ≤ 0 — repair it first, v0.45.0), or the leg does not fit even a FULL tank (v0.16.0 weight-proportional burn — the fuel required to cover the great-circle distance arriving empty; dispatch auto-refuels up to min(maxFuel, max(fuel, 1.5 × required)) first, ISS-082), or cash is insufficient for that top-up',
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } }, content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
}, },
}, },
@@ -233,7 +199,7 @@ export const openApiSpec = {
post: { post: {
summary: 'Queue a flight leg for a plane', summary: 'Queue a flight leg for a plane',
description: 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 — with a short tank it auto-refuels to 150% of the requirement (capped at tank capacity) when cash allows (ISS-082); only an unaffordable top-up grounds the plane with the queue kept intact for a later dispatch.', '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 and the plane is not broken by the landing\'s wear (v0.45.0) — with a short tank it auto-refuels to 150% of the requirement (capped at tank capacity) when cash allows (ISS-082); only an unaffordable top-up (or a broken plane) grounds the plane with the queue kept intact for a later dispatch.',
operationId: 'queueLeg', operationId: 'queueLeg',
parameters: [ parameters: [
{ name: 'planeId', in: 'path', required: true, schema: { type: 'string' } }, { name: 'planeId', in: 'path', required: true, schema: { type: 'string' } },
@@ -370,14 +336,14 @@ export const openApiSpec = {
post: { post: {
summary: 'Repair a grounded plane', summary: 'Repair a grounded plane',
description: 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 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.', 'Ground a worn-out plane in "repairing" status for the flat repair cost (v0.39.0, ISS-069): its health climbs back to 100 over 3 sim days — the engine\'s tick applies the curve each tick and flips it back to "available" exactly when full health is reached (v0.45.0, ISS-089). While repairing it cannot load, fly or be sold; a broken plane (health ≤ 0) cannot fly at all until repaired. A full-health repair is a free no-op (cost 0). No body.',
operationId: 'repairPlane', operationId: 'repairPlane',
parameters: [ parameters: [
{ name: 'planeId', in: 'path', required: true, schema: { type: 'string' } }, { name: 'planeId', in: 'path', required: true, schema: { type: 'string' } },
], ],
responses: { responses: {
200: { 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)', description: 'Repair started (or free no-op) — full state snapshot (status "repairing" with the gate set, health unchanged until the engine\'s tick heals it back to 100 over the duration, cash reduced by the flat cost)',
content: { 'application/json': { schema: { $ref: '#/components/schemas/StateSnapshot' } } }, content: { 'application/json': { schema: { $ref: '#/components/schemas/StateSnapshot' } } },
}, },
404: { 404: {
@@ -493,24 +459,9 @@ export const openApiSpec = {
SimState: { SimState: {
type: 'object', type: 'object',
properties: { properties: {
clockMs: { type: 'integer', description: 'Sim-clock epoch ms (game time)' }, clockMs: { type: 'integer', description: 'Sim-clock epoch ms (game time). The world runs at a fixed rate with no player-facing controls (v0.52.0, ISS-117).' },
timeScale: { type: 'number', minimum: 1, maximum: 1000 },
paused: { type: 'boolean' },
},
required: ['clockMs', 'timeScale', 'paused'],
},
SimControlsRequest: {
type: 'object',
description: 'Partial update — omit a field to leave it unchanged.',
properties: {
timeScale: {
type: 'number',
minimum: 1,
maximum: 1000,
description: 'Game-time multiplier; values outside [1, 1000] are clamped. The UI offers the ladder 1/2/5/10/25/50/100/250/500/1000.',
},
paused: { type: 'boolean' },
}, },
required: ['clockMs'],
}, },
LoadPackageRequest: { LoadPackageRequest: {
type: 'object', type: 'object',
@@ -594,7 +545,7 @@ export const openApiSpec = {
health: { health: {
type: 'number', type: 'number',
description: 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.', '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 cannot fly until repaired (v0.45.0). While "repairing" this doubles as repair progress: it climbs back to 100 over the repair duration.',
}, },
repairUntilSimTime: { repairUntilSimTime: {
type: ['integer', 'null'], type: ['integer', 'null'],
@@ -633,6 +584,7 @@ export const openApiSpec = {
cargoCount: { type: 'integer' }, cargoCount: { type: 'integer' },
weightKg: { type: 'number', description: 'Package weight in kg (v0.27.0) — drives load validation and the per-kg payout part.' }, weightKg: { type: 'number', description: 'Package weight in kg (v0.27.0) — drives load validation and the per-kg payout part.' },
special: { type: ['string', 'null'], description: 'Special status from the specials registry (v0.37.0); null = none.' }, special: { type: ['string', 'null'], description: 'Special status from the specials registry (v0.37.0); null = none.' },
distanceKm: { type: 'number', description: 'Great-circle leg distance in km (v0.48.0) — computed server-side from airport coordinates, not stored.' },
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.
@@ -651,6 +603,7 @@ export const openApiSpec = {
'cargoType', 'cargoType',
'cargoCount', 'cargoCount',
'weightKg', // v0.27.0 (ISS-045) — package weight in kg 'weightKg', // v0.27.0 (ISS-045) — package weight in kg
'distanceKm', // v0.48.0 (ISS-095) — great-circle leg distance in km
'deadlineSimTime', 'deadlineSimTime',
'status', 'status',
'planeId', 'planeId',
+165 -166
View File
@@ -8,28 +8,35 @@ import * as schema from '@/lib/db/schema';
import { loadAirportsGeoJson } from '@/lib/airports'; import { loadAirportsGeoJson } from '@/lib/airports';
import { haversineKm, initialBearingDeg } from '@/lib/geo'; import { haversineKm, initialBearingDeg } from '@/lib/geo';
import { weatherFor } from '@/lib/weather'; import { weatherFor } from '@/lib/weather';
import { BROKEN_BURN_MULTIPLIER, conditionBurnMultiplier, FUEL_DISCOUNT_PER_REP, FUEL_PRICE_PER_L, MAX_FUEL_DISCOUNT, PLANE_TYPES, repRate, WEAR_PER_KM } from '@/lib/game/catalog'; import { FUEL_DISCOUNT_PER_REP, FUEL_PRICE_PER_L, MAX_FUEL_DISCOUNT, PLANE_TYPES, REPAIR_DURATION_MS, repRate, WEAR_PER_KM } from '@/lib/game/catalog';
import { SPECIALS } from '@/lib/game/specials'; import { SPECIALS } from '@/lib/game/specials';
import { MARKET_TARGET_OPEN_PACKAGES, mulberry32 } from '@/lib/game/seed'; import { MARKET_TARGET_OPEN_PACKAGES, mulberry32, STARTING_CLOCK_MS } from '@/lib/game/seed';
import { arrivalTimeFor, fuelAtWeighted, requiredFuelFor, windBurnFactor } from './movement'; import { arrivalTimeFor, fuelAtWeighted, requiredFuelFor, windBurnFactor } from './movement';
import { import {
clampTimeScale,
deliverPackages, deliverPackages,
expirePackages, expirePackages,
finishRepairs, finishRepairs,
MAX_TICK_ELAPSED_MS, MAX_TICK_ELAPSED_MS,
MAX_TIME_SCALE,
MIN_TIME_SCALE,
reputationGainFor, reputationGainFor,
shouldAdvanceTime,
spawnCoveragePackages, spawnCoveragePackages,
spawnOpenPackages, spawnOpenPackages,
tickOnce, tickOnce,
WORLD_TIME_SCALE,
} from './engine'; } from './engine';
// Engine tests run against throwaway DB files (openGameDb) with fake timers so // Engine tests run against throwaway DB files (openGameDb) with fake timers so
// Date.now() — the only wall-clock input to a tick — is fully controlled. The // Date.now() — the only wall-clock input to a tick — is fully controlled. The
// public tickNow()/applyTimeControls() wrappers (singleton + lock) are covered // public tickNow()/setTestTimeScale() wrappers (singleton + lock) are covered by the e2e suite
// by app/api/sim/route.test.ts and the e2e suite. // (every spec's setScale helper hits /api/test/set-timescale; sim.spec.ts pins its validation).
// ISS-117: fresh DBs seed a GROUNDED starter, so any test that needs an advancing world must put
// a plane in flight first — departureSimTime at the game-clock origin keeps its arrival hours away.
async function makeWorldActive(inst: Awaited<ReturnType<typeof openGameDb>>) {
await inst.db.update(schema.planes)
.set({ status: 'flying', destIcao: 'LEMD', departureSimTime: STARTING_CLOCK_MS })
.where(eq(schema.planes.id, 'plane-starter'));
}
let dir: string; let dir: string;
const T0 = 1_750_000_000_000; // seed moment / fake "now" at DB open const T0 = 1_750_000_000_000; // seed moment / fake "now" at DB open
@@ -53,43 +60,46 @@ async function freshDb(name: string) {
describe('tickOnce — clock advance', () => { describe('tickOnce — clock advance', () => {
it('advances the clock by elapsed real ms × timeScale', async () => { it('advances the clock by elapsed real ms × timeScale', async () => {
const inst = await freshDb('scale.db'); const inst = await freshDb('scale.db');
await makeWorldActive(inst); // a grounded fleet would freeze the world (ISS-117)
vi.setSystemTime(T0 + 1_000); // one real second later vi.setSystemTime(T0 + 1_000); // one real second later
await inst.db.update(schema.simState).set({ timeScale: 5 }); await inst.db.update(schema.simState).set({ timeScale: 5 });
const sim = await tickOnce(inst); const sim = await tickOnce(inst);
expect(sim.clockMs).toBe(T0 + 5_000); // 1 s × 5 expect(sim.clockMs).toBe(STARTING_CLOCK_MS + 5_000); // 1 s × 5
expect(sim.lastRealTickMs).toBe(T0 + 1_000); expect(sim.lastRealTickMs).toBe(T0 + 1_000);
}); });
it('freezes the clock while paused but keeps tracking real time', async () => { it('freezes the clock while the fleet is fully grounded but keeps tracking real time', async () => {
const inst = await freshDb('pause.db'); const inst = await freshDb('idle.db'); // seeded starter is on the ground → idle (ISS-117)
await inst.db.update(schema.simState).set({ paused: true, timeScale: 60 });
vi.setSystemTime(T0 + 3_600_000); // an hour of real pause vi.setSystemTime(T0 + 3_600_000); // an hour of real idleness
let sim = await tickOnce(inst); let sim = await tickOnce(inst);
expect(sim.clockMs).toBe(T0); // frozen — no advance at all expect(sim.clockMs).toBe(STARTING_CLOCK_MS); // frozen — no advance at all
expect(sim.lastRealTickMs).toBe(T0 + 3_600_000); expect(sim.lastRealTickMs).toBe(T0 + 3_600_000);
// Unpause: resumes from where it stopped, with NO catch-up for the pause. // A plane takes off: resumes from where it stopped, with NO catch-up for the idle time.
await inst.db.update(schema.simState).set({ paused: false }); await inst.db.update(schema.simState).set({ timeScale: 60 });
await makeWorldActive(inst);
vi.setSystemTime(T0 + 3_600_500); vi.setSystemTime(T0 + 3_600_500);
sim = await tickOnce(inst); sim = await tickOnce(inst);
expect(sim.clockMs).toBe(T0 + 500 * 60); // only the 500 ms since unpausing × 60 expect(sim.clockMs).toBe(STARTING_CLOCK_MS + 500 * 60); // only the 500 ms since activity × 60
}); });
it('clamps long absences: a 1-hour gap advances at most MAX_TICK_ELAPSED_MS × scale', async () => { it('clamps long absences: a 1-hour gap advances at most MAX_TICK_ELAPSED_MS × scale', async () => {
const inst = await freshDb('clamp.db'); const inst = await freshDb('clamp.db');
await makeWorldActive(inst);
vi.setSystemTime(T0 + 3_600_000); // server sat idle for an hour vi.setSystemTime(T0 + 3_600_000); // server sat idle for an hour
const sim = await tickOnce(inst); // timeScale is the seeded 1 const sim = await tickOnce(inst); // timeScale is the seeded WORLD_TIME_SCALE
expect(sim.clockMs).toBe(T0 + MAX_TICK_ELAPSED_MS); // exactly one clamped tick, not a fast-forward expect(sim.clockMs).toBe(STARTING_CLOCK_MS + MAX_TICK_ELAPSED_MS * WORLD_TIME_SCALE); // exactly one clamped tick, not a fast-forward
vi.setSystemTime(T0); // reset for later tests in this file vi.setSystemTime(T0); // reset for later tests in this file
}); });
it('does not advance when two ticks land in the same millisecond', async () => { it('does not advance when two ticks land in the same millisecond', async () => {
const inst = await freshDb('same-ms.db'); const inst = await freshDb('same-ms.db');
await makeWorldActive(inst);
vi.setSystemTime(T0 + 1_000); vi.setSystemTime(T0 + 1_000);
const a = await tickOnce(inst); const a = await tickOnce(inst);
const b = await tickOnce(inst); // no real time passed since `a` const b = await tickOnce(inst); // no real time passed since `a`
@@ -100,15 +110,17 @@ describe('tickOnce — clock advance', () => {
describe('expirePackages', () => { describe('expirePackages', () => {
it('flips open AND loaded packages past their deadline; terminal statuses never expire', async () => { it('flips open AND loaded packages past their deadline; terminal statuses never expire', async () => {
const inst = await freshDb('expiry.db'); const inst = await freshDb('expiry.db');
const clock = T0 + 100_000; // Game-clock anchor just after the seed moment — well inside every seeded package's deadline
// window (DEADLINE_MIN_HOURS), so only the rows inserted below can expire.
const clock = STARTING_CLOCK_MS + 100_000;
await inst.db.insert(schema.packages).values([ await inst.db.insert(schema.packages).values([
{ id: 'p-open-past', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: clock - 1, status: 'open', planeId: null, createdSimTime: T0 }, { id: 'p-open-past', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: clock - 1, status: 'open', planeId: null, createdSimTime: STARTING_CLOCK_MS },
{ id: 'p-open-future', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: clock + 1, status: 'open', planeId: null, createdSimTime: T0 }, { id: 'p-open-future', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: clock + 1, status: 'open', planeId: null, createdSimTime: STARTING_CLOCK_MS },
{ 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: STARTING_CLOCK_MS },
{ 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: STARTING_CLOCK_MS },
{ 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: STARTING_CLOCK_MS },
{ id: 'p-ignored-past', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: clock - 1, status: 'ignored', 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: STARTING_CLOCK_MS },
]); ]);
const n = await expirePackages(inst, clock); const n = await expirePackages(inst, clock);
@@ -127,9 +139,9 @@ describe('expirePackages', () => {
const inst = await freshDb('expiry-edge.db'); const inst = await freshDb('expiry-edge.db');
await inst.db.insert(schema.packages).values({ await inst.db.insert(schema.packages).values({
id: 'p-edge', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1, id: 'p-edge', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1,
deadlineSimTime: T0 + 50_000, status: 'open', planeId: null, createdSimTime: T0, deadlineSimTime: STARTING_CLOCK_MS + 50_000, status: 'open', planeId: null, createdSimTime: STARTING_CLOCK_MS,
}); });
expect(await expirePackages(inst, T0 + 50_000)).toBe(1); // <= is inclusive expect(await expirePackages(inst, STARTING_CLOCK_MS + 50_000)).toBe(1); // <= is inclusive
}); });
}); });
@@ -313,17 +325,16 @@ describe('deliverPackages (v0.12.0 arrival delivery)', () => {
const clock = T0 + travelMs(km, type.speedKmh); // exactly at arrival const clock = T0 + travelMs(km, type.speedKmh); // exactly at arrival
expect(await deliverPackages(inst, clock)).toBe(1); expect(await deliverPackages(inst, clock)).toBe(1);
// Expected top-up from the SAME helpers the engine uses: landed fuel (≈ 0), post-wear health, // Expected top-up from the SAME helpers the engine uses: landed fuel (≈ 0), wind-adjusted
// wind-adjusted requirement for LEMD→EHAM, ×1.5 margin capped at tank capacity. // requirement for LEMD→EHAM, ×1.5 margin capped at tank capacity.
const fc = await loadAirportsGeoJson(); const fc = await loadAirportsGeoJson();
const nextP = fc.features.find((x) => x.properties.icao === 'EHAM')!.properties; const nextP = fc.features.find((x) => x.properties.icao === 'EHAM')!.properties;
const landedFuel = fuelAtWeighted(depFuel, type.burnPerKm * f, type.emptyWeightKg, type.maxFuel, km, 1); const landedFuel = fuelAtWeighted(depFuel, type.burnPerKm * f, type.emptyWeightKg, type.maxFuel, km, 1);
expect(landedFuel).toBeCloseTo(0, 6); // sanity: arrived empty — the top-up path is what ran expect(landedFuel).toBeCloseTo(0, 6); // sanity: arrived empty — the top-up path is what ran
const newHealth = Math.max(0, 100 - WEAR_PER_KM * km);
const wTo = weatherFor(TO); // the chained leg departs from the arrival airport 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)); const fNext = windBurnFactor(wTo.windKt, wTo.windDirDeg, initialBearingDeg(to.lat, to.lon, nextP.lat, nextP.lon));
const nextRequiredL = requiredFuelFor( const nextRequiredL = requiredFuelFor(
type.burnPerKm * fNext * conditionBurnMultiplier(newHealth), type.burnPerKm * fNext,
type.emptyWeightKg, type.emptyWeightKg,
type.maxFuel, type.maxFuel,
haversineKm(to.lat, to.lon, nextP.lat, nextP.lon), haversineKm(to.lat, to.lon, nextP.lat, nextP.lon),
@@ -379,9 +390,55 @@ describe('deliverPackages (v0.12.0 arrival delivery)', () => {
expect(plane.status).toBe('available'); // unchanged landing behavior… expect(plane.status).toBe('available'); // unchanged landing behavior…
expect(JSON.parse(plane.queue)).toEqual([]); // …and the queue stays empty expect(JSON.parse(plane.queue)).toEqual([]); // …and the queue stays empty
}); });
it('grounds with the queue intact when the next leg does not fit even a full tank (v0.46.0 backstop)', async () => {
const { inst, to, km, type } = await setupFlight('chain-nofit.db'); // full tank EDDF→LEMD
const fc = await loadAirportsGeoJson();
const farP = fc.features.find((f) => f.properties.icao === 'FGSL')!.properties; // ~4267 km from LEMD — beyond ATR range
// Precondition sanity: the LEMD→FGSL hop genuinely does not fit a full tank (wind-adjusted,
// exactly as deliverPackages computes it) — so no top-up could ever make it flyable.
const wTo = weatherFor(TO);
const fNext = windBurnFactor(wTo.windKt, wTo.windDirDeg, initialBearingDeg(to.lat, to.lon, farP.lat, farP.lon));
const nextRequiredL = requiredFuelFor(
type.burnPerKm * fNext,
type.emptyWeightKg,
type.maxFuel,
haversineKm(to.lat, to.lon, farP.lat, farP.lon),
);
expect(nextRequiredL).toBeGreaterThan(type.maxFuel);
await inst.db.update(schema.planes)
.set({ queue: JSON.stringify(['FGSL']) })
.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 — the unflyable leg was NOT chained…
expect(plane.destIcao).toBeNull();
expect(JSON.parse(plane.queue)).toEqual(['FGSL']); // …queue kept intact for a later dispatch
});
it('drops a queued leg to the arrival airport without flying — defensive guard for legacy rows (v0.46.0)', async () => {
const { inst, km, type } = await setupFlight('chain-selfleg.db'); // full tank EDDF→LEMD
// Legacy state: queue[0] is the airport we are about to land at — pre-v0.46.0 this chained a
// zero-distance self-hop (the phantom hop). The guard drops it and grounds instead.
await inst.db.update(schema.planes)
.set({ queue: JSON.stringify(['LEMD', 'EHAM']) })
.where(eq(schema.planes.id, 'plane-starter'));
expect(await deliverPackages(inst, T0 + travelMs(km, type.speedKmh))).toBe(1);
const plane = (await inst.db.select().from(schema.planes))[0];
expect(plane.status).toBe('available'); // grounded — the self-leg was dropped, not flown…
expect(plane.destIcao).toBeNull();
expect(JSON.parse(plane.queue)).toEqual(['EHAM']); // …the rest of the queue kept for a later dispatch
});
}); });
describe('wear on landed legs + broken burn (v0.39.0, ISS-069)', () => { describe('wear on landed legs (v0.39.0, ISS-069)', () => {
const FROM = 'EDDF'; const FROM = 'EDDF';
const TO = 'LEMD'; const TO = 'LEMD';
@@ -433,27 +490,22 @@ describe('wear on landed legs + broken burn (v0.39.0, ISS-069)', () => {
expect(plane.health).toBe(0); // still clamped after the second leg 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 () => { it('a plane that lands broken does NOT chain — grounded with the queue intact (v0.45.0, ISS-089)', async () => {
const { inst, from, to, km, type } = await setupFlight('wear-broken.db'); const { inst, km, type } = await setupFlight('wear-broken-nochain.db'); // full tank EDDF→LEMD
await inst.db.update(schema.planes).set({ health: 0 }).where(eq(schema.planes.id, 'plane-starter')); // Start below one leg's wear so this landing clamps health to 0 — a full-tank departure leaves
// enough fuel for the queued LEMD→EHAM hop (same precondition as chain-ok), so ONLY the broken
// guard can explain why it doesn't chain.
await inst.db.update(schema.planes)
.set({ health: WEAR_PER_KM * km / 2, queue: JSON.stringify(['EHAM']) })
.where(eq(schema.planes.id, 'plane-starter'));
expect(await deliverPackages(inst, T0 + travelMs(km, type.speedKmh))).toBe(1); 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]; 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.health).toBe(0); // broken by this landing's wear…
expect(plane.fuel).toBeCloseTo( expect(plane.status).toBe('available'); // …grounded — the chain was skipped despite enough fuel…
fuelAtWeighted(type.maxFuel, type.burnPerKm * f * BROKEN_BURN_MULTIPLIER, type.emptyWeightKg, type.maxFuel, km, 1), expect(plane.destIcao).toBeNull();
6, expect(JSON.parse(plane.queue)).toEqual(['EHAM']); // …queue kept intact for a later dispatch
);
// …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
}); });
}); });
@@ -476,114 +528,81 @@ describe('finishRepairs (v0.39.0, ISS-069)', () => {
it('runs inside tickOnce even when nothing landed: a past gate flips the plane back', async () => { it('runs inside tickOnce even when nothing landed: a past gate flips the plane back', async () => {
const inst = await freshDb('repair-tick.db'); const inst = await freshDb('repair-tick.db');
// The repairing plane keeps the world advancing (ISS-117); scale pinned to 1 for clean math.
await inst.db.update(schema.simState).set({ timeScale: 1 });
await inst.db.update(schema.planes) await inst.db.update(schema.planes)
.set({ status: 'repairing', repairUntilSimTime: T0 + 1_500 }) .set({ status: 'repairing', repairUntilSimTime: STARTING_CLOCK_MS + 1_500 })
.where(eq(schema.planes.id, 'plane-starter')); .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 vi.setSystemTime(T0 + 2_000); // one clamped tick at scale 1 → clock STARTING_CLOCK_MS+2000 ≥ gate
await tickOnce(inst); await tickOnce(inst);
const plane = (await inst.db.select().from(schema.planes))[0]; const plane = (await inst.db.select().from(schema.planes))[0];
expect(plane.status).toBe('available'); expect(plane.status).toBe('available');
expect(plane.repairUntilSimTime ?? null).toBeNull(); expect(plane.repairUntilSimTime ?? null).toBeNull();
}); });
it('heals gradually along round(f × 100) while repairing; lands exactly on 100 + available at the gate (v0.45.0, ISS-089)', async () => {
const inst = await freshDb('repair-curve.db');
// Repair started at T0 with health 42 — the gate is what repairPlane stores: start + duration.
const gate = T0 + REPAIR_DURATION_MS;
await inst.db.update(schema.planes)
.set({ status: 'repairing', health: 42, repairUntilSimTime: gate })
.where(eq(schema.planes.id, 'plane-starter'));
// Mid-repair (f = 0.5): the curve (round(50)) is above the start value → health climbs to 50…
expect(await finishRepairs(inst, T0 + REPAIR_DURATION_MS / 2)).toBe(0); // …but no flip before the gate…
let plane = (await inst.db.select().from(schema.planes))[0];
expect(plane.status).toBe('repairing');
expect(plane.health).toBe(50);
// …and it never DROPS below the start value: at f = 0.2 the curve (20) is below 42, so health stays put.
const inst2 = await freshDb('repair-curve-floor.db');
await inst2.db.update(schema.planes)
.set({ status: 'repairing', health: 42, repairUntilSimTime: gate })
.where(eq(schema.planes.id, 'plane-starter'));
expect(await finishRepairs(inst2, T0 + REPAIR_DURATION_MS / 5)).toBe(0);
const plane2 = (await inst2.db.select().from(schema.planes))[0];
expect(plane2.health).toBe(42); // max(p.health, round(f × 100)) — the start value is the floor
// At the gate: exactly 100 and back in service.
expect(await finishRepairs(inst, gate)).toBe(1);
plane = (await inst.db.select().from(schema.planes))[0];
expect(plane.status).toBe('available');
expect(plane.health).toBe(100);
expect(plane.repairUntilSimTime ?? null).toBeNull();
});
}); });
describe('slow to 1× when a landing leaves any plane on the ground (ISS-057)', () => { describe('idle-pause — the world freezes when nothing is happening (ISS-117)', () => {
const FROM = 'EDDF'; it('shouldAdvanceTime: grounded/empty fleets are idle; any flying or repairing plane advances', () => {
const TO = 'LEMD'; expect(shouldAdvanceTime([])).toEqual({ advance: false, flying: 0, repairing: 0 });
expect(shouldAdvanceTime([{ status: 'available' }, { status: 'available' }])).toEqual({ advance: false, flying: 0, repairing: 0 });
expect(shouldAdvanceTime([{ status: 'flying' }, { status: 'available' }])).toEqual({ advance: true, flying: 1, repairing: 0 });
expect(shouldAdvanceTime([{ status: 'repairing' }])).toEqual({ advance: true, flying: 0, repairing: 1 });
expect(shouldAdvanceTime([{ status: 'flying' }, { status: 'repairing' }])).toEqual({ advance: true, flying: 1, repairing: 1 });
});
// Starter flying EDDF→LEMD with departureSimTime set so its arrival is EXACTLY T0 — it('a fully grounded fleet freezes the clock but keeps tracking real time', async () => {
// any advancing tick past T0 finalizes it. timeScale is the value under test; const inst = await freshDb('idle-pause.db'); // seeded starter is on the ground → idle
// lastSelectedTimeScale mirrors what a manual POST /api/sim would have stored. vi.setSystemTime(T0 + 3_600_000);
async function setupLanding(name: string, timeScale: number) {
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.simState).set({ timeScale, lastSelectedTimeScale: timeScale }); const sim = await tickOnce(inst);
expect(sim.clockMs).toBe(STARTING_CLOCK_MS); // frozen — no advance at all
expect(sim.lastRealTickMs).toBe(T0 + 3_600_000); // …but the real-time anchor moved on
});
it('a repairing plane keeps the world advancing', async () => {
const inst = await freshDb('idle-repair.db');
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: 'repairing', repairUntilSimTime: STARTING_CLOCK_MS + REPAIR_DURATION_MS })
.where(eq(schema.planes.id, 'plane-starter')); .where(eq(schema.planes.id, 'plane-starter'));
return { inst, from, km, type }; vi.setSystemTime(T0 + 1_000);
}
it('last plane lands → sim slows to 1× WITHOUT pausing; lastSelectedTimeScale is preserved', async () => {
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)
const sim = await tickOnce(inst); const sim = await tickOnce(inst);
expect(sim.paused).toBe(false); // no pause anymore — ISS-057 replaces it with a slow-down… expect(sim.clockMs).toBeGreaterThan(STARTING_CLOCK_MS); // the repair gate is what keeps time moving
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
});
it('a landing while another plane is still flying → slows to 1× (any plane on ground)', async () => {
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.
await inst.db.insert(schema.planes).values({
id: 'p-b', model: 'atr72', status: 'flying', fuel: type.maxFuel, maxFuel: type.maxFuel,
locationIcao: FROM, destIcao: TO, lat: from.lat, lon: from.lon, departureSimTime: T0,
});
vi.setSystemTime(T0 + 2_000);
const sim = await tickOnce(inst);
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]));
expect(byId.get('plane-starter')).toBe('available'); // the starter did land…
expect(byId.get('p-b')).toBe('flying'); // …but the fleet is not idle — still slowed
});
it('already at 1× → stays 1 (no-op slow)', async () => {
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
const sim = await tickOnce(inst);
expect(sim.paused).toBe(false);
expect(sim.timeScale).toBe(1);
expect((await inst.db.select().from(schema.planes))[0].status).toBe('available');
});
it('a chained plane does NOT slow the sim (nothing is grounded)', async () => {
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
// controls even though "any plane on ground" holds: the trigger requires a landing this tick.
await inst.db.update(schema.simState).set({ timeScale: 1000, lastSelectedTimeScale: 1000, paused: true });
vi.setSystemTime(T0 + 5_000);
const sim = await tickOnce(inst);
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');
}); });
}); });
@@ -718,7 +737,13 @@ describe('spawnCoveragePackages (v0.8.0 spawn coverage rule)', () => {
it('runs inside tickOnce: a fresh DB gains an EDDF-origin package after one advancing tick', async () => { it('runs inside tickOnce: a fresh DB gains an EDDF-origin package after one advancing tick', async () => {
const inst = await freshDb('coverage-tick.db'); const inst = await freshDb('coverage-tick.db');
vi.setSystemTime(T0 + 1_000); // one real second → advanceMs > 0 at scale 1 // A second, airborne plane keeps the world advancing (ISS-117) while the seeded starter stays
// grounded — which is what coverage spawns for. Arrival is hours out, so it never lands here.
await inst.db.insert(schema.planes).values({
id: 'p-air', model: 'atr72', status: 'flying', fuel: 6300, maxFuel: 6300,
locationIcao: 'EDDF', destIcao: 'LEMD', lat: 50.03, lon: 8.57, departureSimTime: STARTING_CLOCK_MS,
});
vi.setSystemTime(T0 + 1_000); // one real second → advanceMs > 0 at the seeded scale
await tickOnce(inst); await tickOnce(inst);
@@ -729,32 +754,6 @@ describe('spawnCoveragePackages (v0.8.0 spawn coverage rule)', () => {
}); });
}); });
describe('clampTimeScale — v0.14.0 ladder bounds [1, 1000]', () => {
it('clamps below-minimum values to MIN_TIME_SCALE', () => {
expect(clampTimeScale(0)).toBe(MIN_TIME_SCALE);
expect(clampTimeScale(-5)).toBe(MIN_TIME_SCALE);
expect(clampTimeScale(0.1)).toBe(MIN_TIME_SCALE);
});
it('clamps above-maximum values to MAX_TIME_SCALE', () => {
expect(clampTimeScale(1001)).toBe(MAX_TIME_SCALE);
expect(clampTimeScale(2000)).toBe(MAX_TIME_SCALE);
expect(clampTimeScale(Number.MAX_SAFE_INTEGER)).toBe(MAX_TIME_SCALE);
});
it('passes in-range values through unclamped — ladder and off-ladder alike', () => {
for (const s of [1, 2, 5, 10, 25, 50, 100, 250, 500, 1000]) {
expect(clampTimeScale(s)).toBe(s); // the full v0.14.0 slider ladder
}
expect(clampTimeScale(37)).toBe(37); // off-ladder but in range (e.g. a legacy value)
});
it('keeps the endpoints exact', () => {
expect(clampTimeScale(MIN_TIME_SCALE)).toBe(MIN_TIME_SCALE);
expect(clampTimeScale(MAX_TIME_SCALE)).toBe(MAX_TIME_SCALE);
});
});
// Local helper mirroring lib/sim/movement.ts (kept private there on purpose — the // Local helper mirroring lib/sim/movement.ts (kept private there on purpose — the
// engine composes arrivalTimeFor; tests just need the duration for fixtures). // engine composes arrivalTimeFor; tests just need the duration for fixtures).
function travelMs(km: number, speedKmh: number): number { function travelMs(km: number, speedKmh: number): number {
+111 -100
View File
@@ -5,7 +5,7 @@ import * as schema from '@/lib/db/schema';
import { loadAirportsGeoJson } from '@/lib/airports'; import { loadAirportsGeoJson } from '@/lib/airports';
import { haversineKm, initialBearingDeg } from '@/lib/geo'; import { haversineKm, initialBearingDeg } from '@/lib/geo';
import { weatherFor } from '@/lib/weather'; import { weatherFor } from '@/lib/weather';
import { conditionBurnMultiplier, FUEL_DISCOUNT_PER_REP, FUEL_PRICE_PER_L, MAX_FUEL_DISCOUNT, PLANE_TYPES, repRate, WEAR_PER_KM } from '@/lib/game/catalog'; import { FUEL_DISCOUNT_PER_REP, FUEL_PRICE_PER_L, MAX_FUEL_DISCOUNT, PLANE_TYPES, REPAIR_DURATION_MS, repRate, WEAR_PER_KM } from '@/lib/game/catalog';
import { SPECIALS } from '@/lib/game/specials'; import { SPECIALS } from '@/lib/game/specials';
import { parseIcaoQueue } from '@/lib/game/state'; import { parseIcaoQueue } from '@/lib/game/state';
import { import {
@@ -18,20 +18,20 @@ import {
} from '@/lib/game/seed'; } from '@/lib/game/seed';
import { arrivalTimeFor, fuelAtWeighted, requiredFuelFor, windBurnFactor } 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). ISS-117 (v0.52.0): the clock advances on a background
// timer. Every read/action endpoint calls tickNow() first; the clock advances by // tick (~1 s, started at boot from instrumentation.ts) AND on every read/action — both through the
// (real ms since last tick) × timeScale, clamped per tick so long absences never // same locked tickOnce path. Each tick advances by (real ms since last tick) × timeScale, clamped
// fast-forward the game. // per tick so long absences never fast-forward the game; while no plane is flying or in repair the
// clock stays frozen (idle-pause) but lastRealTickMs keeps tracking real time.
// Clamp constant: max real-time gap counted by a single tick. Chosen at 2000 ms — // Clamp constant: max real-time gap counted by a single tick. Chosen at 2000 ms — comfortably above
// comfortably above a normal client poll interval (~1 s), so active play advances // both the client poll interval (~1 s) and the background-tick interval, so active play advances
// ~real time × scale; small enough that one stale read after an absence moves game // ~real time × scale; small enough that one stale read after an absence moves game time by at most
// time by at most 2 s × scale (≈ 33 min even at max scale 1000). The sim therefore // 2 s × scale.
// only meaningfully progresses while actively polled.
export const MAX_TICK_ELAPSED_MS = 2_000; export const MAX_TICK_ELAPSED_MS = 2_000;
export const MIN_TIME_SCALE = 1; // ISS-117: the fixed world rate — re-exported here so server code and tests have one familiar home.
export const MAX_TIME_SCALE = 1000; // top of the slider ladder (v0.14.0) export { WORLD_TIME_SCALE } from '@/lib/worldClock';
// Reputation gain per delivered package: BASE + PER_KM × great-circle km, rounded // Reputation gain per delivered package: BASE + PER_KM × great-circle km, rounded
// to 2 decimals (reputation is REAL). A short hop (~300 km) earns ~1.15 rep, a // to 2 decimals (reputation is REAL). A short hop (~300 km) earns ~1.15 rep, a
@@ -43,11 +43,6 @@ export function reputationGainFor(km: number): number {
return Math.round((REP_BASE + REP_PER_KM * km) * 100) / 100; return Math.round((REP_BASE + REP_PER_KM * km) * 100) / 100;
} }
export interface SimControlsInput {
timeScale?: number;
paused?: boolean;
}
// Serialize ticks across concurrent requests (Next.js route handlers run in the // Serialize ticks across concurrent requests (Next.js route handlers run in the
// same process): each tick re-reads sim_state after the previous one wrote it, so // same process): each tick re-reads sim_state after the previous one wrote it, so
// no advance is ever applied twice. // no advance is ever applied twice.
@@ -63,23 +58,36 @@ function withLock<T>(fn: () => Promise<T>): Promise<T> {
// --- Tick core (takes a GameDb so unit tests can inject their own instance) --- // --- Tick core (takes a GameDb so unit tests can inject their own instance) ---
// ISS-117: idle-pause decision — pure, so unit tests feed it synthetic fleets. The world clock
// advances only while something is happening: at least one plane flying OR in repair. A fully
// grounded fleet freezes the clock (and with it expiries/spawns) until a dispatch or repair starts.
export function shouldAdvanceTime(planes: Array<{ status: string }>): { advance: boolean; flying: number; repairing: number } {
let flying = 0;
let repairing = 0;
for (const p of planes) {
if (p.status === 'flying') flying++;
else if (p.status === 'repairing') repairing++;
}
return { advance: flying > 0 || repairing > 0, flying, repairing };
}
export async function tickOnce(inst: GameDb): Promise<schema.SimStateRow> { export async function tickOnce(inst: GameDb): Promise<schema.SimStateRow> {
const rows = await inst.db.select().from(schema.simState); const rows = await inst.db.select().from(schema.simState);
const sim = rows[0]; const sim = rows[0];
if (!sim) throw new Error('sim_state row missing'); if (!sim) throw new Error('sim_state row missing');
const nowMs = Date.now(); const nowMs = Date.now();
// ISS-117: frozen while nothing is happening (see shouldAdvanceTime). lastRealTickMs still
// tracks real time, so a takeoff resumes cleanly with no catch-up jump for the idle duration.
const planeRows = await inst.db.select().from(schema.planes);
const { advance } = shouldAdvanceTime(planeRows.map((p) => ({ status: p.status })));
const elapsed = Math.max(0, nowMs - sim.lastRealTickMs); const elapsed = Math.max(0, nowMs - sim.lastRealTickMs);
// While paused the clock is frozen; lastRealTickMs still tracks real time so const advanceMs = advance ? Math.min(elapsed, MAX_TICK_ELAPSED_MS) * sim.timeScale : 0;
// unpausing resumes cleanly with no catch-up jump for the pause duration.
const advanceMs = sim.paused ? 0 : Math.min(elapsed, MAX_TICK_ELAPSED_MS) * sim.timeScale;
const clockMs = Math.round(sim.clockMs + advanceMs); const clockMs = Math.round(sim.clockMs + advanceMs);
let landedThisTick = 0;
if (advanceMs > 0) { if (advanceMs > 0) {
await expirePackages(inst, clockMs); await expirePackages(inst, clockMs);
// ISS-057: remember the landings so the tick can slow down when a plane is left on ground. 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 // 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. // nothing landed this tick; BEFORE the spawns so a just-repaired plane gets coverage work too.
await finishRepairs(inst, clockMs); await finishRepairs(inst, clockMs);
@@ -89,25 +97,8 @@ export async function tickOnce(inst: GameDb): Promise<schema.SimStateRow> {
await spawnCoveragePackages(inst, clockMs); await spawnCoveragePackages(inst, clockMs);
} }
const sets: Partial<Pick<schema.SimStateRow, 'clockMs' | 'lastRealTickMs' | 'timeScale'>> = {
clockMs,
lastRealTickMs: nowMs,
};
// Slow on landing (ISS-057): when ≥1 plane landed this tick and ANY plane is grounded
// ('available') afterwards, drop the sim to MIN_TIME_SCALE — 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 is manual-only now;
// flyPlane restores lastSelectedTimeScale when the last grounded plane takes off. Arrival-
// triggered by design: a grounded fleet at high scale is left alone (no idle-enforcement), and
// a paused tick never processes landings (advanceMs === 0), so the trigger only fires on an
// advancing tick — a manual speed change while planes sit grounded survives until the next landing.
if (landedThisTick > 0) {
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.
await inst.db.update(schema.simState).set(sets); await inst.db.update(schema.simState).set({ clockMs, lastRealTickMs: nowMs });
await persist(inst); await persist(inst);
const updated = (await inst.db.select().from(schema.simState))[0]; const updated = (await inst.db.select().from(schema.simState))[0];
if (!updated) throw new Error('sim_state row missing after tick'); if (!updated) throw new Error('sim_state row missing after tick');
@@ -130,18 +121,32 @@ 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 // Repairs heal gradually (v0.45.0, ISS-089): while a plane is 'repairing', its health climbs along
// (v0.39.0, ISS-069) — same shape as expirePackages: one guarded pass over the rows, // round(f × 100), where f is the fraction of REPAIR_DURATION_MS elapsed since the repair started —
// `<=` inclusive at the clock. Returns the count flipped. // derived from the stored gate alone (start = gate − duration), so no schema change. The plane flips
// back to 'available' exactly when it reaches full health, i.e. at the gate. Same shape as
// expirePackages: one guarded pass over the rows. Returns the count flipped.
export async function finishRepairs(inst: GameDb, clockMs: number): Promise<number> { 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')); const rows = await inst.db.select().from(schema.planes).where(eq(schema.planes.status, 'repairing'));
let n = 0; let n = 0;
for (const p of rows) { for (const p of rows) {
if (p.repairUntilSimTime !== null && p.repairUntilSimTime <= clockMs) { if (p.repairUntilSimTime === null) continue; // defensive — repairing always carries a gate
// ponytail: constant duration regardless of starting health — the curve is absolute (f × 100), so a
// plane at 90 heals in the same sim time as one at 5. Proportional duration would need storing the
// start health; add a column if balance wants it. The flip stays strictly at the gate (`<=` inclusive,
// as before) — round(f × 100) can hit 100 a hair early (f ≥ 0.995), which is cosmetic only: the plane
// is still 'repairing' and grounded until the gate passes.
const f = Math.min(1, Math.max(0, (clockMs - (p.repairUntilSimTime - REPAIR_DURATION_MS)) / REPAIR_DURATION_MS));
if (p.repairUntilSimTime <= clockMs) {
await inst.db.update(schema.planes) await inst.db.update(schema.planes)
.set({ status: 'available', repairUntilSimTime: null }) .set({ status: 'available', health: 100, repairUntilSimTime: null })
.where(eq(schema.planes.id, p.id)); .where(eq(schema.planes.id, p.id));
n++; n++;
} else {
const newHealth = Math.min(100, Math.max(p.health, Math.round(f * 100)));
if (newHealth !== p.health) {
await inst.db.update(schema.planes).set({ health: newHealth }).where(eq(schema.planes.id, p.id));
}
} }
} }
return n; return n;
@@ -152,15 +157,15 @@ export async function finishRepairs(inst: GameDb, clockMs: number): Promise<numb
// ones still inside their deadline are delivered (cash += reward + reputation // ones still inside their deadline are delivered (cash += reward + reputation
// gain per package), 'expired' ones just unbind (no reward). Packages destined // gain per package), 'expired' ones just unbind (no reward). Packages destined
// elsewhere stay loaded on the plane for a later leg. Then (v0.25.0, ISS-043) // elsewhere stay loaded on the plane for a later leg. Then (v0.25.0, ISS-043)
// auto-chain: if the queue holds legs and fuel covers the first one, start it // auto-chain: if the queue holds legs, fuel covers the first one and the plane is not
// immediately from the arrival airport; with a short tank, ISS-082 (v0.42.0) tops // broken by this landing's wear (v0.45.0, ISS-089), start it immediately from the arrival
// airport; with a short tank, ISS-082 (v0.42.0) tops
// up to 150% of the requirement (capped at tank capacity) when cash allows and // up to 150% of the requirement (capped at tank capacity) when cash allows and
// chains anyway — only an unaffordable top-up grounds the plane there with its // chains anyway — only an unaffordable top-up grounds the plane there with its
// remaining fuel and the queue kept intact for a later dispatch. // remaining fuel and the queue kept intact for a later dispatch.
// Idempotent — a finalized plane either leaves the 'flying' state or starts a NEW // Idempotent — a finalized plane either leaves the 'flying' state or starts a NEW
// leg whose own arrival is in the future, so it can never be awarded twice. // leg whose own arrival is in the future, so it can never be awarded twice.
// Returns planes landed. NOTE: runs BEFORE tickOnce's slow-down check on purpose — // Returns planes landed.
// 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),
@@ -183,12 +188,10 @@ export async function deliverPackages(inst: GameDb, clockMs: number): Promise<nu
// 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 —
// wind-adjusted (v0.36.0) by departure weather vs route heading, same factor flyPlane used. // wind-adjusted (v0.36.0) by departure weather vs route heading, same factor flyPlane used.
// v0.39.0 (ISS-069): broken planes (health ≤ 0 at DEPARTURE) burn BROKEN_BURN_MULTIPLIER × more —
// p.health is the pre-landing value, so this stays bit-consistent with livePlane()'s gauge.
const w = weatherFor(p.locationIcao); const w = weatherFor(p.locationIcao);
const landedFuel = fuelAtWeighted( const landedFuel = fuelAtWeighted(
p.fuel, p.fuel,
type.burnPerKm * windBurnFactor(w.windKt, w.windDirDeg, initialBearingDeg(from.lat, from.lon, to.lat, to.lon)) * conditionBurnMultiplier(p.health), type.burnPerKm * windBurnFactor(w.windKt, w.windDirDeg, initialBearingDeg(from.lat, from.lon, to.lat, to.lon)),
type.emptyWeightKg, type.emptyWeightKg,
type.maxFuel, type.maxFuel,
distanceKm, distanceKm,
@@ -225,40 +228,51 @@ export async function deliverPackages(inst: GameDb, clockMs: number): Promise<nu
// (requiredFuelFor is independent of current load). ISS-082 (v0.42.0): manual refuel is gone, // (requiredFuelFor is independent of current load). ISS-082 (v0.42.0): manual refuel is gone,
// so a short tank no longer strands the chain — when cash allows, top up to 150% of the // so a short tank no longer strands the chain — when cash allows, top up to 150% of the
// requirement (capped at tank capacity) and chain anyway; unaffordable → ground here with the // requirement (capped at tank capacity) and chain anyway; unaffordable → ground here with the
// queue kept intact. An unknown ICAO in the queue simply cannot chain either way. // queue kept intact. An unknown ICAO in the queue simply cannot chain either way. v0.45.0
// (ISS-089): a plane that lands BROKEN (newHealth ≤ 0) never chains — it stays grounded with
// the queue intact until repaired, exactly like flyPlane's broken guard.
const queue = parseIcaoQueue(p.queue); const queue = parseIcaoQueue(p.queue);
let chainedTo: string | null = null; let chainedTo: string | null = null;
let topUpFuel: number | null = null; // post-top-up fuel for the chained departure, when a top-up happened let topUpFuel: number | null = null; // post-top-up fuel for the chained departure, when a top-up happened
if (queue.length > 0) { if (queue.length > 0 && newHealth > 0) {
const nextDest = queue[0]; const nextDest = queue[0];
const next = byIcao.get(nextDest); // unknown ICAO in the queue → cannot chain // ponytail: defensive guard for legacy rows — a queued leg to the airport we just landed at is a
if (next) { // zero-distance no-op hop; drop it and ground instead of flying. Normal play can't produce this
// Wind-adjusted requirement for the NEXT leg — it departs from `to` (v0.36.0). v0.39.0 (ISS-069): // since v0.46.0 (flyPlane consumes queue[0] on dispatch, every chain shifts its own leg).
// the chained leg starts AFTER this landing's wear, so its burn uses newHealth — exactly what if (nextDest === to.icao) {
// livePlane()/the next landing will compute for that flight. queue.shift();
const wNext = weatherFor(to.icao); } else {
const nextRequiredL = requiredFuelFor( const next = byIcao.get(nextDest); // unknown ICAO in the queue → cannot chain
type.burnPerKm * windBurnFactor(wNext.windKt, wNext.windDirDeg, initialBearingDeg(to.lat, to.lon, next.lat, next.lon)) * conditionBurnMultiplier(newHealth), if (next) {
type.emptyWeightKg, // Wind-adjusted requirement for the NEXT leg — it departs from `to` (v0.36.0).
type.maxFuel, const wNext = weatherFor(to.icao);
haversineKm(to.lat, to.lon, next.lat, next.lon), const nextRequiredL = requiredFuelFor(
); type.burnPerKm * windBurnFactor(wNext.windKt, wNext.windDirDeg, initialBearingDeg(to.lat, to.lon, next.lat, next.lon)),
if (nextRequiredL <= landedFuel) { type.emptyWeightKg,
queue.shift(); type.maxFuel,
chainedTo = nextDest; haversineKm(to.lat, to.lon, next.lat, next.lon),
} else { );
// ISS-082: auto top-up — same target/price terms as flyPlane's dispatch refuel. // v0.46.0 backstop: a leg that doesn't fit even a full tank is unflyable — ground with the queue
const target = Math.min(type.maxFuel, Math.max(landedFuel, nextRequiredL * 1.5)); // intact, mirroring flyPlane's 409 without throwing in the engine.
const liters = Math.max(0, target - landedFuel); if (nextRequiredL > type.maxFuel) {
// Fresh account read (same pattern as the delivery awards above) so a top-up in this tick sees current cash. // no chain — the grounded update below keeps the queue for a later dispatch
const fresh = (await inst.db.select().from(schema.account))[0]; } else if (nextRequiredL <= landedFuel) {
if (!fresh) throw new Error('account row missing');
const cost = Math.round(liters * FUEL_PRICE_PER_L * (1 - repRate(fresh.reputation, FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT)) * 100) / 100;
if (cost === 0 || fresh.cash >= cost) {
queue.shift(); queue.shift();
chainedTo = nextDest; chainedTo = nextDest;
topUpFuel = target; } else {
if (cost > 0) await inst.db.update(schema.account).set({ cash: Math.round((fresh.cash - cost) * 100) / 100 }); // ISS-082: auto top-up — same target/price terms as flyPlane's dispatch refuel.
const target = Math.min(type.maxFuel, Math.max(landedFuel, nextRequiredL * 1.5));
const liters = Math.max(0, target - landedFuel);
// Fresh account read (same pattern as the delivery awards above) so a top-up in this tick sees current cash.
const fresh = (await inst.db.select().from(schema.account))[0];
if (!fresh) throw new Error('account row missing');
const cost = Math.round(liters * FUEL_PRICE_PER_L * (1 - repRate(fresh.reputation, FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT)) * 100) / 100;
if (cost === 0 || fresh.cash >= cost) {
queue.shift();
chainedTo = nextDest;
topUpFuel = target;
if (cost > 0) await inst.db.update(schema.account).set({ cash: Math.round((fresh.cash - cost) * 100) / 100 });
}
} }
} }
} }
@@ -399,6 +413,17 @@ export function tickNow(): Promise<schema.SimStateRow> {
return withLock(async () => tickOnce(await getGameDb())); return withLock(async () => tickOnce(await getGameDb()));
} }
// ISS-117: the world clock runs even with nobody polling — a ~1 s interval drives the same locked
// tick path as reads/actions. Started once at server boot from instrumentation.ts (Next ≥15); the
// guard makes double-starts harmless (dev HMR reloads).
let backgroundTimer: ReturnType<typeof setInterval> | null = null;
export function startBackgroundTick(intervalMs = 1_000): void {
if (backgroundTimer !== null) return;
backgroundTimer = setInterval(() => {
tickNow().catch((err) => console.error('flight: background tick failed', err));
}, intervalMs);
}
// Run a game action atomically with respect to ticks (v0.8.0): the clock is // Run a game action atomically with respect to ticks (v0.8.0): the clock is
// advanced FIRST so validation and mutation see current game time, then fn runs — // advanced FIRST so validation and mutation see current game time, then fn runs —
// all under the same promise lock as tickNow(), so no concurrent request can // all under the same promise lock as tickNow(), so no concurrent request can
@@ -412,31 +437,17 @@ export function runAction<T>(fn: (inst: GameDb) => Promise<T>): Promise<T> {
}); });
} }
// Tick first so the returned state reflects current time, then apply the new // Test-hook backing (FLIGHT_TEST_HOOKS=1 only — see app/api/test/set-timescale/route.ts): set the
// controls. timeScale is clamped to [1, 1000]; validation of raw input happens in // effective time scale under the same lock as ticks so a background tick can never interleave
// the route handler (400 on non-number / non-boolean). ISS-057: posting a timeScale // between read and write. Input validation happens in the route; this trusts its number.
// ALSO records it as lastSelectedTimeScale — the resume target flyPlane restores when export function setTestTimeScale(scale: number): Promise<schema.SimStateRow> {
// 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> {
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' | 'lastSelectedTimeScale' | 'paused'>> = {}; await inst.db.update(schema.simState).set({ timeScale: scale });
if (input.timeScale !== undefined) {
const scale = clampTimeScale(input.timeScale);
sets.timeScale = scale;
sets.lastSelectedTimeScale = scale;
}
if (input.paused !== undefined) sets.paused = input.paused;
if (Object.keys(sets).length > 0) await inst.db.update(schema.simState).set(sets);
await persist(inst); await persist(inst);
const updated = (await inst.db.select().from(schema.simState))[0]; const updated = (await inst.db.select().from(schema.simState))[0];
if (!updated) throw new Error('sim_state row missing'); if (!updated) throw new Error('sim_state row missing');
return updated; return updated;
}); });
} }
export function clampTimeScale(v: number): number {
return Math.min(MAX_TIME_SCALE, Math.max(MIN_TIME_SCALE, v));
}
+21
View File
@@ -5,6 +5,8 @@ import {
burnRateConstant, burnRateConstant,
FUEL_DENSITY_KG_PER_L, FUEL_DENSITY_KG_PER_L,
fuelAtWeighted, fuelAtWeighted,
guaranteedRangeKm,
MAX_WIND_BURN_FACTOR,
maxRangeKm, maxRangeKm,
positionAt, positionAt,
progressAt, progressAt,
@@ -234,3 +236,22 @@ describe('maxRangeKm (v0.28.0)', () => {
expect(() => maxRangeKm(BURN, 0, TANK)).toThrow(); expect(() => maxRangeKm(BURN, 0, TANK)).toThrow();
}); });
}); });
describe('guaranteedRangeKm (v0.51.0, ISS-099)', () => {
it("MAX_WIND_BURN_FACTOR is windBurnFactor's actual maximum over the weather domain", () => {
// Full headwind at max wind: exactly +20% — the constant must track the factor function, not a copy.
expect(MAX_WIND_BURN_FACTOR).toBe(windBurnFactor(30, 90, 90));
});
it('is maxRangeKm under worst-case headwind burn (ATR ≈ 3696 km ±5%)', () => {
const r = guaranteedRangeKm(BURN, WE, TANK);
expect(r).toBeCloseTo(maxRangeKm(BURN * MAX_WIND_BURN_FACTOR, WE, TANK), 12);
expect(r).toBeGreaterThan(3696 * 0.95);
expect(r).toBeLessThan(3696 * 1.05);
});
it('zero fuel → zero range; rejects a non-positive empty weight', () => {
expect(guaranteedRangeKm(BURN, WE, 0)).toBe(0);
expect(() => guaranteedRangeKm(BURN, 0, TANK)).toThrow();
});
});
+13
View File
@@ -137,3 +137,16 @@ export function maxRangeKm(burnPerKm: number, emptyWeightKg: number, maxFuel: nu
const rho = FUEL_DENSITY_KG_PER_L; const rho = FUEL_DENSITY_KG_PER_L;
return Math.log(1 + (rho * maxFuel) / emptyWeightKg) * ((emptyWeightKg + rho * maxFuel) / (rho * burnPerKm)); return Math.log(1 + (rho * maxFuel) / emptyWeightKg) * ((emptyWeightKg + rho * maxFuel) / (rho * burnPerKm));
} }
// Max headwind burn multiplier over the weather domain (v0.51.0, ISS-099): the upper end of
// windBurnFactor's [0.8, 1.2] range — a full 30 kt headwind (the max in lib/weather.ts) ÷ 150.
export const MAX_WIND_BURN_FACTOR = 1 + 30 / 150;
// Guaranteed full-tank range (v0.51.0, ISS-099): the maxRangeKm closed form under worst-case
// headwind burn — any leg within this distance is flyable in ALL weather, since windBurnFactor
// never exceeds MAX_WIND_BURN_FACTOR and requiredFuelFor grows with the burn rate. This is what
// the UI displays as "Max range", so a spec-possible leg always passes flyPlane's fuel guard
// (requiredFuelFor ≤ maxFuel under the same wind-adjusted burn).
export function guaranteedRangeKm(burnPerKm: number, emptyWeightKg: number, maxFuel: number): number {
return maxRangeKm(burnPerKm * MAX_WIND_BURN_FACTOR, emptyWeightKg, maxFuel);
}
-81
View File
@@ -1,81 +0,0 @@
import { describe, expect, it } from 'vitest';
import { MAX_SCALE, MIN_SCALE, TIME_SCALES, scaleToSlider, sliderToScale } from './timeScale';
const STEPS = TIME_SCALES.length - 1; // 9 intervals across the 10 ladder positions (v0.14.0)
describe('sliderToScale', () => {
it('is exact at the endpoints (0 → 1×, 1 → 1000×)', () => {
expect(sliderToScale(0)).toBe(MIN_SCALE);
expect(sliderToScale(1)).toBe(MAX_SCALE);
});
it('maps each of the 10 positions to the ladder', () => {
const expected = [1, 2, 5, 10, 25, 50, 100, 250, 500, 1000];
for (let i = 0; i < TIME_SCALES.length; i++) {
expect(sliderToScale(i / STEPS)).toBe(expected[i]);
}
});
it('is monotonic non-decreasing across the whole range', () => {
let prev = sliderToScale(0);
for (let i = 1; i <= 2000; i++) {
const s = sliderToScale(i / 2000);
expect(s).toBeGreaterThanOrEqual(prev);
prev = s;
}
});
it('only ever returns a ladder value', () => {
for (let i = 0; i <= 2000; i++) {
expect(TIME_SCALES).toContain(sliderToScale(i / 2000));
}
});
it('clamps out-of-range and non-finite input', () => {
expect(sliderToScale(-0.5)).toBe(MIN_SCALE);
expect(sliderToScale(1.5)).toBe(MAX_SCALE);
expect(sliderToScale(NaN)).toBe(MIN_SCALE);
});
});
describe('scaleToSlider', () => {
it('is exact at the endpoints (1× → 0, 1000× → 1)', () => {
expect(scaleToSlider(1)).toBe(0);
expect(scaleToSlider(1000)).toBe(1);
});
it('places each ladder value on its own position', () => {
for (let i = 0; i < TIME_SCALES.length; i++) {
expect(scaleToSlider(TIME_SCALES[i])).toBeCloseTo(i / STEPS, 12);
}
});
it('stays within [0, 1] for in-range scales', () => {
for (let s = MIN_SCALE; s <= MAX_SCALE; s++) {
const t = scaleToSlider(s);
expect(t).toBeGreaterThanOrEqual(0);
expect(t).toBeLessThanOrEqual(1);
}
});
it('clamps out-of-range and non-finite input', () => {
expect(scaleToSlider(0)).toBe(0);
expect(scaleToSlider(-3)).toBe(0);
expect(scaleToSlider(1001)).toBe(1); // above the ladder top → clamped to 1000× → t=1
expect(scaleToSlider(NaN)).toBe(0);
});
});
describe('round-trip', () => {
it('sliderToScale(scaleToSlider(s)) === s for every ladder value', () => {
for (const s of TIME_SCALES) {
expect(sliderToScale(scaleToSlider(s))).toBe(s);
}
});
it('maps off-ladder scales to the nearest ladder step by log distance', () => {
expect(sliderToScale(scaleToSlider(3))).toBe(2); // ln 3 ≈ 1.10 → closer to 2 (ln ≈ 0.69) than 5 (ln ≈ 1.61)
expect(sliderToScale(scaleToSlider(60))).toBe(50); // legacy max from the v0.9.x era sits between 50 and 100, nearer 50
expect(sliderToScale(scaleToSlider(200))).toBe(250); // ln 200 ≈ 5.30 → closer to 250 (ln ≈ 5.52) than 100 (ln ≈ 4.61)
});
});
-33
View File
@@ -1,33 +0,0 @@
// Pure mapping helpers for the time-scale slider (v0.9.2, values reworked in v0.14.0) — no
// game logic, unit-testable in node. The slider has 10 discrete positions on a mixed
// ×2/×2.5 ladder: 1× → 2× → 5× → 10× → 25× → 50× → 100× → 250× → 500× → 1000×, so each
// step roughly doubles the speed (matching POST /api/sim's clamp range [1, 1000]).
export const TIME_SCALES = [1, 2, 5, 10, 25, 50, 100, 250, 500, 1000] as const;
export const MIN_SCALE: number = TIME_SCALES[0];
export const MAX_SCALE: number = TIME_SCALES[TIME_SCALES.length - 1];
const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));
// t → scale: the position maps to the nearest ladder step. Exact at the endpoints:
// sliderToScale(0) === 1, sliderToScale(1) === 1000.
export function sliderToScale(t: number): number {
const tt = clamp(Number.isFinite(t) ? t : 0, 0, 1);
return TIME_SCALES[clamp(Math.round(tt * (TIME_SCALES.length - 1)), 0, TIME_SCALES.length - 1)];
}
// scale → t: the nearest ladder position by log distance. Exact at the endpoints:
// scaleToSlider(1) === 0, scaleToSlider(1000) === 1; round-trips every value in TIME_SCALES.
export function scaleToSlider(scale: number): number {
const s = clamp(Number.isFinite(scale) ? scale : MIN_SCALE, MIN_SCALE, MAX_SCALE);
let best = 0;
let bestDist = Infinity;
for (let i = 0; i < TIME_SCALES.length; i++) {
const d = Math.abs(Math.log(s) - Math.log(TIME_SCALES[i]));
if (d < bestDist) {
bestDist = d;
best = i;
}
}
return best / (TIME_SCALES.length - 1);
}
+1 -1
View File
@@ -6,7 +6,7 @@ import { fetchState } from './useGameState';
const SNAPSHOT = { const SNAPSHOT = {
account: { cash: 20_000, reputation: 10 }, account: { cash: 20_000, reputation: 10 },
simState: { clockMs: 1_750_000_000_000, timeScale: 1, paused: false }, simState: { clockMs: 1_750_000_000_000 },
planes: [], planes: [],
packages: [], packages: [],
}; };
+1 -1
View File
@@ -28,7 +28,7 @@ export async function fetchState(): Promise<StateSnapshot> {
(window as unknown as Record<string, unknown>).__flightLastSnapshot = { (window as unknown as Record<string, unknown>).__flightLastSnapshot = {
planeIds: snapshot.planes.map((p) => p.id), planeIds: snapshot.planes.map((p) => p.id),
cash: snapshot.account.cash, cash: snapshot.account.cash,
timeScale: snapshot.simState.timeScale, clockMs: snapshot.simState.clockMs,
at: Date.now(), at: Date.now(),
}; };
} }
+14 -18
View File
@@ -1,49 +1,45 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { advanceSimClock, type SimClockAnchor } from './useSimClock'; import { advanceSimClock, type SimClockAnchor } from './useSimClock';
import { WORLD_TIME_SCALE } from './worldClock';
// ISS-007 (v0.16.1) — the pure extrapolation core of lib/useSimClock.ts. The hook itself is a // ISS-007 (v0.16.1) — the pure extrapolation core of lib/useSimClock.ts. The hook itself is a
// thin setInterval + re-sync wrapper covered by e2e (panes.spec.ts "ticks smoothly between polls"); // thin setInterval + re-sync wrapper covered by e2e (panes.spec.ts "ticks smoothly between polls");
// this suite pins the math: game time advances real-time × timeScale, frozen while paused. // this suite pins the math: game time advances real-time × WORLD_TIME_SCALE, frozen while idle.
const anchor = (over: Partial<SimClockAnchor> = {}): SimClockAnchor => ({ const anchor = (over: Partial<SimClockAnchor> = {}): SimClockAnchor => ({
clockMs: 1_750_000_000_000, clockMs: 1_750_000_000_000,
at: 1_000_000, at: 1_000_000,
timeScale: 1, advancing: true,
paused: false,
...over, ...over,
}); });
describe('advanceSimClock', () => { describe('advanceSimClock', () => {
it('advances by exactly elapsed real-time × timeScale while running', () => { it('advances by exactly elapsed real-time × WORLD_TIME_SCALE while running', () => {
expect(advanceSimClock(anchor(), 1_001_000)).toBe(1_750_000_000_000 + 1_000); // scale 1: 1 s real = 1 s game expect(advanceSimClock(anchor(), 1_001_000)).toBe(1_750_000_000_000 + 1_000 * WORLD_TIME_SCALE);
expect(advanceSimClock(anchor({ timeScale: 25 }), 1_002_500)).toBe(1_750_000_000_000 + 62_500); // The e2e scenario: ~2.6 s real at the fixed world rate.
// The e2e scenario: scale 1000, 2.5 s real → 2 500 game seconds. expect(advanceSimClock(anchor(), 1_002_600)).toBe(1_750_000_000_000 + 2_600 * WORLD_TIME_SCALE);
expect(advanceSimClock(anchor({ timeScale: 1000 }), 1_002_500)).toBe(1_750_000_000_000 + 2_500_000);
}); });
it('returns the anchor value exactly at zero elapsed', () => { it('returns the anchor value exactly at zero elapsed', () => {
expect(advanceSimClock(anchor({ timeScale: 1000 }), 1_000_000)).toBe(1_750_000_000_000); expect(advanceSimClock(anchor(), 1_000_000)).toBe(1_750_000_000_000);
}); });
it('is frozen while paused regardless of elapsed or scale', () => { it('is frozen while idle regardless of elapsed time', () => {
const a = anchor({ timeScale: 1000, paused: true }); const a = anchor({ advancing: false });
expect(advanceSimClock(a, 1_000_000)).toBe(1_750_000_000_000); expect(advanceSimClock(a, 1_000_000)).toBe(1_750_000_000_000);
expect(advanceSimClock(a, 2_000_000)).toBe(1_750_000_000_000); // a full second later — still frozen expect(advanceSimClock(a, 2_000_000)).toBe(1_750_000_000_000); // a full second later — still frozen
}); });
it('clamps to the anchor value when nowMs is before the anchor (clock skew), never backwards', () => { it('clamps to the anchor value when nowMs is before the anchor (clock skew), never backwards', () => {
expect(advanceSimClock(anchor({ timeScale: 1000 }), 999_999)).toBe(1_750_000_000_000); expect(advanceSimClock(anchor(), 999_999)).toBe(1_750_000_000_000);
expect(advanceSimClock(anchor({ paused: true }), 999_999)).toBe(1_750_000_000_000); expect(advanceSimClock(anchor({ advancing: false }), 999_999)).toBe(1_750_000_000_000);
}); });
it('mirrors the server tick model (tickOnce): advance = elapsed × timeScale while actively polled', () => { it('mirrors the server tick model (tickOnce): advance = elapsed × WORLD_TIME_SCALE while actively polled', () => {
// lib/sim/engine.ts: clockMs += min(now − lastRealTickMs, MAX_TICK_ELAPSED_MS) × timeScale. // lib/sim/engine.ts: clockMs += min(now − lastRealTickMs, MAX_TICK_ELAPSED_MS) × timeScale.
// For gaps under the 2 s clamp the client extrapolation is exactly the server's own formula. // For gaps under the 2 s clamp the client extrapolation is exactly the server's own formula.
const scale = 50;
for (const gap of [1, 250, 999, 1_999]) { for (const gap of [1, 250, 999, 1_999]) {
expect(advanceSimClock(anchor({ timeScale: scale }), 1_000_000 + gap)).toBe( expect(advanceSimClock(anchor(), 1_000_000 + gap)).toBe(1_750_000_000_000 + gap * WORLD_TIME_SCALE);
1_750_000_000_000 + gap * scale,
);
} }
}); });
}); });
+19 -25
View File
@@ -2,57 +2,51 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import type { StateSnapshot } from './game/state'; import type { StateSnapshot } from './game/state';
import { WORLD_TIME_SCALE } from './worldClock';
// ISS-007 (v0.16.1): the sim clock ticks smoothly on the CLIENT between polls instead of // ISS-007 (v0.16.1): the sim clock ticks smoothly on the CLIENT between polls instead of
// jumping once per snapshot. The server's authoritative rate is exactly real-time × timeScale // jumping once per snapshot. The server's authoritative rate is exactly real-time × scale
// (tickOnce in lib/sim/engine.ts: advance = min(elapsed, MAX_TICK_ELAPSED_MS) × timeScale), so // (tickOnce in lib/sim/engine.ts: advance = min(elapsed, MAX_TICK_ELAPSED_MS) × timeScale), so
// client-side extrapolation mirrors the server's own model. Every new snapshot re-anchors to // client-side extrapolation mirrors the server's own model. Every new snapshot re-anchors to
// server truth, bounding any drift by network latency only. // server truth, bounding any drift by network latency only. ISS-117 (v0.52.0): the scale is the
// fixed WORLD_TIME_SCALE and the clock freezes while nothing is happening — mirroring the
// server's idle-pause instead of a player-controlled pause.
export interface SimClockAnchor { export interface SimClockAnchor {
// Server truth at anchor time — the snapshot's simState.clockMs. // Server truth at anchor time — the snapshot's simState.clockMs.
clockMs: number; clockMs: number;
// Date.now() when that snapshot was received. // Date.now() when that snapshot was received.
at: number; at: number;
timeScale: number; // Whether the world clock is advancing (any plane flying or in repair).
paused: boolean; advancing: boolean;
} }
// Pure core of the extrapolation — mirrors tickOnce: while running, game time advances by // Pure core of the extrapolation — mirrors tickOnce: while advancing, game time advances by
// real-time × timeScale; while paused it is frozen (advance 0). nowMs before `at` clamps to // real-time × WORLD_TIME_SCALE; while idle it is frozen (advance 0). nowMs before `at` clamps to
// the anchor value instead of going backwards. Unit-testable in node. // the anchor value instead of going backwards. Unit-testable in node.
export function advanceSimClock(anchor: SimClockAnchor, nowMs: number): number { export function advanceSimClock(anchor: SimClockAnchor, nowMs: number): number {
if (anchor.paused) return anchor.clockMs; if (!anchor.advancing) return anchor.clockMs;
return anchor.clockMs + Math.max(0, nowMs - anchor.at) * anchor.timeScale; return anchor.clockMs + Math.max(0, nowMs - anchor.at) * WORLD_TIME_SCALE;
} }
// ~10 fps display refresh — smooth for a seconds-precision wall clock without busy re-renders. // ~10 fps display refresh — smooth for a seconds-precision wall clock without busy re-renders.
const DISPLAY_TICK_MS = 100; const DISPLAY_TICK_MS = 100;
export interface SimClockControls {
// EFFECTIVE controls (optimistic pending scale / current pause), not the snapshot's:
// UI changes take effect on click/drag without waiting for a confirming snapshot.
timeScale: number;
paused: boolean;
}
// Returns the display clockMs to render, or null until the first snapshot arrives. // Returns the display clockMs to render, or null until the first snapshot arrives.
export function useSimClock(snapshot: StateSnapshot | null, controls: SimClockControls): number | null { export function useSimClock(snapshot: StateSnapshot | null): number | null {
const anchorRef = useRef<SimClockAnchor | null>(null); const anchorRef = useRef<SimClockAnchor | null>(null);
const [displayMs, setDisplayMs] = useState<number | null>(null); const [displayMs, setDisplayMs] = useState<number | null>(null);
// Re-sync on every snapshot AND effective-control change: snap to server truth. The snapshot // Re-sync on every snapshot: snap to server truth. The snapshot object is fresh per poll
// object is fresh per poll (useGameState), so this runs once per poll cycle as well. // (useGameState), so this runs once per poll cycle as well.
useEffect(() => { useEffect(() => {
if (!snapshot) return; if (!snapshot) return;
anchorRef.current = { // Mirror of the server's shouldAdvanceTime (lib/sim/engine.ts): frozen while no plane is
clockMs: snapshot.simState.clockMs, // flying or in repair. One line on purpose — keep it in sync with that function.
at: Date.now(), const advancing = snapshot.planes.some((p) => p.status === 'flying' || p.status === 'repairing');
timeScale: controls.timeScale, anchorRef.current = { clockMs: snapshot.simState.clockMs, at: Date.now(), advancing };
paused: controls.paused,
};
setDisplayMs(advanceSimClock(anchorRef.current, Date.now())); setDisplayMs(advanceSimClock(anchorRef.current, Date.now()));
}, [snapshot, controls.timeScale, controls.paused]); }, [snapshot]);
useEffect(() => { useEffect(() => {
const id = setInterval(() => { const id = setInterval(() => {
+4
View File
@@ -0,0 +1,4 @@
// ISS-117 (v0.52.0): the world clock runs at a fixed rate — no player-facing time controls.
// Server-side the effective scale lives in the sim_state row (seeded to this value); client-side
// lib/useSimClock.ts extrapolates between polls at this same constant, so keep them in sync here.
export const WORLD_TIME_SCALE = 24;
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "flight-ops", "name": "flight-ops",
"version": "0.44.0", "version": "0.54.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev -p 3000", "dev": "next dev -p 3000",