Compare commits

..
18 Commits
Author SHA1 Message Date
kamilkrzywda b8f321dfd5 Merge pull request 'Maintenance: wear, broken burn +50%, repair (ISS-069, v0.39.0)' (#78) from feat/maintenance into master
CI / build + unit + e2e (push) Successful in 9m29s
Maintenance: wear, broken burn +50%, repair (ISS-069, v0.39.0)
2026-09-21 14:20:32 +02:00
kamilkrzywda 336ed4ee7c Maintenance: distance wear, broken-state burn ×1.5, repair action (ISS-069, v0.39.0)
CI / build + unit + e2e (pull_request) Successful in 9m34s
2026-09-21 12:06:45 +00:00
kamilkrzywda 929452ca33 Merge pull request 'Reputation drives the economy: purchase/fuel discounts + better jobs (ISS-072, v0.38.0)' (#76) from feat/reputation-economy into master
CI / build + unit + e2e (push) Successful in 9m4s
2026-09-21 13:01:53 +02:00
kamilkrzywda 8daca54840 Reputation economy: rep-scaled buy/refuel discounts + job reward quality (ISS-072, v0.38.0)
CI / build + unit + e2e (pull_request) Successful in 9m3s
2026-09-21 10:51:22 +00:00
kamilkrzywda 0f5f3ae23e Merge pull request 'Special jobs: data-driven registry + priority status (ISS-073, v0.37.0)' (#75) from feat/special-jobs into master
CI / build + unit + e2e (push) Successful in 9m6s
Special jobs: data-driven registry + priority status (ISS-073, v0.37.0)
2026-09-21 12:17:42 +02:00
kamilkrzywda 8ec485b93e Harden right-pane/slow-on-landing e2e self-heal: clear leftover cargo, refuel-and-retry repositioning
CI / build + unit + e2e (pull_request) Successful in 9m4s
2026-09-21 10:08:33 +00:00
kamilkrzywda ea740fc378 Special jobs: data-driven special-status registry with priority status (ISS-073, v0.37.0)
CI / build + unit + e2e (pull_request) Failing after 7m56s
2026-09-21 09:30:36 +00:00
kamilkrzywda 718e3a0177 Merge pull request 'Refuel-for-flight buys 150% of requirement; wind adjusts per-leg burn ±20% (ISS-071, ISS-070, v0.36.0)' (#74) from feat/weather-fuel-burn into master
CI / build + unit + e2e (push) Failing after 12m14s
Merge feat/weather-fuel-burn: refuel ×1.5 + wind burn ±20% (ISS-071, ISS-070, v0.36.0)
2026-09-21 11:03:03 +02:00
kamilkrzywda 5030fcd8b4 Wind-adjusted fuel burn ±20% + refuel-for-flight buys 150% margin (ISS-070, ISS-071, v0.36.0)
CI / build + unit + e2e (pull_request) Successful in 10m34s
2026-09-21 08:47:42 +00:00
kamilkrzywda 6d8e1ad35e Merge pull request 'Job list shows cargo tonnage instead of count (ISS-066, v0.35.0)' (#68) from fix/job-list-cargo-tonnage into master
CI / build + unit + e2e (push) Successful in 8m24s
Merge pull request 'Job list shows cargo tonnage instead of count (ISS-066, v0.35.0)' (#68) from fix/job-list-cargo-tonnage into master
2026-09-21 04:58:30 +02:00
kamilkrzywda 0ac133f750 fix(e2e): hook package weight within starter payload (4 t < 4.8 t max)
CI / build + unit + e2e (pull_request) Successful in 8m21s
2026-09-21 02:50:10 +00:00
kamilkrzywda d3038b3c7f Job list shows cargo tonnage instead of count (ISS-066, v0.35.0)
CI / build + unit + e2e (pull_request) Failing after 10m9s
2026-09-21 02:34:20 +00:00
kamilkrzywda 847e74b7b8 Merge pull request 'Reject duplicate queue legs — same route can no longer be queued twice (ISS-065, v0.34.0)' (#67) from feat/queue-duplicate-guard into master
CI / build + unit + e2e (push) Successful in 8m26s
Merge pull request 'Reject duplicate queue legs — same route can no longer be queued twice (ISS-065, v0.34.0)' (#67) from feat/queue-duplicate-guard into master
2026-09-21 04:31:54 +02:00
kamilkrzywda 394d51d827 Reject duplicate queue legs — same route can no longer be queued twice (ISS-065, v0.34.0)
CI / build + unit + e2e (pull_request) Successful in 8m43s
2026-09-21 01:28:41 +00:00
kamilkrzywda 264778522c Merge pull request 'Merge On board into Fleet list — single per-plane block (ISS-054, v0.33.0)' (#64) from feat/merge-onboard-into-fleet into master
CI / build + unit + e2e (push) Successful in 10m53s
2026-09-21 03:16:40 +02:00
kamilkrzywda e55078fb5d Merge On board into Fleet list — single per-plane block (ISS-054, v0.33.0)
CI / build + unit + e2e (pull_request) Successful in 10m18s
2026-09-21 00:46:12 +00:00
kamilkrzywda 4e585cbed1 Merge pull request 'Queue via fly-to + Relocate; remove dedicated queue input (ISS-053, v0.32.0)' (#63) from feat/queue-via-fly-to-relocate into master
CI / build + unit + e2e (push) Successful in 9m28s
2026-09-21 02:07:45 +02:00
kamilkrzywda 89ad09a04d Queue via fly-to + Relocate; remove dedicated queue-add input (ISS-053, v0.32.0)
CI / build + unit + e2e (pull_request) Successful in 10m18s
- LeftPane fly-to button enabled for any plane state; posts to /queue when the plane is flying, else /fly
- Relocate lists ALL planes (relocatable); relocate-go posts to /queue for a flying selected plane, else /fly
- Delete queue-add select + Add-to-queue button and shared queueAddDestIcao state; leg rows/cancel kept
- e2e/queue.spec.ts test 1 adds its queued leg via the Relocate section (starter is flying)
- e2e/two-planes.spec.ts per-plane-queues test adds the starter's second leg via API POST /queue
2026-09-20 23:48:12 +00:00
44 changed files with 2002 additions and 426 deletions
+50
View File
@@ -1,5 +1,55 @@
# Changelog
## v0.39.0 — 2026-09-21 12:02
- **Maintenance (ISS-069).** Planes wear out and can be repaired: distance-based health, a broken state that burns more fuel, and a repair action that grounds the plane for 3 sim days. New balance constants in `lib/game/catalog.ts` (all ponytail-tunable): `WEAR_PER_KM = 0.005`, `BROKEN_BURN_MULTIPLIER = 1.5`, `REPAIR_COST = 1_500` (flat), `REPAIR_DURATION_MS = 3 × 86_400_000`, plus the pure helper `conditionBurnMultiplier(health)` — exactly 1 above zero health, ×1.5 at/below it
- **Schema.** Two new `planes` columns in all four places: Drizzle schema (`lib/db/schema.ts`), raw DDL + guarded `ALTER TABLE … ADD COLUMN` migrations in `migrateColumns()` (pre-existing rows backfill to full health / NULL gate, mirroring the weight_kg/special precedent), and the seed insert path — starter plane in `buildSeed` (`lib/game/seed.ts`) and every `buyPlane` delivery start at `health: 100`. New plane status `'repairing'` added to the three pinned unions (schema enum, `PlaneState`, OpenAPI) plus `mapData.ts`'s feature props
- **Wear.** At landing only — `deliverPackages` (`lib/sim/engine.ts`) drops health by `WEAR_PER_KM × distanceKm` per landed leg (clamped at 0), in both the grounded and auto-chain write branches. A broken plane is one with `health ≤ 0`: it still flies, but burns ×1.5 more fuel — applied as a third factor alongside wind at ALL FIVE burn sites that hold the plane row: `flyPlane` dispatch feasibility + `refuelPlane` forFlight requirement (`lib/game/actions.ts`), engine landed-fuel + auto-chain next-leg check (`lib/sim/engine.ts`), and the live in-flight gauge in `livePlane()` (`lib/game/state.ts`). The engine's landed fuel uses the PRE-landing health (frozen at departure, like fuel) so it stays bit-consistent with the UI gauge; the chained leg's feasibility uses the post-wear value
- **Repair.** New action `repairPlane` (`lib/game/actions.ts`) + POST `/api/planes/{planeId}/repair` (no body): 404 unknown plane, 409 not grounded ("only grounded planes can be repaired"), free no-op at full health (mirrors refuel), else charges the flat `REPAIR_COST`, restores health to 100 and grounds the plane in `'repairing'` with a gate at `clockMs + REPAIR_DURATION_MS`. The engine's new `finishRepairs` pass (same shape as `expirePackages`) runs inside every advancing tick — even when nothing landed — flipping gated planes back to `'available'` and clearing the gate. While repairing, the existing `status !== 'available'` guards in load/fly/refuel/sell/abandon block everything automatically
- **Snapshot & OpenAPI.** `PlaneState` gains `health: number` + `repairUntilSimTime: number | null`; the repair endpoint is documented (200 → snapshot, 404/409). Pins updated in lockstep: `lib/openapi.test.ts` (fourteen endpoints, nine action-endpoint loop with errorCodes `['404','409']`, PlaneState key list) and `e2e/api.spec.ts`
- **UI.** The fleet rows (`components/LeftPane.tsx`) show an amber health bar (new `.health-bar`, modeled on `.fuel-bar`) + a `{n}%` readout next to the fuel text; grounded planes get a red "broken" pill at zero health and an amber "in repair" pill while the gate runs (`.status-broken` / `.status-repairing`). A Repair button appears for any grounded plane below full health — no cost preview, flat cost is server-side only
- **Test hook.** POST `/api/test/set-health` (`FLIGHT_TEST_HOOKS=1` only): `{ planeId, health?, repairUntilMs? }` forces a plane's health (finite in [0,100]) and/or sets its repair gate to `clock + repairUntilMs` — lets e2e force the broken state and shorten an active 3-day gate without flying thousands of km
- **Tests.** New unit tests: wear accumulation per landed leg + clamping at 0 across multiple legs; broken-state ×1.5 landed fuel vs a healthy control on the identical leg; `conditionBurnMultiplier` pins; `flyPlane` feasibility using the adjusted requirement (same tank flyable at full health, 409 when broken); `repairPlane` (exact cost/status/gate/health, free no-op, 409s for flying/repairing/insufficient funds, load/fly/refuel/sell all blocked while repairing) and `finishRepairs` (gate boundary + flip-back through a full `tickOnce` with nothing landed). E2E: new `e2e/maintenance.spec.ts` — the visible flow per the issue (force 42 → UI shows it; force 0 → broken pill + short full-tank leg lands with exactly the ×1.5-adjusted fuel from the real lib helpers; click Repair → exact cash drop + "in repair" pill; shorten the gate via the hook → back to service at health 100), restoring a clean starter on exit
- **E2E drift mitigation.** Wear accumulates across the whole CI run (one shared DB, workers:1) — the self-heal helpers in `e2e/right-pane.spec.ts` (`flyHomeViaApi`), `e2e/two-planes.spec.ts` (`resetFleet`) and `e2e/actions.spec.ts` (`flyHomeViaApi`) now restore full health + clear any active repair gate first, so the farthest-flyable pickers and the FAR round-trip feasibility pin never see a worn or broken starter
## v0.38.0 — 2026-09-21 10:47
- **Reputation economy (ISS-072).** Reputation is no longer a dead stat — it drives the economy through three simple tunable formulas, all built on one new pure helper `repRate(reputation, perPoint, cap)` in `lib/game/catalog.ts` (linear above the starting value of 10, capped; exactly 0 at/below start so default-rep behavior is byte-identical):
- **Cheaper plane purchases** — `buyPlane` (`lib/game/actions.ts`) charges `price × (1 − repRate(rep, PLANE_DISCOUNT_PER_REP = 0.01, MAX_PLANE_DISCOUNT = 0.4))`, rounded to 2dp; the effective cost drives both the funds check and the deduction (up to 40% off at rep ≥ 50)
- **Cheaper refuel** — `refuelPlane` multiplies ONLY the price term: `liters × FUEL_PRICE_PER_L × (1 − repRate(rep, FUEL_DISCOUNT_PER_REP = 0.005, MAX_FUEL_DISCOUNT = 0.2))`; burn/target math (wind factor, ×1.5 forFlight margin) untouched
- **Better jobs at higher reputation** — `generatePackage` (`lib/game/seed.ts`) takes an optional `reputation` input (defaults to the starting value) and multiplies the final reward by `1 + repRate(rep, JOB_BONUS_PER_REP = 0.01, MAX_JOB_BONUS = 1.0)` at the same spot as the special multiplier (base × jobBonus × special, single round at the end; up to ×2 at rep ≥ 110). Consumes NO extra RNG draw
- **Spawn sites carry live reputation.** `spawnOpenPackages` + `spawnCoveragePackages` (`lib/sim/engine.ts`) read the account row once per spawn batch and thread its reputation into `generatePackage`; the test hook POST /api/test/create-package does the same (hook-created packages get current job quality, never a forced value). `buildSeed` keeps defaulting — no rep at seed time
- **UI shows the effect.** The market buy row (`components/RightPane.tsx`) previews the EFFECTIVE price computed client-side from the snapshot's reputation + the imported constants, with a sky-blue "−N%" pill (`market-buy-discount`, reusing `.special-badge`) when the discount is non-zero; tech-spec cards keep showing full list prices. The left-pane Refuel section (`components/LeftPane.tsx`) previews the discounted fill-up cost with its own `refuel-discount` pill. No new snapshot fields — rep was already in the state, so the OpenAPI schema is unchanged
- **OpenAPI (descriptions only).** buy-plane + refuel endpoint descriptions mention the rep-scaled discounts; `CatalogEntry.price`, `StateSnapshot.fuelPricePerL` and `AccountState.reputation` gain notes that the discount applies at purchase/refuel time. No properties or endpoints added/removed
- **Tests.** New unit tests: `repRate` (zero at/below start, linear mid-range exact values, cap clamping), `buyPlane` at rep 30 (exact discounted cash delta + funds boundary between discounted and full price), `refuelPlane` at fixed rep in both 'full' and 'forFlight' modes (discount on the price term only), `generatePackage` job bonus (×1.5 at rep 60, default-rep byte-identity, composition with the special multiplier, no extra RNG draw). All existing unit tests pass unmodified — at the starting reputation every rate is exactly 0. E2E: the full-price pins in `right-pane.spec.ts`, `two-planes.spec.ts` and `actions.spec.ts` now compute expectations from the LIVE snapshot's reputation via the imported helpers (rep accumulates across specs), plus a new assertion that the buy-row discount pill appears iff live rep > starting with text round(rate × 100)
## v0.37.0 — 2026-09-21 09:29
- **Special jobs: data-driven special-status registry for packages (ISS-073).** New `lib/game/specials.ts` holds a flat `SPECIALS` record (`special status → { rewardMultiplier, deadlineScale, repBonus }`) in the same house style as `PLANE_TYPES`, plus `SPECIAL_SPAWN_CHANCE = 0.15` and a pure `pickSpecial(rng, registry?)` helper (one chance draw, then a uniform pick among the registry's keys; the registry is a param defaulting to the real one). Adding or removing a special is an edit of ONE registry entry — no scattered branches. First status: **priority** (×1.5 reward, ×0.5 deadline window, +0.5 rep at delivery; all balance-tunable)
- **Spawn & modifiers.** `generatePackage` (`lib/game/seed.ts`) appends one RNG draw at the END of its fixed order to roll a special (~15% of packages); a forced `special` input (including explicit null) skips the draw, mirroring the other overrides. The reward multiplier applies where the reward is computed; the deadline scale applies only to the drawn 6–72h window — a forced `deadlineOffsetMs` skips scaling
- **Rep bonus at delivery.** `deliverPackages` (`lib/sim/engine.ts`) adds the registry's flat rep bonus on top of the distance-based gain for special packages, keeping the existing double-rounding convention (gain rounded to cents, total re-rounded)
- **State shape & DB.** `special: string | null` added in all four places — `SeedPackage`, Drizzle schema (`lib/db/schema.ts`), raw DDL and a guarded nullable `ALTER TABLE … ADD COLUMN special TEXT` migration in `migrateColumns()` (pre-existing rows stay NULL = none, mirroring the weight_kg precedent), and `PackageState` + its row mapping
- **UI badge.** Open-package rows (`components/RightPane.tsx`) and on-board rows (`components/LeftPane.tsx`) show a sky-blue "Priority" pill next to the route when the package has a special status (new `.special-badge` class, `package-special-{id}` testid); label via new `formatSpecialLabel` in `lib/format.ts`
- **Test hooks & spec.** POST /api/test/create-package accepts `special` (string or null; omitted → forced null so existing e2e reward/rep assertions stay deterministic — documented in the route header), and expire-package passes `special: null` explicitly. OpenAPI PackageState gains optional `special: string | null`
- **Tests.** New unit tests: registry application (reward ×multiplier, deadline ×scale, rep bonus at delivery), pickSpecial distribution with a fixed RNG, forced-value draw skip, and removing an entry cleanly disabling the status; existing reward/deadline assertions scoped for special packages. E2E: new right-pane test forces `special: 'priority'` via the hook and asserts the row shows the "Priority" badge
- **E2E self-heal hardening.** The `flyHomeViaApi` helper in `e2e/right-pane.spec.ts` now abandons any leftover loaded/expired package bound to plane-starter once it's back at EDDF (the "open packages render" test leaves a 4 t package on board, which made later hook-package loads hit the payload check), and both that helper and `e2e/slow-on-landing.spec.ts`'s equivalent now refuel-and-retry the repositioning flight when it fails on insufficient fuel (modeled on `e2e/two-planes.spec.ts`)
## v0.36.0 — 2026-09-21 08:45
- **Refuel-for-flight now buys 150% of the required fuel for the leg (ISS-071).** `refuelPlane` mode `'forFlight'` (`lib/game/actions.ts`) tops the tank up to `min(maxFuel, max(current fuel, 1.5 × required))` instead of exactly the requirement — a margin so burn variance doesn't strand planes at the far end. The "does not fit a full tank" 409 guard now compares against the weather-adjusted requirement (see below). No UI change: the per-leg Refuel button has no amount preview, and the left-pane "Refuel to full" row is unaffected
- **Wind affects per-leg fuel burn ±20% based on departure weather vs route heading (ISS-070).** The deterministic per-airport weather (`lib/weather.ts`, FNV-1a seeded by ICAO — no dayKey, stable across polls/sessions) now feeds the fuel math: a new `windBurnFactor(windKt, windDirDeg, bearingDeg)` in `lib/sim/movement.ts` returns `1 + headwindKt/150` (exactly [0.8, 1.2] over the 2–30 kt weather range), and a new `initialBearingDeg(lat1, lon1, lat2, lon2)` in `lib/geo.ts` gives the great-circle heading of each leg. Every fuel call site applies the departure airport's factor to `burnPerKm`: `flyPlane`'s feasibility check, `refuelPlane` forFlight, the engine's landed-fuel + auto-chain checks (`lib/sim/engine.ts`), and the live in-flight fuel display — computed server-side in `getStateSnapshot()`/`livePlane()` (`lib/game/state.ts`) from airport coordinates, so no schema change. Fully deterministic: one factor per leg, a pure function of (ICAO pair, catalog)
- **Tests & spec.** Unit tests added for both helpers and the ×1.5 expectation (computed in-test via the same helpers — no stale literals); e2e specs import the real helpers from `lib/` instead of mirroring the wind math (`e2e/right-pane.spec.ts`, `e2e/two-planes.spec.ts`, `e2e/actions.spec.ts`), and their "farthest flyable" airport pickers now require BOTH directions to fit a full tank under wind. OpenAPI: description-only updates (refuel endpoint + `burnPerKm` note)
## v0.35.0 — 2026-09-21 02:26
- **Job list shows cargo tonnage instead of count (ISS-066).** The open-package rows in the right pane's Jobs tab (`components/RightPane.tsx`) now show cargo as type + tonnage ("Mail 10.0t") via a new `formatCargo` in `lib/format.ts` — `(kg / 1000).toFixed(1) + 't'`, identical to the loadout display — instead of "type × count"; unit tests added to `lib/format.test.ts`; e2e assertion added to `e2e/right-pane.spec.ts` (the hook-created package is forced to 10 t and its row must contain "10.0t"). No server or OpenAPI changes — `weightKg` was already in the snapshot
## v0.34.0 — 2026-09-21 01:26
- **Duplicate queue legs rejected with 409 (ISS-065).** `queueLeg` (`lib/game/actions.ts`) now refuses to append a destination that is already in the plane's flight queue ("{icao} is already in the flight queue"), and — while the plane is flying — re-queuing the active leg's destination ("already flying to {icao}"). Previously the same route could be queued any number of times, producing pointless repeated/zero-length hops when landing auto-chains `queue[0]`. No UI or OpenAPI changes (the endpoint already documents error responses)
## v0.33.0 — 2026-09-21 00:45
- **Merged On board into the Fleet list (ISS-054).** The left pane's separate "On board" section is deleted; each Fleet row (`components/LeftPane.tsx`) is now the single per-plane block — status/fuel/loadout plus, when present, that plane's loaded/expired cargo rows (fly-to / refuel-for-flight / abandon + penalty preview) and its queue legs. The header shows "in flight" + the current leg for flying planes and the location ICAO when grounded; the empty "Queue" heading is no longer rendered (the sub-section renders only with ≥1 queued leg). All per-item testids unchanged (`onboard-package-*`, `fly-to-*`, `refuel-for-*`, `abandon-*`, `abandon-penalty-*`, `plane-leg-*`, `queue-leg-*`, `queue-cancel-*`); the removed one is `onboard-{plane.id}` (use `plane-{id}`). CSS: `.onboard-block .contract-row*` re-targeted to `.plane-row .contract-row*`; no server or OpenAPI changes
- **E2E updates.** The four `onboard-{plane.id}` references now target the fleet rows (`e2e/hover.spec.ts`, `e2e/right-pane.spec.ts`, `e2e/two-planes.spec.ts`); `e2e/panes.spec.ts` no longer asserts an "On board" heading — it asserts the heading is absent from both panes
## v0.32.0 — 2026-09-20 23:47
- **Queue via fly-to and Relocate; dedicated queue-add input removed (ISS-053).** The flight queue no longer has its own entry point in the On board block (`components/LeftPane.tsx`): the per-cargo "Fly to …" button is now enabled for ANY plane state, and posts to `/api/planes/{id}/queue` instead of `/fly` when the plane is `flying` (grounded → `/fly`, unchanged); Relocate's plane list widened from grounded-only to ALL planes (`relocatable = state.planes`; Refuel keeps its own `grounded` filter), and `relocate-go` posts to `/queue` when the SELECTED plane is flying, else `/fly`. The queue-add select + "Add to queue" button (and the shared `queueAddDestIcao` state) are deleted; leg rows + cancel buttons unchanged. No server or OpenAPI changes — both endpoints already existed
- **E2E updates.** `e2e/queue.spec.ts` test 1 now adds its queued leg via the Relocate section (selects plane-starter, which is flying at that point → `/queue`) instead of the removed queue-add control; `e2e/two-planes.spec.ts` "per-plane queues are independent" adds the starter's second leg via API POST `/api/planes/plane-starter/queue` (the starter is grounded there, so fly-to would start rather than queue). All `queue-leg-*` / `queue-cancel-*` assertions unchanged
## v0.31.0 — 2026-09-20 21:23
- **Two-plane Load is one-click again (ISS-055).** The reported "can't load cargo with two planes at the same airport" was UX, not a server bug: `LoadControls` (`components/RightPane.tsx`) rendered a plane picker for ≥2 eligible planes whose Load button stayed DISABLED until a pick existed, while the single-plane path is one-click. The multi-plane path now PRESELECTS the first eligible plane (`effective = … ?? eligible[0] ?? null`; the select already bound to `effective?.id`), so Load is enabled and one-click whenever ≥1 plane qualifies — the dropdown still allows picking a different plane
- **LeftPane queue testids scoped by plane id (ISS-055).** With two on-board blocks in the DOM the unscoped ids duplicated: `queue-leg-{i}` → `queue-leg-{plane.id}-{i}`, `queue-cancel-{i}` → `queue-cancel-{plane.id}-{i}`, plus the add control `queue-add-dest`/`queue-add` → `queue-add-dest-{plane.id}`/`queue-add-{plane.id}` (`components/LeftPane.tsx`). The shared pane-level destination pick is unchanged. `e2e/queue.spec.ts` locators updated to the scoped form (single-plane fleet, id `plane-starter`)
+16 -6
View File
@@ -6,8 +6,9 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import * as schema from '@/lib/db/schema';
import { getGameDb } from '@/lib/db/client';
import { loadAirportsGeoJson } from '@/lib/airports';
import { haversineKm } from '@/lib/geo';
import { requiredFuelFor } from '@/lib/sim/movement';
import { haversineKm, initialBearingDeg } from '@/lib/geo';
import { weatherFor } from '@/lib/weather';
import { requiredFuelFor, windBurnFactor } from '@/lib/sim/movement';
import { FUEL_PRICE_PER_L, PLANE_TYPES } from '@/lib/game/catalog';
import { HUB_ICAO } from '@/lib/game/seed';
import { POST } from './route';
@@ -124,7 +125,7 @@ describe('POST /api/planes/:planeId/refuel', () => {
expect(res.status).toBe(400);
});
it('forFlight tops up exactly the liters needed for the loaded package leg', async () => {
it('forFlight buys 150% of the weather-adjusted leg requirement', async () => {
const { db } = await getGameDb();
await db.update(schema.account).set({ cash: 50_000 });
// A loaded EDDF → LEMD package on the starter (direct row — loadPackage is covered in actions tests).
@@ -138,7 +139,14 @@ describe('POST /api/planes/:planeId/refuel', () => {
const to = fc.features.find((f) => f.properties.icao === 'LEMD')!.properties;
const km = haversineKm(from.lat, from.lon, to.lat, to.lon);
const type = PLANE_TYPES.atr72;
const required = requiredFuelFor(type.burnPerKm, type.emptyWeightKg, type.maxFuel, km);
// Same wind-adjusted requirement the server computes (v0.36.0).
const w = weatherFor(HUB_ICAO);
const required = requiredFuelFor(
type.burnPerKm * windBurnFactor(w.windKt, w.windDirDeg, initialBearingDeg(from.lat, from.lon, to.lat, to.lon)),
type.emptyWeightKg,
type.maxFuel,
km,
);
const startFuel = Math.round(required / 2); // partial tank so L > 0
await db.update(schema.planes).set({ fuel: startFuel }).where(eq(schema.planes.id, 'plane-starter'));
@@ -151,9 +159,11 @@ describe('POST /api/planes/:planeId/refuel', () => {
account: { cash: number };
planes: Array<{ id: string; fuel: number }>;
};
const cost = Math.round((required - startFuel) * FUEL_PRICE_PER_L * 100) / 100;
// ISS-071: target is 150% of the requirement, capped at tank capacity.
const targetFuel = Math.min(type.maxFuel, Math.max(startFuel, required * 1.5));
const cost = Math.round((targetFuel - startFuel) * FUEL_PRICE_PER_L * 100) / 100;
expect(body.account.cash).toBeCloseTo(cashBefore - cost, 6);
expect(body.planes.find((p) => p.id === 'plane-starter')!.fuel).toBeCloseTo(required, 6);
expect(body.planes.find((p) => p.id === 'plane-starter')!.fuel).toBeCloseTo(targetFuel, 6);
// Clean up the test package.
await db.delete(schema.packages).where(eq(schema.packages.id, 'p-ff-api'));
+12
View File
@@ -0,0 +1,12 @@
import { performAction, repairPlane } from '@/lib/game/actions';
// POST /api/planes/:planeId/repair (v0.39.0, ISS-069) — no body. Restores a worn-out plane to
// full health for the flat REPAIR_COST: it is grounded in "repairing" status until 3 sim days
// pass, when the engine's tick flips it back to "available". A full-health repair is a free
// no-op (cost 0). 404 unknown id; 409 plane not grounded or insufficient funds. On success
// returns the full state snapshot.
export async function POST(_req: Request, ctx: { params: Promise<{ planeId: string }> }) {
const { planeId } = await ctx.params;
return performAction((inst) => repairPlane(inst, planeId));
}
+14 -1
View File
@@ -21,6 +21,9 @@ import { tickNow } from '@/lib/sim/engine';
// hook deadline of expire-package) — pass an explicit value to override.
// - weightKg (v0.27.0) forces the package weight instead of the RNG draw, so e2e
// flows that load several packages can stay under the plane's payload deterministically.
// - special (v0.37.0) forces the package's special status ('priority' or null). When the body
// OMITS it, this hook forces null instead of letting generatePackage roll one — so existing
// e2e specs that assert exact reward/rep values stay deterministic.
// Returns 201 + the created package row.
export async function POST(req: Request) {
@@ -40,7 +43,7 @@ export async function POST(req: Request) {
}
const input = body as Record<string, unknown>;
for (const key of Object.keys(input)) {
if (key !== 'originIcao' && key !== 'destIcao' && key !== 'deadlineOffsetMs' && key !== 'weightKg') {
if (key !== 'originIcao' && key !== 'destIcao' && key !== 'deadlineOffsetMs' && key !== 'weightKg' && key !== 'special') {
return Response.json({ error: `unknown field "${key}"` }, { status: 400 });
}
}
@@ -55,6 +58,9 @@ export async function POST(req: Request) {
if (input.weightKg !== undefined && (typeof input.weightKg !== 'number' || !Number.isFinite(input.weightKg) || input.weightKg <= 0)) {
return Response.json({ error: 'weightKg must be a positive finite number' }, { status: 400 });
}
if (input.special !== undefined && input.special !== null && typeof input.special !== 'string') {
return Response.json({ error: 'special must be a string or null' }, { status: 400 });
}
try {
const inst = await getGameDb();
@@ -76,6 +82,10 @@ export async function POST(req: Request) {
}
const rng = mulberry32(randomInt(0, 2 ** 31));
// v0.38.0 (ISS-072): hook-created packages carry the LIVE account reputation — same job quality
// as engine spawns; never force a fixed value here.
const account = (await inst.db.select().from(schema.account))[0];
if (!account) throw new Error('account row missing');
const created = generatePackage({
id: randomUUID(),
airports,
@@ -85,6 +95,9 @@ export async function POST(req: Request) {
originIcao: input.originIcao as string | undefined,
destIcao: input.destIcao as string | undefined,
weightKg: input.weightKg as number | undefined,
// Omitted → force null (no roll) so existing e2e reward/rep assertions stay deterministic.
special: input.special === undefined ? null : (input.special as string | null),
reputation: account.reputation,
});
await inst.db.insert(schema.packages).values(created);
await persist(inst);
+2 -1
View File
@@ -64,7 +64,8 @@ export async function POST(req: Request) {
lon: f.properties.lon,
}));
const rng = mulberry32(randomInt(0, 2 ** 31));
const created = generatePackage({ id: randomUUID(), airports, rng, nowMs: sim.clockMs, deadlineOffsetMs: HOOK_DEADLINE_MS });
// special: null — the hook never rolls a special status (v0.37.0).
const created = generatePackage({ id: randomUUID(), airports, rng, nowMs: sim.clockMs, deadlineOffsetMs: HOOK_DEADLINE_MS, special: null });
await inst.db.insert(schema.packages).values(created);
await persist(inst);
return Response.json(created, { status: 201 });
+82
View File
@@ -0,0 +1,82 @@
import { eq } from 'drizzle-orm';
import { getGameDb, persist } from '@/lib/db/client';
import * as schema from '@/lib/db/schema';
import { getStateSnapshot } from '@/lib/game/state';
import { tickNow } from '@/lib/sim/engine';
// TEST-ONLY hook (v0.39.0, ISS-069) — lets the e2e suite force a plane's health and/or shorten an
// active repair gate without flying thousands of km or waiting 3 sim days. Disabled unless
// FLIGHT_TEST_HOOKS=1 (set in docker-compose.yml for the e2e instance only); every other
// deployment 404s here. Sibling of /api/test/add-cash — same gate, same conventions.
//
// POST /api/test/set-health
// body { planeId: string, health?: number, repairUntilMs?: number }
// - planeId is required and must name an existing plane (404 otherwise);
// - at least one of health / repairUntilMs must be present;
// - health must be a finite number in [0, 100];
// - repairUntilMs sets repair_until_sim_time = clock + repairUntilMs (the expire-package
// near-term-gate precedent) so the next advancing tick flips a shortened repair back.
// Returns the full state snapshot after ticking.
export async function POST(req: Request) {
if (process.env.FLIGHT_TEST_HOOKS !== '1') {
return Response.json({ error: 'test hooks disabled' }, { status: 404 });
}
let body: unknown = {};
try {
const text = await req.text();
body = text ? JSON.parse(text) : {};
} catch {
return Response.json({ error: 'invalid JSON body' }, { status: 400 });
}
if (typeof body !== 'object' || body === null || Array.isArray(body)) {
return Response.json({ error: 'body must be a JSON object' }, { status: 400 });
}
const input = body as Record<string, unknown>;
for (const key of Object.keys(input)) {
if (key !== 'planeId' && key !== 'health' && key !== 'repairUntilMs') {
return Response.json({ error: `unknown field "${key}"` }, { status: 400 });
}
}
const planeId = input.planeId;
if (typeof planeId !== 'string' || planeId.length === 0) {
return Response.json({ error: 'planeId is required and must be a non-empty string' }, { status: 400 });
}
let health: number | undefined;
if (input.health !== undefined) {
if (typeof input.health !== 'number' || !Number.isFinite(input.health) || input.health < 0 || input.health > 100) {
return Response.json({ error: 'health must be a finite number in [0, 100]' }, { status: 400 });
}
health = input.health;
}
let repairUntilMs: number | undefined;
if (input.repairUntilMs !== undefined) {
if (typeof input.repairUntilMs !== 'number' || !Number.isFinite(input.repairUntilMs)) {
return Response.json({ error: 'repairUntilMs must be a finite number' }, { status: 400 });
}
repairUntilMs = input.repairUntilMs;
}
if (health === undefined && repairUntilMs === undefined) {
return Response.json({ error: 'at least one of health or repairUntilMs is required' }, { status: 400 });
}
try {
const inst = await getGameDb();
const sim = await tickNow(); // clock is current before we set the gate (same as siblings)
const rows = await inst.db.select().from(schema.planes).where(eq(schema.planes.id, planeId));
if (!rows[0]) {
return Response.json({ error: `plane ${planeId} not found` }, { status: 404 });
}
const sets: Partial<Pick<schema.PlaneRow, 'health' | 'repairUntilSimTime'>> = {};
if (health !== undefined) sets.health = health;
if (repairUntilMs !== undefined) sets.repairUntilSimTime = Math.round(sim.clockMs + repairUntilMs);
await inst.db.update(schema.planes).set(sets).where(eq(schema.planes.id, planeId));
await persist(inst);
return Response.json(await getStateSnapshot());
} catch (err) {
console.error('test hook failed', err);
return Response.json({ error: 'test hook failed' }, { status: 500 });
}
}
+45 -12
View File
@@ -214,6 +214,26 @@ body {
color: #bfdbfe;
}
/* v0.39.0 (ISS-069): maintenance pills on fleet rows — broken = red, in repair = amber. */
.status-broken {
background: rgba(239, 68, 68, 0.25);
color: #fecaca;
}
.status-repairing {
background: rgba(245, 158, 11, 0.25);
color: #fde68a;
}
/* v0.37.0 (ISS-073): special-status pill on package rows — sky-blue accent, modeled on .plane-status. */
.special-badge {
font-size: 11px;
padding: 1px 6px;
border-radius: 4px;
background: rgba(56, 189, 248, 0.2);
color: #bae6fd;
}
.fuel-bar {
height: 4px;
border-radius: 2px;
@@ -228,6 +248,26 @@ body {
background: #34d399;
}
/* v0.39.0 (ISS-069): health bar — modeled on .fuel-bar, amber fill. */
.health-bar {
height: 4px;
border-radius: 2px;
background: rgba(148, 163, 184, 0.25);
margin: 2px 0 4px;
overflow: hidden;
}
.health-bar > div {
height: 100%;
border-radius: 2px;
background: #f59e0b;
}
/* v0.39.0 (ISS-069): the per-plane Repair button sits under the readout row. */
.repair-btn {
margin-top: 6px;
}
.plane-sub {
display: flex;
justify-content: space-between;
@@ -376,17 +416,10 @@ body {
font-variant-numeric: tabular-nums;
}
/* v0.12.0 — on-board blocks (per plane); moved to the left pane in ISS-021 (v0.20.0) */
.onboard-block {
background: rgba(30, 41, 59, 0.6);
border-radius: 6px;
padding: 8px;
margin-bottom: 8px;
}
/* ISS-028 (v0.20.0 part 2) — on-board cargo rows are two lines: a full-width fly-to button,
then refuel + abandon side by side below it; long destination names truncate with an ellipsis. */
.onboard-block .contract-row {
/* ISS-054 (v0.33.0) — per-plane cargo rows nested inside the Fleet list's plane cards: two lines,
a full-width fly-to button then refuel + abandon side by side below it; long destination names
truncate with an ellipsis. Transparent sub-rows with a top border under the card header. */
.plane-row .contract-row {
background: transparent;
padding: 6px 0 0;
border-top: 1px solid rgba(148, 163, 184, 0.2);
@@ -395,7 +428,7 @@ body {
gap: 5px;
}
.onboard-block .contract-row .contract-sub {
.plane-row .contract-row .contract-sub {
margin-top: 0; /* row spacing comes from the container's gap */
}
+198 -202
View File
@@ -3,9 +3,9 @@
import { useCallback, useEffect, useRef, useState, type ChangeEvent } from 'react';
import type { GeoJsonFeatureCollection } from '@/lib/airports';
import type { PlaneState, StateSnapshot } from '@/lib/game/state';
import { PLANE_TYPES } from '@/lib/game/catalog';
import { FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT, PLANE_TYPES, repRate } from '@/lib/game/catalog';
import { abandonPenalty } from '@/lib/game/penalty';
import { formatCash, formatDeadlineRelative, formatFuel, formatSimClock } from '@/lib/format';
import { formatCash, formatDeadlineRelative, formatFuel, formatSimClock, formatSpecialLabel } from '@/lib/format';
import { TIME_SCALES, scaleToSlider, sliderToScale } from '@/lib/timeScale';
import { useGameAction } from '@/lib/useGameAction';
import { useSimClock } from '@/lib/useSimClock';
@@ -19,7 +19,7 @@ interface LeftPaneProps {
// The shell's single-poll refresh — called after a sim-control POST so the pane updates immediately.
onRefresh: () => void;
// ISS-021 (v0.20.0): push the full snapshot returned by an action endpoint into the shared shell
// state — needed now that the On board section's refuel/fly/abandon actions live in this pane.
// state — needed now that the per-plane cargo rows' refuel/fly/abandon actions live in this pane.
applySnapshot: (snapshot: StateSnapshot) => void;
// ISS-002 (v0.15.0): report fleet-row hovers to the shell so the map can highlight the plane.
onHover: (target: HoverTarget | null) => void;
@@ -92,7 +92,7 @@ export async function retrySimPost(
export default function LeftPane({ state, loading, error, onRefresh, applySnapshot, onHover, onError }: LeftPaneProps) {
const [busy, setBusy] = useState(false);
// ISS-021 (v0.20.0): shared action runner for the On board section (moved here from RightPane).
// ISS-021 (v0.20.0): shared action runner for the per-plane cargo/queue actions (moved here from RightPane).
const { runAction, busy: actionsBusy, actionError } = useGameAction(state, applySnapshot, onError);
// ISS-023 (v0.21.0): Relocate selections — moved here from the right-pane market row; same
@@ -106,10 +106,6 @@ export default function LeftPane({ state, loading, error, onRefresh, applySnapsh
// a sensible default (first grounded plane with fuel below max).
const [refuelPlaneId, setRefuelPlaneId] = useState<string | null>(null);
// ISS-043 (v0.26.0): flight-queue "add" destination — one shared pick across every on-board
// block's queue-add select (the e2e fleet is a single plane; per-block state would be dead weight).
const [queueAddDestIcao, setQueueAddDestIcao] = useState<string | null>(null);
// ISS-010 (v0.18.0): one-time /api/airports fetch per page life — destination options for the
// Relocate section, sorted by name so e2e selection is stable. Same promise-ref pattern as Map.tsx;
// on failure airportOptions stays null and the Relocate button simply remains disabled. Moved here
@@ -199,33 +195,34 @@ export default function LeftPane({ state, loading, error, onRefresh, applySnapsh
const paused = state?.simState.paused ?? false;
const timeScale = state?.simState.timeScale ?? 1;
// On board (v0.12.0, moved from the right pane in ISS-021 v0.20.0): every plane that carries
// cargo or is mid-flight gets a block — its loaded/expired packages with fly-to-destination +
// abandon controls, and flying planes show their current leg.
// Snapshot clock — feeds the deadline/penalty previews in the per-plane cargo rows.
const clockMs = state?.simState.clockMs ?? 0;
// ISS-043 (v0.26.0): a plane with queued legs always shows its block too — the queue is the
// reason it's here even when grounded with nothing on board.
const onboard = state
? state.planes.filter((p) => p.status === 'flying' || cargoWeightFor(state, p.id) > 0 || (p.queue?.length ?? 0) > 0)
: [];
// ISS-023 (v0.21.0): Relocate — any GROUNDED plane can be repositioned to any airport via the
// existing fly endpoint (pure repositioning, no package needed since v0.12.0). Moved here from
// the right-pane market row; with state null both fall back to null and the section isn't rendered.
const grounded = state ? state.planes.filter((p) => p.status === 'available') : [];
const relocatePlane = grounded.find((p) => p.id === relocatePlaneId) ?? grounded[0] ?? null;
// ISS-023 (v0.21.0): Relocate — any plane can be repositioned to any airport via the existing fly
// endpoint (pure repositioning, no package needed since v0.12.0). Moved here from the right-pane
// market row; with state null both fall back to null and the section isn't rendered.
// ISS-053 (v0.32.0): widened from grounded-only to ALL planes — a flying plane's relocation is
// queued instead of started immediately (see relocate-go below). `grounded` stays for Refuel.
const relocatable = state ? state.planes : [];
const relocatePlane = relocatable.find((p) => p.id === relocatePlaneId) ?? relocatable[0] ?? null;
const relocateDest =
airportOptions === null ? null : airportOptions.find((a) => a.icao === relocateDestIcao) ?? airportOptions[0] ?? null;
// ISS-035 (v0.23.0): Refuel-to-full — any GROUNDED plane with fuel below max can be topped up via the
// existing refuel endpoint (no body → server defaults to mode 'full'). Moved here from the right-pane
// market row; reuses `grounded` above, and with state null falls back to null.
// market row; grounded-only, and with state null falls back to null.
const grounded = relocatable.filter((p) => p.status === 'available');
const refuelable = grounded.filter((p) => p.fuel < p.maxFuel);
const refuelPlane =
(state?.planes.find((p) => p.id === refuelPlaneId && p.status === 'available' && p.fuel < p.maxFuel)) ??
refuelable[0] ??
null;
const refuelCost = refuelPlane && state ? (refuelPlane.maxFuel - refuelPlane.fuel) * state.fuelPricePerL : null;
// v0.38.0 (ISS-072): rep-scaled preview — same formula as the server's refuelPlane 'full' mode.
const fuelDiscountRate = state ? repRate(state.account.reputation, FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT) : 0;
const refuelCost =
refuelPlane && state
? Math.round((refuelPlane.maxFuel - refuelPlane.fuel) * state.fuelPricePerL * (1 - fuelDiscountRate) * 100) / 100
: null;
// Slider commit (v0.9.1): the displayed value follows the drag optimistically via
// pendingScale; POST /api/sim fires after a 250 ms debounce, or immediately when the
@@ -341,187 +338,179 @@ export default function LeftPane({ state, loading, error, onRefresh, applySnapsh
<p className="pane-muted">No planes.</p>
) : (
<ul data-testid="fleet-list" className="fleet-list">
{state.planes.map((p) => (
// ISS-002: hovering a fleet row highlights the plane on the map.
<li
key={p.id}
data-testid={`plane-${p.id}`}
className="plane-row"
onMouseEnter={() => onHover({ kind: 'plane', id: p.id })}
onMouseLeave={() => onHover(null)}
>
<div className="plane-main">
<span className="plane-model">{PLANE_TYPES[p.model]?.name ?? p.model}</span>
<span className={`plane-status status-${p.status}`}>{p.status}</span>
</div>
<div className="fuel-bar" aria-hidden="true">
<div style={{ width: `${Math.max(0, Math.min(100, (p.fuel / p.maxFuel) * 100))}%` }} />
</div>
<div className="plane-sub">
<span data-testid={`plane-fuel-${p.id}`}>{formatFuel(p.fuel, p.maxFuel)}</span>
<span data-testid={`plane-loadout-${p.id}`}>{loadoutFor(state, p)}</span>
<span className="plane-loc">{planeLocation(p)}</span>
</div>
</li>
))}
{state.planes.map((p) => {
// ISS-054 (v0.33.0): the merged per-plane block — status/fuel/loadout plus the former
// On board section's cargo rows and queue legs, so all plane info lives in one place.
const cargo = state.packages.filter(
(pkg) => pkg.planeId === p.id && (pkg.status === 'loaded' || pkg.status === 'expired'),
);
return (
// ISS-002: hovering a fleet row highlights the plane on the map. Handlers live only on
// the outer <li> — React's enter/leave don't fire when moving to a child, so this covers
// the whole block without flicker between sub-elements; cargo rows override with their
// own package hover (below).
<li
key={p.id}
data-testid={`plane-${p.id}`}
className="plane-row"
onMouseEnter={() => onHover({ kind: 'plane', id: p.id })}
onMouseLeave={() => onHover(null)}
>
<div className="plane-main">
<span className="plane-model">{PLANE_TYPES[p.model]?.name ?? p.model}</span>
{p.status === 'flying' ? (
<>
<span className="plane-status status-flying">in flight</span>
{p.destName && (
<span data-testid={`plane-leg-${p.id}`} className="contract-route">
{p.locationName} → {p.destName}
</span>
)}
</>
) : (
<>
<span className="plane-status status-available">{p.locationIcao}</span>
{/* v0.39.0 (ISS-069): maintenance pills — "in repair" while the gate runs, "broken" at zero health. */}
{p.status === 'repairing' && (
<span data-testid={`plane-repairing-${p.id}`} className="plane-status status-repairing">in repair</span>
)}
{p.health <= 0 && (
<span data-testid={`plane-broken-${p.id}`} className="plane-status status-broken">broken</span>
)}
</>
)}
</div>
<div className="fuel-bar" aria-hidden="true">
<div style={{ width: `${Math.max(0, Math.min(100, (p.fuel / p.maxFuel) * 100))}%` }} />
</div>
{/* v0.39.0 (ISS-069): health bar — sibling of the fuel bar, amber fill. */}
<div className="health-bar" aria-hidden="true">
<div style={{ width: `${Math.max(0, Math.min(100, p.health))}%` }} />
</div>
<div className="plane-sub">
<span data-testid={`plane-fuel-${p.id}`}>{formatFuel(p.fuel, p.maxFuel)}</span>
{/* v0.39.0 (ISS-069): health percentage next to the fuel readout. */}
<span data-testid={`plane-health-${p.id}`}>{Math.round(p.health)}%</span>
<span data-testid={`plane-loadout-${p.id}`}>{loadoutFor(state, p)}</span>
<span className="plane-loc">{planeLocation(p)}</span>
</div>
{/* v0.39.0 (ISS-069): repair — grounded plane with worn health; flat cost is server-side only (no preview). */}
{p.status === 'available' && p.health < 100 && (
<button
type="button"
data-testid={`repair-${p.id}`}
className="pane-btn repair-btn"
disabled={actionsBusy}
onClick={() => void runAction(`/api/planes/${p.id}/repair`)}
>
Repair
</button>
)}
{cargo.map((pkg) => {
const penalty = abandonPenalty(pkg, clockMs);
return (
// ISS-025 (v0.22.0): hovering a loaded package draws its full origin→dest trace on the map —
// same as job-list rows — and keeps this plane's halo lit via planeId. Leaving the row restores
// the block's plain plane highlight; leaving the whole block still ends at null (the child's
// mouseleave fires before the parent's).
<div
key={pkg.id}
data-testid={`onboard-package-${pkg.id}`}
className="contract-row"
onMouseEnter={() => onHover({ kind: 'package', originIcao: pkg.originIcao, destIcao: pkg.destIcao, planeId: p.id })}
onMouseLeave={() => onHover({ kind: 'plane', id: p.id })}
>
<button
type="button"
data-testid={`fly-to-${pkg.id}`}
className="pane-btn onboard-fly-btn"
disabled={actionsBusy}
// ISS-053 (v0.32.0): a flying plane can't start another leg — the click queues it instead,
// so "Fly to …" works from any state and the queue needs no dedicated input.
onClick={() => void runAction(`/api/planes/${p.id}/${p.status === 'flying' ? 'queue' : 'fly'}`, { destIcao: pkg.destIcao })}
>
<span className="onboard-fly-label" title={`Fly to ${pkg.destName}`}>
Fly to {pkg.destName}
</span>
</button>
<div className="onboard-actions">
<button
type="button"
data-testid={`refuel-for-${pkg.id}`}
className="pane-btn"
disabled={actionsBusy || p.status !== 'available'}
onClick={() => void runAction(`/api/planes/${p.id}/refuel`, { mode: 'forFlight', packageId: pkg.id })}
>
Refuel
</button>
<button
type="button"
data-testid={`abandon-${pkg.id}`}
className="pane-btn"
disabled={actionsBusy || p.status !== 'available'}
onClick={() => void runAction(`/api/packages/${pkg.id}/abandon`)}
>
Abandon{' '}
<span data-testid={`abandon-penalty-${pkg.id}`}>−{formatCash(penalty)}</span>
</button>
</div>
<div className="contract-sub">
<span className="contract-route">{pkg.originName} → {pkg.destName}</span>
{/* v0.37.0 (ISS-073): special-status badge — rendered only when the package has one. */}
{pkg.special && (
<span data-testid={`package-special-${pkg.id}`} className="special-badge">
{formatSpecialLabel(pkg.special)}
</span>
)}
<span>{formatCash(pkg.reward)}</span>
{pkg.status === 'expired' ? (
<span data-testid={`onboard-expired-${pkg.id}`}>expired</span>
) : (
<span>{formatDeadlineRelative(pkg.deadlineSimTime, clockMs)}</span>
)}
</div>
</div>
);
})}
{/* ISS-043 (v0.26.0): flight queue — the not-yet-started legs with cancel.
ISS-053 (v0.32.0): the dedicated add input is gone — fly-to and Relocate post to
/queue while a plane is flying, so no separate entry point is needed.
ISS-054 (v0.33.0): rendered only when non-empty. */}
{p.queue.length > 0 && (
<div className="queue-subsection">
<span className="queue-heading">Queue</span>
{/* ISS-055 (v0.31.0): testids scoped by plane id — with two blocks the unscoped ids
duplicated in the DOM and broke Playwright strict mode. */}
{p.queue.map((icao, i) => (
<div key={`${icao}-${i}`} data-testid={`queue-leg-${p.id}-${i}`} className="queue-row">
<span className="contract-route">
{airportOptions?.find((a) => a.icao === icao)?.name ?? icao} ({icao})
</span>
<button
type="button"
data-testid={`queue-cancel-${p.id}-${i}`}
className="pane-btn"
disabled={actionsBusy}
onClick={() => void runAction(`/api/planes/${p.id}/queue/cancel`, { index: i })}
>
Cancel
</button>
</div>
))}
</div>
)}
</li>
);
})}
</ul>
)}
</section>
{/* ISS-021 (v0.20.0): moved here from the right pane — between Fleet and Time. */}
<section className="pane-section">
<h3 className="pane-heading">On board</h3>
{onboard.length === 0 ? (
<p className="pane-muted">Nothing on board.</p>
) : (
onboard.map((plane) => {
const cargo = state.packages.filter(
(pkg) => pkg.planeId === plane.id && (pkg.status === 'loaded' || pkg.status === 'expired'),
);
return (
// ISS-002: hovering the on-board block (including its cargo rows) highlights the plane.
// Handlers live only on the outer div — React's enter/leave don't fire when moving to a
// child, so this covers the whole block without flicker between sub-elements.
<div
key={plane.id}
data-testid={`onboard-${plane.id}`}
className="onboard-block"
onMouseEnter={() => onHover({ kind: 'plane', id: plane.id })}
onMouseLeave={() => onHover(null)}
>
<div className="contract-main">
<span className="plane-model">{PLANE_TYPES[plane.model]?.name ?? plane.model}</span>
{plane.status === 'flying' ? (
<>
<span className="plane-status status-flying">in flight</span>
{plane.destName && (
<span data-testid={`plane-leg-${plane.id}`} className="contract-route">
{plane.locationName} → {plane.destName}
</span>
)}
</>
) : (
<span className="plane-status status-available">{plane.locationIcao}</span>
)}
</div>
{cargo.map((pkg) => {
const penalty = abandonPenalty(pkg, clockMs);
return (
// ISS-025 (v0.22.0): hovering a loaded package draws its full origin→dest trace on the map —
// same as job-list rows — and keeps this plane's halo lit via planeId. Leaving the row restores
// the block's plain plane highlight; leaving the whole block still ends at null (the child's
// mouseleave fires before the parent's).
<div
key={pkg.id}
data-testid={`onboard-package-${pkg.id}`}
className="contract-row"
onMouseEnter={() => onHover({ kind: 'package', originIcao: pkg.originIcao, destIcao: pkg.destIcao, planeId: plane.id })}
onMouseLeave={() => onHover({ kind: 'plane', id: plane.id })}
>
<button
type="button"
data-testid={`fly-to-${pkg.id}`}
className="pane-btn onboard-fly-btn"
disabled={actionsBusy || plane.status !== 'available'}
onClick={() => void runAction(`/api/planes/${plane.id}/fly`, { destIcao: pkg.destIcao })}
>
<span className="onboard-fly-label" title={`Fly to ${pkg.destName}`}>
Fly to {pkg.destName}
</span>
</button>
<div className="onboard-actions">
<button
type="button"
data-testid={`refuel-for-${pkg.id}`}
className="pane-btn"
disabled={actionsBusy || plane.status !== 'available'}
onClick={() => void runAction(`/api/planes/${plane.id}/refuel`, { mode: 'forFlight', packageId: pkg.id })}
>
Refuel
</button>
<button
type="button"
data-testid={`abandon-${pkg.id}`}
className="pane-btn"
disabled={actionsBusy || plane.status !== 'available'}
onClick={() => void runAction(`/api/packages/${pkg.id}/abandon`)}
>
Abandon{' '}
<span data-testid={`abandon-penalty-${pkg.id}`}>−{formatCash(penalty)}</span>
</button>
</div>
<div className="contract-sub">
<span className="contract-route">{pkg.originName} → {pkg.destName}</span>
<span>{formatCash(pkg.reward)}</span>
{pkg.status === 'expired' ? (
<span data-testid={`onboard-expired-${pkg.id}`}>expired</span>
) : (
<span>{formatDeadlineRelative(pkg.deadlineSimTime, clockMs)}</span>
)}
</div>
</div>
);
})}
{/* ISS-043 (v0.26.0): flight queue — the not-yet-started legs with cancel, plus an
add control reusing this pane's already-fetched airport options (no second fetch). */}
<div className="queue-subsection">
<span className="queue-heading">Queue</span>
{/* ISS-055 (v0.31.0): testids scoped by plane id — with two on-board blocks the
unscoped ids duplicated in the DOM and broke Playwright strict mode. */}
{plane.queue.map((icao, i) => (
<div key={`${icao}-${i}`} data-testid={`queue-leg-${plane.id}-${i}`} className="queue-row">
<span className="contract-route">
{airportOptions?.find((a) => a.icao === icao)?.name ?? icao} ({icao})
</span>
<button
type="button"
data-testid={`queue-cancel-${plane.id}-${i}`}
className="pane-btn"
disabled={actionsBusy}
onClick={() => void runAction(`/api/planes/${plane.id}/queue/cancel`, { index: i })}
>
Cancel
</button>
</div>
))}
<div className="market-row">
<select
data-testid={`queue-add-dest-${plane.id}`}
aria-label="Airport to add to the flight queue"
value={queueAddDestIcao ?? ''}
onChange={(e) => setQueueAddDestIcao(e.target.value || null)}
>
<option value="">Select an airport…</option>
{airportOptions?.map((a) => (
<option key={a.icao} value={a.icao}>
{a.name}
</option>
))}
</select>
<button
type="button"
data-testid={`queue-add-${plane.id}`}
className="pane-btn"
disabled={actionsBusy || queueAddDestIcao === null}
onClick={() => {
if (queueAddDestIcao) void runAction(`/api/planes/${plane.id}/queue`, { destIcao: queueAddDestIcao });
}}
>
Add to queue
</button>
</div>
</div>
</div>
);
})
)}
</section>
{/* ISS-023 (v0.21.0): moved here from the right-pane market row — it belongs with
everything else about our planes, between On board and Time. */}
everything else about our planes, between Fleet and Time. */}
{/* ISS-027 (v0.22.0): hovering the section previews the planned relocation on the map —
dashed trace from the selected plane's airport to the destination + a ring on the target.
With no grounded plane or no loaded airport options there is nothing to preview → null. */}
With no planes or no loaded airport options there is nothing to preview → null. */}
<section
className="pane-section"
data-testid="relocate-section"
@@ -535,8 +524,9 @@ export default function LeftPane({ state, loading, error, onRefresh, applySnapsh
onMouseLeave={() => onHover(null)}
>
<h3 className="pane-heading">Relocate</h3>
{/* ISS-010 (v0.18.0): relocate a grounded plane — the fly endpoint already allows pure
repositioning, this row just exposes it for planes with nothing on board. */}
{/* ISS-010 (v0.18.0): relocate a plane — the fly endpoint already allows pure
repositioning, this row just exposes it for planes with nothing on board.
ISS-053 (v0.32.0): lists ALL planes; a flying one's relocation is queued below. */}
<div className="market-row market-row-relocate">
<select
data-testid="relocate-plane"
@@ -544,7 +534,7 @@ export default function LeftPane({ state, loading, error, onRefresh, applySnapsh
value={relocatePlane?.id ?? ''}
onChange={(e) => setRelocatePlaneId(e.target.value || null)}
>
{grounded.map((p) => (
{relocatable.map((p) => (
<option key={p.id} value={p.id}>
{PLANE_TYPES[p.model]?.name ?? p.model} — {p.locationIcao}
</option>
@@ -567,9 +557,11 @@ export default function LeftPane({ state, loading, error, onRefresh, applySnapsh
data-testid="relocate-go"
className="pane-btn"
disabled={actionsBusy || relocatePlane === null || relocateDest === null}
// ISS-053 (v0.32.0): a flying plane can't start another leg — queue it instead, so the
// relocation takes off when the current one lands.
onClick={() => {
if (relocatePlane && relocateDest) {
void runAction(`/api/planes/${relocatePlane.id}/fly`, { destIcao: relocateDest.icao });
void runAction(`/api/planes/${relocatePlane.id}/${relocatePlane.status === 'flying' ? 'queue' : 'fly'}`, { destIcao: relocateDest.icao });
}
}}
>
@@ -583,7 +575,7 @@ export default function LeftPane({ state, loading, error, onRefresh, applySnapsh
<section className="pane-section" data-testid="refuel-section">
<h3 className="pane-heading">Refuel</h3>
{/* Moved verbatim from the right-pane Market section — same testids/labels; the per-package
"Refuel" button in On board (mode 'forFlight') is a different feature and stays there. */}
"Refuel" button in the fleet rows (mode 'forFlight') is a different feature and stays there. */}
<div className="market-row">
{refuelable.length === 0 ? (
<span data-testid="market-refuel-none" className="pane-muted">
@@ -606,6 +598,10 @@ export default function LeftPane({ state, loading, error, onRefresh, applySnapsh
<span data-testid="market-refuel-cost" className="market-preview">
{refuelCost !== null ? formatCash(refuelCost) : ''}
</span>
{/* v0.38.0 (ISS-072): rep discount hint — only when the account actually gets one. */}
{fuelDiscountRate > 0 && (
<span data-testid="refuel-discount" className="special-badge">−{Math.round(fuelDiscountRate * 100)}%</span>
)}
</>
)}
<button
+18 -4
View File
@@ -2,9 +2,9 @@
import { useState } from 'react';
import type { PackageState, PlaneState, StateSnapshot } from '@/lib/game/state';
import { PLANE_TYPES, 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 { formatCash, formatDeadlineRelative } from '@/lib/format';
import { formatCargo, formatCash, formatDeadlineRelative, formatSpecialLabel } from '@/lib/format';
import { useGameAction } from '@/lib/useGameAction';
import type { HoverTarget } from './hoverTypes';
@@ -151,6 +151,9 @@ export default function RightPane({ state, loading, applySnapshot, onHover, onEr
// Previews computed client-side from the snapshot's catalog + economy constants (v0.10.0).
const sellEntry = sellPlane ? catalog.find((e) => e.model === sellPlane.model) : undefined;
const sellPayout = sellEntry ? Math.round(sellEntry.price * state.sellFraction * 100) / 100 : null;
// v0.38.0 (ISS-072): the buy row shows the EFFECTIVE price — same rep-scaled formula as the server's buyPlane.
const planeDiscountRate = repRate(state.account.reputation, PLANE_DISCOUNT_PER_REP, MAX_PLANE_DISCOUNT);
const buyPrice = buyEntry ? Math.round(buyEntry.price * (1 - planeDiscountRate) * 100) / 100 : null;
return (
<aside data-testid="right-pane" className="pane right-pane">
@@ -200,6 +203,12 @@ export default function RightPane({ state, loading, applySnapshot, onHover, onEr
>
<div className="contract-main">
<span className="contract-route">{pkg.originName} → {pkg.destName}</span>
{/* v0.37.0 (ISS-073): special-status badge — rendered only when the package has one. */}
{pkg.special && (
<span data-testid={`package-special-${pkg.id}`} className="special-badge">
{formatSpecialLabel(pkg.special)}
</span>
)}
{eligible.length > 0 ? (
<LoadControls
pkg={pkg}
@@ -225,7 +234,8 @@ export default function RightPane({ state, loading, applySnapshot, onHover, onEr
</div>
<div className="contract-sub">
<span data-testid={`open-package-reward-${pkg.id}`}>{formatCash(pkg.reward)}</span>
<span>{pkg.cargoType} × {pkg.cargoCount}</span>
{/* ISS-066 (v0.35.0): cargo shows as type + tonnage ("Mail 10.0t"), not "type × count". */}
<span data-testid={`open-package-cargo-${pkg.id}`}>{formatCargo(pkg.cargoType, pkg.weightKg)}</span>
<span data-testid={`open-package-deadline-${pkg.id}`}>
{formatDeadlineRelative(pkg.deadlineSimTime, clockMs)}
</span>
@@ -258,8 +268,12 @@ export default function RightPane({ state, loading, applySnapshot, onHover, onEr
))}
</select>
<span data-testid="market-buy-price" className="market-preview">
{buyEntry ? formatCash(buyEntry.price) : ''}
{buyPrice !== null ? formatCash(buyPrice) : ''}
</span>
{/* v0.38.0 (ISS-072): rep discount hint — only when the account actually gets one. */}
{planeDiscountRate > 0 && (
<span data-testid="market-buy-discount" className="special-badge">−{Math.round(planeDiscountRate * 100)}%</span>
)}
<button
type="button"
data-testid="market-buy"
+3
View File
@@ -24,6 +24,8 @@ function plane(over: Partial<PlaneState> & Pick<PlaneState, 'id'>): PlaneState {
departureSimTime: null,
lat: 50,
lon: 8.5,
health: 100, // v0.39.0 (ISS-069) — full-health fixture default
repairUntilSimTime: null,
queue: [],
...over,
};
@@ -39,6 +41,7 @@ function pkg(over: Partial<PackageState> & Pick<PackageState, 'id'>): PackageSta
cargoType: 'mail',
cargoCount: 4,
weightKg: 1_000,
special: null, // v0.37.0 (ISS-073) — no special status by default in fixtures
deadlineSimTime: 1_000_000,
status: 'open',
planeId: null,
+26 -6
View File
@@ -1,5 +1,12 @@
import type { Page, Request } from '@playwright/test';
import { expect, test } from '@playwright/test';
// Real helpers (v0.36.0) — the wind-adjusted fuel math must match lib/ exactly; Playwright resolves
// the repo's tsconfig paths, so importing beats mirroring a third copy of the formulas.
import { initialBearingDeg } from '../lib/geo';
import { weatherFor } from '../lib/weather';
import { windBurnFactor } from '../lib/sim/movement';
// v0.38.0 (ISS-072) — reputation discounts: expectations computed from the LIVE snapshot's rep with the real helpers.
import { FUEL_DISCOUNT_PER_REP, FUEL_PRICE_PER_L, MAX_FUEL_DISCOUNT, repRate } from '../lib/game/catalog';
// v0.12.0 package model against the live origin (https://flight.dev.kkhost.pl):
// the full core loop through the UI — hook creates a short package at EDDF → Load
@@ -16,7 +23,7 @@ const FAR = 'LEZL'; // Seville — a long leg: under the v0.16.0 weighted model
// Mirrors lib/sim/movement.ts (v0.16.0 weight-proportional burn) + lib/game/catalog.ts (atr72).
const FUEL_DENSITY_KG_PER_L = 0.8; // Jet-A1 density — must match FUEL_DENSITY_KG_PER_L in movement.ts
const FUEL_PRICE_PER_L = 0.8; // must match catalog.ts
// FUEL_PRICE_PER_L is imported from ../lib/game/catalog (v0.38.0) instead of mirrored here.
const REP_BASE = 1; // lib/sim/engine.ts
const REP_PER_KM = 0.0005;
@@ -111,7 +118,11 @@ test.describe.configure({ mode: 'serial' }); // shared live DB — order matters
// (repositioning needs no package — v0.12.0 manual flight), and abandon any leftover cargo so
// exact cash/rep assertions start from a clean baseline even after an interrupted previous run.
async function flyHomeViaApi(page: Page): Promise<void> {
let s = await getState(page);
// v0.39.0 (ISS-069): wear accumulates across the shared CI DB — restore full health and clear any
// active repair gate from an interrupted run before the steps below act on the starter, so the FAR
// round-trip feasibility pin never sees a worn or broken plane.
expect((await page.request.post('/api/test/set-health', { data: { planeId: 'plane-starter', health: 100, repairUntilMs: 0 } })).status()).toBe(200);
let s = await getState(page); // ticks — a shortened gate flips the plane back to available here
let plane = s.planes.find((p) => p.id === 'plane-starter')!;
if (plane.status === 'flying' || plane.locationIcao !== HUB) {
// Fast-forward any leftover leg / repositioning flight (~20 s real time worst case at 1000×).
@@ -219,20 +230,29 @@ test('full core loop via UI: load → fly to destination → exact reward + rep
const repGain = Math.round((REP_BASE + REP_PER_KM * exactKm) * 100) / 100;
expect(s.account.reputation).toBeCloseTo(Math.round((before.account.reputation + repGain) * 100) / 100, 9);
// Fuel burned per the v0.16.0 weight-proportional model (closed form — no rounding drift).
// Fuel burned per the v0.16.0 weight-proportional model (closed form — no rounding drift),
// wind-adjusted (v0.36.0) by departure weather vs route heading.
const entry = before.catalog.find((e) => e.model === 'atr72')!;
const wHub = weatherFor(HUB);
expect(landed.fuel).toBeCloseTo(
fuelAfter(before.planes.find((p) => p.id === planeId)!.fuel, entry.burnPerKm, entry.emptyWeightKg, entry.maxFuel, exactKm),
fuelAfter(
before.planes.find((p) => p.id === planeId)!.fuel,
entry.burnPerKm * windBurnFactor(wHub.windKt, wHub.windDirDeg, initialBearingDeg(nearest.hubLat, nearest.hubLon, nearest.lat, nearest.lon)),
entry.emptyWeightKg,
entry.maxFuel,
exactKm,
),
6,
);
// Refuel via the API: cost = (maxFuel − fuel) × FUEL_PRICE_PER_L, deducted exactly.
// Refuel via the API: cost = (maxFuel − fuel) × FUEL_PRICE_PER_L, rep-scaled (v0.38.0, ISS-072), deducted exactly.
const refuelRes = await page.request.post(`/api/planes/${planeId}/refuel`);
expect(refuelRes.ok()).toBeTruthy();
s = await getState(page);
const afterRefuel = s.planes.find((p) => p.id === planeId)!;
expect(afterRefuel.fuel).toBe(afterRefuel.maxFuel); // back to full
const refuelCost = Math.round((afterRefuel.maxFuel - landed.fuel) * FUEL_PRICE_PER_L * 100) / 100;
const fuelRate = repRate(s.account.reputation, FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT);
const refuelCost = Math.round((afterRefuel.maxFuel - landed.fuel) * FUEL_PRICE_PER_L * (1 - fuelRate) * 100) / 100;
expect(s.account.cash).toBeCloseTo(before.account.cash + pkg.reward - refuelCost, 6);
// Restore: fly home (repositioning — no package needed in the v0.12.0 model). ISS-041: the
+2 -1
View File
@@ -13,7 +13,7 @@ test('GET /api/version returns the app version from package.json', async ({ page
expect(body.version).toBe(pkg.version);
});
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 fourteen endpoints and the package.json version', async ({ page }) => {
const res = await page.request.get('/api/openapi');
expect(res.status()).toBe(200);
expect(res.headers()['content-type']).toContain('application/json');
@@ -32,6 +32,7 @@ test('GET /api/openapi serves the spec with all thirteen endpoints and the packa
'/api/planes/{planeId}/queue',
'/api/planes/{planeId}/queue/cancel',
'/api/planes/{planeId}/refuel',
'/api/planes/{planeId}/repair', // v0.39.0 (ISS-069)
'/api/sim',
'/api/state',
'/api/version',
+3 -3
View File
@@ -439,7 +439,7 @@ test('hovering the Relocate section previews the planned route and target airpor
}).toBe(0);
});
test('hovering an on-board block of a flying plane highlights its route', async ({ page }) => {
test('hovering the fleet row of a flying plane highlights its route', async ({ page }) => {
test.setTimeout(600_000); // self-heal + one flight at scale 1000 + fly-home cleanup
await flyHomeViaApi(page);
@@ -458,8 +458,8 @@ test('hovering an on-board block of a flying plane highlights its route', async
expect(fly.status()).toBe(200);
await setScale(page, SCALE);
// The on-board block shows the plane in flight once a snapshot with status 'flying' lands.
const block = page.getByTestId('onboard-plane-starter');
// The fleet row shows the plane in flight once a snapshot with status 'flying' lands (ISS-054).
const block = page.getByTestId('plane-plane-starter');
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
+250
View File
@@ -0,0 +1,250 @@
import type { Page } from '@playwright/test';
import { expect, test } from '@playwright/test';
// Real helpers (v0.39.0) — the broken-state burn math must match lib/ exactly; Playwright resolves
// the repo's tsconfig paths, so importing beats mirroring a third copy of the formulas.
import { initialBearingDeg } from '../lib/geo';
import { weatherFor } from '../lib/weather';
import { fuelAtWeighted, windBurnFactor } from '../lib/sim/movement';
// v0.39.0 (ISS-069) — maintenance: expectations use the real balance constants + burn helpers.
import { BROKEN_BURN_MULTIPLIER, REPAIR_COST } from '../lib/game/catalog';
// v0.39.0 (ISS-069) — maintenance visible flow against the live origin: health is shown per plane in
// the fleet list; forcing it to 0 via the set-health hook shows the "broken" pill and makes a short
// full-tank leg burn ×1.5 more fuel (asserted with the real lib helpers); clicking Repair deducts
// exactly REPAIR_COST, restores health and grounds the plane ("in repair" pill) until its gate
// passes; shortening the gate via the hook brings it back to service on the next tick.
const SCALE = 1000; // fastest sim speed (v0.14.0 ladder top) — keeps flights to seconds of real time
const HUB = 'EDDF'; // must match HUB_ICAO in lib/game/seed.ts
const STARTER = 'plane-starter';
interface PlaneState {
id: string;
model: string;
status: 'available' | 'flying' | 'repairing';
fuel: number;
maxFuel: number;
locationIcao: string;
destIcao: string | null;
health: number; // v0.39.0 (ISS-069) — structural health in [0, 100]
repairUntilSimTime: number | null; // v0.39.0 (ISS-069) — active repair gate
}
interface CatalogEntry {
model: string;
burnPerKm: number;
emptyWeightKg: number;
maxFuel: number;
}
interface StateBody {
account: { cash: number };
simState: { clockMs: number; timeScale: number; paused: boolean };
planes: PlaneState[];
catalog: CatalogEntry[];
}
async function getState(page: Page): Promise<StateBody> {
const res = await page.request.get('/api/state');
expect(res.status()).toBe(200);
return (await res.json()) as StateBody;
}
// Set the time scale and VERIFY from the response that it stuck — a silent reset would otherwise
// surface as an unexplained wait timeout. Also clears pause (ISS-041: landings slow to 1×).
async function setScale(page: Page, scale: number): Promise<void> {
const res = await page.request.post('/api/sim', { data: { timeScale: scale, paused: false } });
expect(res.status()).toBe(200);
const body = (await res.json()) as { timeScale: number };
expect(body.timeScale, `timeScale should be ${scale} after POST /api/sim`).toBe(scale);
}
// Test hook (FLIGHT_TEST_HOOKS=1): force a plane's health and/or shorten an active repair gate.
async function setHealth(
page: Page,
body: { planeId: string; health?: number; repairUntilMs?: number },
): Promise<StateBody> {
const res = await page.request.post('/api/test/set-health', { data: body });
expect(res.status()).toBe(200);
return (await res.json()) as StateBody;
}
// Mirrors haversineKm in lib/geo.ts (same formula → identical floats).
function haversineKm(lat1: number, lon1: number, lat2: number, lon2: number): number {
const toRad = (d: number) => (d * Math.PI) / 180;
const dLat = toRad(lat2 - lat1);
const dLon = toRad(lon2 - lon1);
const a =
Math.sin(dLat / 2) ** 2 +
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2;
return 2 * 6371 * Math.asin(Math.min(1, Math.sqrt(a)));
}
// Closest airport to EDDF from /api/airports — the short fixed leg for the broken-burn flight.
// Returns both endpoints' coordinates so the exact-km fuel assertion can mirror lib/geo.ts.
async function nearestToHub(page: Page): Promise<{ icao: string; lat: number; lon: number; hubLat: number; hubLon: number }> {
const fc = (await (await page.request.get('/api/airports')).json()) as {
features: Array<{ properties: { icao: string; lat: number; lon: number } }>;
};
const hub = fc.features.find((f) => f.properties.icao === HUB)!.properties;
let nearest = fc.features.filter((f) => f.properties.icao !== HUB)[0].properties;
for (const f of fc.features) {
if (f.properties.icao === HUB) continue;
if (haversineKm(hub.lat, hub.lon, f.properties.lat, f.properties.lon) < haversineKm(hub.lat, hub.lon, nearest.lat, nearest.lon)) {
nearest = f.properties;
}
}
return { icao: nearest.icao, lat: nearest.lat, lon: nearest.lon, hubLat: hub.lat, hubLon: hub.lon };
}
// Self-heal: let any in-flight leg finish and reposition plane-starter to the hub (shared DB — an
// interrupted earlier run may have left it elsewhere or airborne). Same pattern as flyHomeViaApi in
// right-pane.spec.ts; cargo cleanup is skipped because this flow asserts cash DELTAS, not absolutes.
async function healStarterHome(page: Page): Promise<void> {
let s = await getState(page);
let starter = s.planes.find((p) => p.id === STARTER)!;
if (starter.status === 'repairing') {
// An interrupted run may have left it mid-repair — shorten the gate so the next tick flips it back.
await setHealth(page, { planeId: STARTER, health: 100, repairUntilMs: 0 });
await expect
.poll(async () => ((await getState(page)).planes.find((p) => p.id === STARTER)?.status ?? ''), {
timeout: 30_000,
message: 'waiting for the shortened repair gate to flip plane-starter back',
})
.toBe('available');
s = await getState(page);
starter = s.planes.find((p) => p.id === STARTER)!;
}
if (starter.status === 'flying' || starter.locationIcao !== HUB) {
// Fast-forward any leftover leg / repositioning flight (~20 s real time worst case at 1000×).
await setScale(page, SCALE);
}
if (starter.status === 'flying') {
await expect
.poll(async () => ((await getState(page)).planes.find((p) => p.id === STARTER)?.status ?? ''), {
timeout: 240_000,
message: 'waiting for an in-flight plane-starter to land',
})
.toBe('available');
s = await getState(page);
starter = s.planes.find((p) => p.id === STARTER)!;
}
if (starter.locationIcao !== HUB) {
// ISS-041: the landing above slowed the sim to 1× — setScale restores it for the return leg.
await setScale(page, SCALE);
let flyHome = await page.request.post(`/api/planes/${STARTER}/fly`, { data: { destIcao: HUB } });
if (!flyHome.ok()) {
// A long outbound leg can leave the tank too empty for the return — top up and retry.
expect((await page.request.post(`/api/planes/${STARTER}/refuel`)).status()).toBe(200);
flyHome = await page.request.post(`/api/planes/${STARTER}/fly`, { data: { destIcao: HUB } });
}
expect(flyHome.status()).toBe(200); // repositioning needs no package (v0.12.0 manual flight)
await expect
.poll(async () => ((await getState(page)).planes.find((p) => p.id === STARTER)?.locationIcao ?? ''), {
timeout: 240_000,
message: 'waiting for plane-starter to land back at the hub',
})
.toBe(HUB);
}
}
// Leave a clean starter for later spec files (shared DB, workers:1): full health and no active
// repair gate — an interrupted run may have left the plane mid-repair or worn down.
async function restoreStarter(page: Page): Promise<void> {
const s = await getState(page);
const starter = s.planes.find((p) => p.id === STARTER)!;
const body: { planeId: string; health?: number; repairUntilMs?: number } = { planeId: STARTER, health: 100 };
if (starter.status === 'repairing') body.repairUntilMs = 0; // shorten the active gate → flips on the next tick
await setHealth(page, body);
}
test.describe.configure({ mode: 'serial' }); // shared live DB — order matters within this file
// On exit (success or failure): restore scale=1 / unpaused for the other specs (house convention)
// and leave a clean starter — full health, no active repair gate.
test.afterEach(async ({ page }) => {
const res = await page.request.post('/api/sim', { data: { timeScale: 1, paused: false } });
expect(res.status()).toBe(200);
await restoreStarter(page);
});
test('health shown → broken burn ×1.5 → repair grounds for 3 sim days → back to service', async ({ page }) => {
test.setTimeout(300_000); // self-heal + one short leg at 1000× + the shortened repair gate wait
await healStarterHome(page); // deterministic: the starter sits grounded at EDDF
// (a) Force health to 42 — snapshot AND UI show it.
let s = await setHealth(page, { planeId: STARTER, health: 42 });
expect(s.planes.find((p) => p.id === STARTER)!.health).toBe(42);
await page.goto('/');
const row = page.getByTestId(`plane-${STARTER}`);
await expect(row, 'the starter fleet row renders').toBeVisible({ timeout: 15_000 });
await expect(page.getByTestId(`plane-health-${STARTER}`), 'health readout shows the forced value')
.toContainText('42%');
// (b) Force broken — the pill shows, and a short full-tank leg burns ×1.5 more fuel.
s = await setHealth(page, { planeId: STARTER, health: 0 });
expect(s.planes.find((p) => p.id === STARTER)!.health).toBe(0);
await expect(page.getByTestId(`plane-broken-${STARTER}`), 'the broken pill appears')
.toHaveText('broken', { timeout: 15_000 });
// Full tank for the flight (free no-op if already full)…
expect((await page.request.post(`/api/planes/${STARTER}/refuel`)).status()).toBe(200);
s = await getState(page);
const starter = s.planes.find((p) => p.id === STARTER)!;
expect(starter.fuel).toBe(starter.maxFuel);
// …then fly the short fixed leg HUB → nearest via the API (the UI under test is maintenance, not dispatch).
const nearest = await nearestToHub(page);
expect((await page.request.post(`/api/planes/${STARTER}/fly`, { data: { destIcao: nearest.icao } })).status()).toBe(200);
await setScale(page, SCALE);
await expect
.poll(async () => ((await getState(page)).planes.find((p) => p.id === STARTER)?.locationIcao ?? ''), {
timeout: 120_000,
message: 'waiting for the broken plane to land at the nearest airport',
})
.toBe(nearest.icao);
// Landed fuel matches the ×1.5-adjusted closed form (real lib helpers — same pattern as right-pane.spec.ts).
s = await getState(page);
const landed = s.planes.find((p) => p.id === STARTER)!;
const entry = s.catalog.find((e) => e.model === starter.model)!;
const km = haversineKm(nearest.hubLat, nearest.hubLon, nearest.lat, nearest.lon);
const wHub = weatherFor(HUB);
const burn = entry.burnPerKm * windBurnFactor(wHub.windKt, wHub.windDirDeg, initialBearingDeg(nearest.hubLat, nearest.hubLon, nearest.lat, nearest.lon));
expect(landed.fuel).toBeCloseTo(fuelAtWeighted(starter.maxFuel, burn * BROKEN_BURN_MULTIPLIER, entry.emptyWeightKg, entry.maxFuel, km, 1), 6);
// Wear clamped at 0 — the broken plane stays broken.
expect(landed.health).toBe(0);
// (c) Repair through the UI: exact cash drop + "in repair" pill.
const cashBefore = s.account.cash;
const repairBtn = page.getByTestId(`repair-${STARTER}`);
await expect(repairBtn, 'the Repair button shows for a grounded worn plane').toBeVisible({ timeout: 15_000 });
// The click fires the POST asynchronously — capture the endpoint's OWN response so the cash drop is
// checked against the exact game clock the server used (a later GET would tick further).
const [res] = await Promise.all([
page.waitForResponse((r) => r.url().includes(`/api/planes/${STARTER}/repair`), { timeout: 15_000 }),
repairBtn.click(),
]);
expect(res.status()).toBe(200);
const post = (await res.json()) as StateBody; // full post-action snapshot at the action's clock
expect(post.account.cash).toBeCloseTo(cashBefore - REPAIR_COST, 6); // exactly REPAIR_COST
const repaired = post.planes.find((p) => p.id === STARTER)!;
expect(repaired.status).toBe('repairing');
expect(repaired.health).toBe(100); // restored to full…
expect(repaired.repairUntilSimTime).not.toBeNull(); // …with the gate set (3 sim days out)
await expect(page.getByTestId(`plane-repairing-${STARTER}`), 'the "in repair" pill appears')
.toHaveText('in repair', { timeout: 15_000 });
// (d) Shorten the gate via the hook — the plane comes back to service on the next tick.
await setHealth(page, { planeId: STARTER, repairUntilMs: 10_000 });
await setScale(page, SCALE); // fast-forward the shortened gate (each tick advances up to 2 s × scale)
await expect
.poll(async () => {
const p = (await getState(page)).planes.find((x) => x.id === STARTER)!;
return p.status === 'available' && p.health === 100 ? true : false;
}, { timeout: 60_000, message: 'waiting for the shortened repair gate to flip the plane back' })
.toBe(true);
});
+3 -4
View File
@@ -70,10 +70,9 @@ test('left pane renders wallet, reputation and the starter fleet at Frankfurt',
// Sim clock renders (UTC wall-clock of the game time).
await expect(page.getByTestId('sim-clock')).toContainText(/UTC/);
// ISS-021 (v0.20.0): the On board section moved to the left pane (between Fleet and Time) —
// its heading renders here…
await expect(page.getByTestId('left-pane').getByRole('heading', { name: 'On board', exact: true })).toBeVisible();
// …and no longer in the right pane.
// ISS-054 (v0.33.0): On board merged into the Fleet list — its heading is gone from both panes,
// per-plane cargo rows now render inside the fleet rows.
await expect(page.getByTestId('left-pane').getByRole('heading', { name: 'On board', exact: true })).toHaveCount(0);
await expect(page.getByTestId('right-pane').getByRole('heading', { name: 'On board', exact: true })).toHaveCount(0);
// ISS-035 (v0.23.0): the Refuel-to-full row moved to the left pane next to Relocate — its section
+14 -11
View File
@@ -1,7 +1,8 @@
import { test, expect, type Page } from '@playwright/test';
// v0.26.0 — ISS-043 part 2 (UI): the flight queue. A plane's not-yet-started legs show up in a
// Queue sub-section of its on-board block (add via airport select + button, cancel per leg), and
// Queue sub-section of its on-board block (cancel per leg; adding happens via fly-to / Relocate
// while flying, ISS-053), and
// a flying plane with queued legs draws them as a DOTTED line (`queue-routes` layer) chaining
// dest → queue[0] → … — the active leg stays in the solid `routes-legs` layer. Landing auto-starts
// the next queued leg server-side (part 1); test 3 exercises that chain end-to-end through the API.
@@ -216,10 +217,9 @@ test('queue display + cancel via UI (ISS-043 part 2)', async ({ page }) => {
const onboardRow = page.getByTestId(`onboard-package-${pkg.id}`);
await expect(onboardRow, 'loaded package appears in the On board section').toBeVisible({ timeout: 15_000 });
// LeftPane's own /api/airports fetch populates the queue-add select — wait for it.
// ISS-055 (v0.31.0): queue testids are scoped by plane id (the e2e fleet is one plane here).
const addSelect = page.getByTestId('queue-add-dest-plane-starter');
await expect(addSelect.locator(`option[value="${QUEUE}"]`)).toHaveCount(1, { timeout: 15_000 });
// LeftPane's own /api/airports fetch populates the Relocate destination select — wait for it.
const destSelect = page.getByTestId('relocate-dest');
await expect(destSelect.locator(`option[value="${QUEUE}"]`)).toHaveCount(1, { timeout: 15_000 });
// START leg 1 via the UI (fly-to button in the on-board block).
const flyBtn = page.getByTestId(`fly-to-${pkg.id}`);
@@ -233,12 +233,15 @@ test('queue display + cancel via UI (ISS-043 part 2)', async ({ page }) => {
}, { timeout: 15_000, message: 'waiting for the fly click to take effect (status "flying" + dest set)' }).toBe(true);
await postSim(page, { paused: true }); // pause-sim pattern: deterministic window for UI clicks
// ADD a leg to the queue via the UI. The button is disabled until an airport is selected…
const addButton = page.getByTestId('queue-add-plane-starter');
await expect(addButton).toBeDisabled();
await addSelect.selectOption(QUEUE);
await expect(addButton).toBeEnabled();
await addButton.click();
// ISS-065: re-queuing the CURRENT leg's destination while flying must be rejected with 409.
const dupQueue = await page.request.post('/api/planes/plane-starter/queue', { data: { destIcao: DEST } });
expect(dupQueue.status()).toBe(409);
// ADD a leg to the queue via the Relocate section (ISS-053): the starter is flying, so relocate-go
// posts to /queue and the leg starts when it lands.
await page.getByTestId('relocate-plane').selectOption('plane-starter');
await destSelect.selectOption(QUEUE);
await page.getByTestId('relocate-go').click();
// …and the new leg appears as queue-leg-0 with its destination.
const legRow = page.getByTestId('queue-leg-plane-starter-0');
+99 -24
View File
@@ -1,5 +1,13 @@
import type { Page } from '@playwright/test';
import { expect, test } from '@playwright/test';
// Real helpers (v0.36.0) — the wind-adjusted fuel math must match lib/ exactly; Playwright resolves
// the repo's tsconfig paths, so importing beats mirroring a third copy of the formulas.
import { initialBearingDeg } from '../lib/geo';
import { weatherFor } from '../lib/weather';
import { windBurnFactor } from '../lib/sim/movement';
// v0.38.0 (ISS-072) — reputation discounts: expectations computed from the LIVE snapshot's rep with the real helpers.
import { FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT, MAX_PLANE_DISCOUNT, PLANE_DISCOUNT_PER_REP, repRate } from '../lib/game/catalog';
import { STARTING_REPUTATION } from '../lib/game/seed';
// v0.12.0 — right Jobs & Market pane against the live origin: open packages render with
// names/reward/deadline and Load (UI click) binds one to the starter; the on-board list
@@ -32,6 +40,7 @@ interface PackageState {
planeId: string | null;
createdSimTime: number;
deadlineSimTime: number;
special: string | null; // v0.37.0 (ISS-073) — special status from the specials registry
}
interface CatalogEntry {
@@ -119,22 +128,28 @@ async function nearestToHub(page: Page): Promise<{ icao: string; lat: number; lo
}
// Farthest airport from EDDF whose leg still FITS A FULL TANK — the longest flyable leg (v0.16.0
// refuel-for-flight test). The dataset's absolute farthest can exceed the aircraft's range (UNNT is
// ~4803 km out and needs ~6955 L > the 6300 L tank), so a full-tank fly there would rightly 409;
// this test wants a long-but-flyable leg instead.
// refuel-for-flight test). The dataset's absolute farthest can exceed the aircraft's range, so a
// full-tank fly there would rightly 409; this test wants a long-but-flyable leg instead. Wind-
// adjusted (v0.36.0): BOTH directions must fit — the outbound is flown from a full tank and the
// return leg is what the forFlight refuel below buys, which also 409s if it doesn't fit.
async function farthestFlyableToHub(
page: Page,
entry: CatalogEntry,
): Promise<{ icao: string; lat: number; lon: number; hubLat: number; hubLon: number }> {
): Promise<{ icao: string; lat: number; lon: number; d: number; hubLat: number; hubLon: number }> {
const fc = (await (await page.request.get('/api/airports')).json()) as {
features: Array<{ properties: { icao: string; lat: number; lon: number } }>;
};
const hub = fc.features.find((f) => f.properties.icao === HUB)!.properties;
const wHub = weatherFor(HUB);
let farthest: { icao: string; lat: number; lon: number; d: number } | null = null;
for (const f of fc.features) {
if (f.properties.icao === HUB) continue;
const d = haversineKm(hub.lat, hub.lon, f.properties.lat, f.properties.lon);
if (requiredFuelFor(entry.burnPerKm, entry.emptyWeightKg, entry.maxFuel, d) > entry.maxFuel) continue; // beyond range
const wDest = weatherFor(f.properties.icao);
const outBurn = entry.burnPerKm * windBurnFactor(wHub.windKt, wHub.windDirDeg, initialBearingDeg(hub.lat, hub.lon, f.properties.lat, f.properties.lon));
if (requiredFuelFor(outBurn, entry.emptyWeightKg, entry.maxFuel, d) > entry.maxFuel) continue; // beyond range
const backBurn = entry.burnPerKm * windBurnFactor(wDest.windKt, wDest.windDirDeg, initialBearingDeg(f.properties.lat, f.properties.lon, hub.lat, hub.lon));
if (requiredFuelFor(backBurn, entry.emptyWeightKg, entry.maxFuel, d) > entry.maxFuel) continue; // return leg wouldn't fit a full tank either
if (!farthest || d > farthest.d) {
farthest = { icao: f.properties.icao, lat: f.properties.lat, lon: f.properties.lon, d };
}
@@ -157,10 +172,11 @@ function maxRangeKm(burnPerKm: number, emptyWeightKg: number, maxFuel: number):
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.
async function createPackage(page: Page, fromIcao: string, toIcao: string, deadlineOffsetMs?: number): Promise<PackageState> {
// Test hook (FLIGHT_TEST_HOOKS=1): create an open package on a known route. Omitting `special`
// makes the hook force null (no roll), so existing specs' exact reward/rep assertions stay deterministic.
async function createPackage(page: Page, fromIcao: string, toIcao: string, deadlineOffsetMs?: number, weightKg?: number, special?: string | null): Promise<PackageState> {
const created = await page.request.post('/api/test/create-package', {
data: { originIcao: fromIcao, destIcao: toIcao, deadlineOffsetMs },
data: { originIcao: fromIcao, destIcao: toIcao, deadlineOffsetMs, weightKg, special },
});
expect(created.status()).toBe(201);
return (await created.json()) as PackageState;
@@ -168,7 +184,11 @@ async function createPackage(page: Page, fromIcao: string, toIcao: string, deadl
// Self-heal: let any in-flight leg finish, then reposition plane-starter home via the API.
async function flyHomeViaApi(page: Page): Promise<void> {
const s0 = await getState(page);
// v0.39.0 (ISS-069): wear accumulates across the shared CI DB — restore full health and clear any
// active repair gate from an interrupted run before the steps below act on the starter, so the
// farthest-flyable picker and fuel pins never see a worn or broken plane.
expect((await page.request.post('/api/test/set-health', { data: { planeId: 'plane-starter', health: 100, repairUntilMs: 0 } })).status()).toBe(200);
const s0 = await getState(page); // ticks — a shortened gate flips the plane back to available here
const starter = s0.planes.find((p) => p.id === 'plane-starter')!;
if (starter.status === 'flying' || starter.locationIcao !== HUB) {
// Fast-forward any leftover leg / repositioning flight (~20 s real time worst case at 1000×).
@@ -186,8 +206,13 @@ async function flyHomeViaApi(page: Page): Promise<void> {
// ISS-041: the landing above paused the sim — setScale clears pause so the return leg
// fast-forwards instead of taking real-time hours.
await setScale(page, SCALE);
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)
let flyHome = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
if (!flyHome.ok()) {
// A long outbound leg can leave the tank too empty for the return — top up and retry.
expect((await page.request.post('/api/planes/plane-starter/refuel')).status()).toBe(200);
flyHome = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
}
expect(flyHome.status()).toBe(200); // repositioning needs no package (v0.12.0 manual flight)
await expect
.poll(async () => (await getState(page)).planes.find((p) => p.id === 'plane-starter')?.locationIcao === HUB, {
timeout: 240_000,
@@ -195,6 +220,14 @@ async function flyHomeViaApi(page: Page): Promise<void> {
})
.toBe(true);
}
// Abandon leftover loaded/expired packages (the baseline is captured AFTER this cleanup).
const s1 = await getState(page);
for (const pkg of s1.packages) {
if (pkg.planeId === 'plane-starter' && (pkg.status === 'loaded' || pkg.status === 'expired')) {
const res = await page.request.post(`/api/packages/${pkg.id}/abandon`);
expect(res.ok()).toBeTruthy();
}
}
}
test('open packages render with names, reward and deadline; Load is enabled at a package origin', async ({ page }) => {
@@ -213,9 +246,12 @@ test('open packages render with names, reward and deadline; Load is enabled at a
// Deterministic target: a hook-created EDDF package — Load is enabled (starter at origin, free slot).
const nearest = await nearestToHub(page);
const pkg = await createPackage(page, HUB, nearest.icao);
const pkg = await createPackage(page, HUB, nearest.icao, undefined, 4_000);
const row = page.locator(`li[data-testid="open-package-${pkg.id}"]`);
await expect(row, 'the hook-created package appears in the list').toBeVisible({ timeout: 15_000 });
// ISS-066 (v0.35.0): cargo shows as type + tonnage ("… 4.0t"), not "type × count".
// 4 t is within the starter ATR's 4.8 t maxPayloadKg, so Load stays enabled below.
await expect(page.getByTestId(`open-package-cargo-${pkg.id}`)).toContainText('4.0t');
await expect(page.getByTestId(`open-package-reward-${pkg.id}`)).toContainText(eur(pkg.reward));
const loadBtn = page.getByTestId(`load-${pkg.id}`);
await expect(loadBtn).toBeEnabled();
@@ -231,6 +267,23 @@ test('open packages render with names, reward and deadline; Load is enabled at a
await expect(page.getByTestId(`onboard-package-${pkg.id}`)).toBeVisible({ timeout: 10_000 });
});
test('a priority package shows its special badge on the open-package row', async ({ page }) => {
test.setTimeout(300_000); // self-heal can take minutes after an interrupted run
await flyHomeViaApi(page); // deterministic: the starter sits at EDDF
const nearest = await nearestToHub(page);
const pkg = await createPackage(page, HUB, nearest.icao, undefined, 4_000, 'priority');
expect(pkg.special).toBe('priority'); // hook passthrough — no roll
await page.goto('/');
const row = page.locator(`li[data-testid="open-package-${pkg.id}"]`);
await expect(row, 'the hook-created package appears in the list').toBeVisible({ timeout: 15_000 });
// v0.37.0 (ISS-073): the row carries a "Priority" badge next to the route.
const badge = page.getByTestId(`package-special-${pkg.id}`);
await expect(badge).toBeVisible();
await expect(badge).toHaveText('Priority');
});
test('Load is disabled when no plane sits at the package origin', async ({ page }) => {
test.setTimeout(300_000); // self-heal can take minutes after an interrupted run
await flyHomeViaApi(page); // starter at EDDF — a Madrid-origin package cannot be loaded here
@@ -268,8 +321,8 @@ test('on-board list: fly-to-destination + abandon with penalty preview; flying p
await expect(flyBtn).toBeEnabled();
await flyBtn.click();
// The on-board block now shows the plane in flight with its current leg…
await expect(page.getByTestId('onboard-plane-starter')).toContainText(/in flight/i, { timeout: 10_000 });
// The fleet row now shows the plane in flight with its current leg (ISS-054)…
await expect(page.getByTestId('plane-plane-starter')).toContainText(/in flight/i, { timeout: 10_000 });
const leg = page.getByTestId('plane-leg-plane-starter');
await expect(leg).toBeVisible();
await expect(leg).toContainText('→');
@@ -321,11 +374,20 @@ test('market buy/sell render with previews from the catalog in /api/state; left-
// v0.28.0 (ISS-047): the market rows live in the Planes market tab — switch to it first.
await page.getByTestId('tab-planes-market').click();
// Buy: the model picker lists the catalog entry and the price preview is its full price.
// Buy: the model picker lists the catalog entry and the price preview is its EFFECTIVE price —
// rep-scaled (v0.38.0) from the live snapshot's reputation; at the starting rep this equals list price.
const buyModel = page.getByTestId('market-buy-model');
await expect(buyModel).toBeVisible({ timeout: 15_000 });
await expect(buyModel.locator(`option[value="${entry.model}"]`)).toHaveText(entry.label);
await expect(page.getByTestId('market-buy-price')).toContainText(eur(entry.price));
const planeRate = repRate(body.account.reputation, PLANE_DISCOUNT_PER_REP, MAX_PLANE_DISCOUNT);
const buyPrice = Math.round(entry.price * (1 - planeRate) * 100) / 100;
await expect(page.getByTestId('market-buy-price')).toContainText(eur(buyPrice));
// The discount pill is present iff the live rep earns one, and shows round(rate × 100).
if (body.account.reputation > STARTING_REPUTATION) {
await expect(page.getByTestId('market-buy-discount')).toHaveText(`−${Math.round(planeRate * 100)}%`);
} else {
await expect(page.getByTestId('market-buy-discount')).toHaveCount(0);
}
await expect(page.getByTestId('market-buy')).toBeVisible();
// Sell: the grounded-plane picker lists plane-starter and the payout preview is
@@ -341,7 +403,9 @@ test('market buy/sell render with previews from the catalog in /api/state; left-
// Refuel through the pane: the flight genuinely burned fuel…
if (starter.fuel < starter.maxFuel) {
const expectedCost = Math.round((starter.maxFuel - starter.fuel) * body.fuelPricePerL * 100) / 100;
// v0.38.0 (ISS-072): rep-scaled cost — same formula as the server's refuelPlane 'full'.
const fuelRate = repRate(body.account.reputation, FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT);
const expectedCost = Math.round((starter.maxFuel - starter.fuel) * body.fuelPricePerL * (1 - fuelRate) * 100) / 100;
const cashBefore = body.account.cash;
// …the refuel picker lists it, the preview matches the exact cost, and the click deducts it.
@@ -362,7 +426,7 @@ test('market buy/sell render with previews from the catalog in /api/state; left-
}
});
test('on-board row Refuel tops up just enough for the package leg; left-pane button says "Refuel to full"', async ({ page }) => {
test('on-board row Refuel buys 150% of the weather-adjusted leg requirement; left-pane button says "Refuel to full"', async ({ page }) => {
test.setTimeout(300_000); // self-heal + one long flight each way at scale 1000
await flyHomeViaApi(page);
@@ -372,7 +436,7 @@ test('on-board row Refuel tops up just enough for the package leg; left-pane but
// Deterministic baseline: a full tank.
expect((await page.request.post('/api/planes/plane-starter/refuel')).status()).toBe(200);
// Longest FLYABLE leg from the hub (fits a full tank) — long enough that "just enough" for it
// Longest FLYABLE leg from the hub (fits a full tank) — long enough that 150% of its requirement
// exceeds what's left on arrival.
const farthest = await farthestFlyableToHub(page, entry);
const pkg1 = await createPackage(page, HUB, farthest.icao, 24 * 3_600_000);
@@ -393,14 +457,25 @@ test('on-board row Refuel tops up just enough for the package leg; left-pane but
const pkg2 = await createPackage(page, farthest.icao, HUB, 24 * 3_600_000);
expect((await page.request.post(`/api/packages/${pkg2.id}/load`, { data: { planeId: 'plane-starter' } })).status()).toBe(200);
// Expected "just enough" from the snapshot constants (mirrors requiredFuelFor in lib/sim/movement.ts).
// Expected "150% of the requirement" from the snapshot constants (mirrors requiredFuelFor in
// lib/sim/movement.ts, wind-adjusted per v0.36.0 — departure weather at the far end vs heading home).
const pre = await getState(page);
const fuelBefore = pre.planes.find((p) => p.id === 'plane-starter')!.fuel;
const cashBefore = pre.account.cash;
const km = haversineKm(farthest.hubLat, farthest.hubLon, farthest.lat, farthest.lon); // symmetric: hub ↔ far end
const liters = Math.max(0, requiredFuelFor(entry.burnPerKm, entry.emptyWeightKg, entry.maxFuel, km) - fuelBefore);
const wFar = weatherFor(farthest.icao);
const required = requiredFuelFor(
entry.burnPerKm * windBurnFactor(wFar.windKt, wFar.windDirDeg, initialBearingDeg(farthest.lat, farthest.lon, farthest.hubLat, farthest.hubLon)),
entry.emptyWeightKg,
entry.maxFuel,
km,
);
const target = Math.min(entry.maxFuel, required * 1.5); // ISS-071: 150% margin, capped at tank capacity
const liters = Math.max(0, target - fuelBefore);
expect(liters).toBeGreaterThan(0); // the long leg genuinely needs more than what's left
const expectedCost = Math.round(liters * pre.fuelPricePerL * 100) / 100;
// v0.38.0 (ISS-072): rep-scaled cost — same formula as the server's refuelPlane 'forFlight'.
const fuelRate = repRate(pre.account.reputation, FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT);
const expectedCost = Math.round(liters * pre.fuelPricePerL * (1 - fuelRate) * 100) / 100;
await page.goto('/');
const refuelBtn = page.getByTestId(`refuel-for-${pkg2.id}`);
@@ -421,8 +496,8 @@ test('on-board row Refuel tops up just enough for the package leg; left-pane but
await expect(page.getByTestId('market-refuel')).toHaveText('Refuel to full');
// Restore: fly home — pkg2's destination IS the hub, so it delivers on arrival. The tank holds
// exactly the leg requirement, so this also proves "just enough" is enough. ISS-041: the far-end
// arrival above paused the sim, so re-set SCALE for this leg (setScale clears pause).
// 150% of the leg requirement (or a full tank), so the return is comfortably feasible. ISS-041:
// the far-end arrival above paused the sim, so re-set SCALE for this leg (setScale clears pause).
await setScale(page, SCALE);
expect((await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } })).status()).toBe(200);
await expect
+7 -2
View File
@@ -80,8 +80,13 @@ async function flyHomeViaApi(page: Page): Promise<void> {
// 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);
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)
let flyHome = await page.request.post(`/api/planes/${plane.id}/fly`, { data: { destIcao: HUB } });
if (!flyHome.ok()) {
// A long outbound leg can leave the tank too empty for the return — top up and retry.
expect((await page.request.post(`/api/planes/${plane.id}/refuel`)).status()).toBe(200);
flyHome = await page.request.post(`/api/planes/${plane.id}/fly`, { data: { destIcao: HUB } });
}
expect(flyHome.ok()).toBeTruthy(); // repositioning needs no package (v0.12.0 manual flight)
await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')!.locationIcao), { timeout: 240_000 }).toBe(HUB);
}
}
+30 -15
View File
@@ -1,5 +1,12 @@
import type { Page } from '@playwright/test';
import { expect, test } from '@playwright/test';
// Real helpers (v0.36.0) — the wind-adjusted fuel math must match lib/ exactly; Playwright resolves
// the repo's tsconfig paths, so importing beats mirroring a third copy of the formulas.
import { initialBearingDeg } from '../lib/geo';
import { weatherFor } from '../lib/weather';
import { windBurnFactor } from '../lib/sim/movement';
// v0.38.0 (ISS-072) — reputation discounts: expectations computed from the LIVE snapshot's rep with the real helpers.
import { FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT, repRate } from '../lib/game/catalog';
// v0.31.0 — ISS-055: gameplay with TWO planes in the fleet. The reported bug ("can't load cargo
// if both planes are on the same airport") was UX-only: LoadControls' multi-plane path rendered a
@@ -114,18 +121,25 @@ async function twoNearestToHub(page: Page): Promise<Array<{ icao: string; lat: n
}
// Farthest airport from EDDF whose leg still fits a FULL ATR tank — the longest flyable leg. The
// dataset's absolute farthest can exceed range (a full-tank fly there would rightly 409).
// dataset's absolute farthest can exceed range (a full-tank fly there would rightly 409). Wind-
// adjusted (v0.36.0): BOTH directions must fit — the outbound is flown from a full tank and the
// return leg is flown after a top-up, which also 409s if it doesn't fit.
async function farthestFlyableToHub(page: Page): Promise<{ icao: string; lat: number; lon: number }> {
const entry = (await getState(page)).catalog.find((e) => e.model === BUY_MODEL)!;
const fc = (await (await page.request.get('/api/airports')).json()) as {
features: Array<{ properties: { icao: string; lat: number; lon: number } }>;
};
const hub = fc.features.find((f) => f.properties.icao === HUB)!.properties;
const wHub = weatherFor(HUB);
let farthest: { icao: string; lat: number; lon: number; d: number } | null = null;
for (const f of fc.features) {
if (f.properties.icao === HUB) continue;
const d = haversineKm(hub.lat, hub.lon, f.properties.lat, f.properties.lon);
if (requiredFuelFor(entry.burnPerKm, entry.emptyWeightKg, entry.maxFuel, d) > entry.maxFuel) continue; // beyond range
const wDest = weatherFor(f.properties.icao);
const outBurn = entry.burnPerKm * windBurnFactor(wHub.windKt, wHub.windDirDeg, initialBearingDeg(hub.lat, hub.lon, f.properties.lat, f.properties.lon));
if (requiredFuelFor(outBurn, entry.emptyWeightKg, entry.maxFuel, d) > entry.maxFuel) continue; // beyond range
const backBurn = entry.burnPerKm * windBurnFactor(wDest.windKt, wDest.windDirDeg, initialBearingDeg(f.properties.lat, f.properties.lon, hub.lat, hub.lon));
if (requiredFuelFor(backBurn, entry.emptyWeightKg, entry.maxFuel, d) > entry.maxFuel) continue; // return leg wouldn't fit a full tank either
if (!farthest || d > farthest.d) {
farthest = { icao: f.properties.icao, lat: f.properties.lat, lon: f.properties.lon, d };
}
@@ -261,8 +275,11 @@ async function sellNonStarters(page: Page): Promise<void> {
// Canonical start state for every test: fleet of exactly one plane (the starter), grounded at EDDF,
// no cargo on board — even after an interrupted previous run.
async function resetFleet(page: Page): Promise<void> {
// v0.39.0 (ISS-069): wear accumulates across the shared CI DB — restore full health and clear any
// active repair gate before healing, so the farthest-flyable picker never sees a worn/broken starter.
expect((await page.request.post('/api/test/set-health', { data: { planeId: STARTER, health: 100, repairUntilMs: 0 } })).status()).toBe(200);
await sellNonStarters(page);
await healPlaneHome(page, STARTER);
await healPlaneHome(page, STARTER); // the tick inside its getState flips a shortened gate back to available
await abandonLeftoverCargo(page, STARTER);
}
@@ -323,9 +340,9 @@ test('two grounded planes: Load is one-click with a preselected plane; the picke
}, { timeout: 15_000, message: 'waiting for the second package to load onto a plane' })
.toBe(secondId);
// Both planes carry cargo simultaneously — both on-board blocks render.
await expect(page.getByTestId(`onboard-${STARTER}`), 'the starter block renders').toBeVisible({ timeout: 15_000 });
await expect(page.getByTestId(`onboard-${secondId}`), 'the second plane block renders').toBeVisible({ timeout: 15_000 });
// Both planes carry cargo simultaneously — both fleet rows render their cargo (ISS-054).
await expect(page.getByTestId(`plane-${STARTER}`), 'the starter row renders').toBeVisible({ timeout: 15_000 });
await expect(page.getByTestId(`plane-${secondId}`), 'the second plane row renders').toBeVisible({ timeout: 15_000 });
});
test('two planes fly in parallel; the last landing slows to 1× and the next dispatch resumes', async ({ page }) => {
@@ -440,8 +457,10 @@ test('refuel section lists both planes; refueling one tops only its tank', async
await expect(refuelSelect.locator(`option[value="${STARTER}"]`)).toHaveCount(1);
await expect(refuelSelect.locator(`option[value="${secondId}"]`)).toHaveCount(1);
// …select the SECOND plane — the preview is its exact fill-up cost (mirrors refuelPlane 'full').
const expectedCost = Math.round((second.maxFuel - second.fuel) * pre.fuelPricePerL * 100) / 100;
// …select the SECOND plane — the preview is its exact fill-up cost (mirrors refuelPlane 'full'),
// rep-scaled (v0.38.0, ISS-072) from the live snapshot's reputation.
const fuelRate = repRate(pre.account.reputation, FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT);
const expectedCost = Math.round((second.maxFuel - second.fuel) * pre.fuelPricePerL * (1 - fuelRate) * 100) / 100;
await refuelSelect.selectOption(secondId);
await expect(page.getByTestId('market-refuel-cost')).toContainText(eur(expectedCost));
@@ -519,13 +538,9 @@ test('per-plane queues are independent; landing auto-chains the next queued leg'
await expect(starterLeg0, 'the starter block renders its queued leg').toContainText(a.icao, { timeout: 15_000 });
await expect(page.getByTestId(`queue-leg-${secondId}-0`), 'the second block renders its queued leg').toContainText(b.icao);
// …and ADD a second leg to the STARTER via its scoped controls only.
const starterDest = page.getByTestId(`queue-add-dest-${STARTER}`);
await expect(starterDest.locator(`option[value="${b.icao}"]`)).toHaveCount(1, { timeout: 15_000 }); // airports fetched
await starterDest.selectOption(b.icao);
const starterAdd = page.getByTestId(`queue-add-${STARTER}`);
await expect(starterAdd).toBeEnabled();
await starterAdd.click();
// …and ADD a second leg to the STARTER via the API — the dedicated queue-add UI was removed in
// ISS-053 (fly-to / Relocate cover it), and the starter is grounded here so fly-to would start, not queue.
expect((await page.request.post(`/api/planes/${STARTER}/queue`, { data: { destIcao: b.icao } })).status()).toBe(200);
await expect(page.getByTestId(`queue-leg-${STARTER}-1`), 'the second leg is queued on the STARTER').toContainText(b.icao, { timeout: 15_000 });
// CANCEL the only leg on the SECOND plane — its queue empties, the starter's stays intact.
+16 -1
View File
@@ -31,7 +31,9 @@ CREATE TABLE IF NOT EXISTS planes (
lat REAL NOT NULL,
lon REAL NOT NULL,
departure_sim_time INTEGER,
queue TEXT NOT NULL DEFAULT '[]'
queue TEXT NOT NULL DEFAULT '[]',
health REAL NOT NULL DEFAULT 100,
repair_until_sim_time INTEGER
);
CREATE TABLE IF NOT EXISTS packages (
id TEXT PRIMARY KEY NOT NULL,
@@ -41,6 +43,7 @@ CREATE TABLE IF NOT EXISTS packages (
cargo_type TEXT NOT NULL,
cargo_count INTEGER NOT NULL,
weight_kg REAL NOT NULL DEFAULT 2000,
special TEXT,
deadline_sim_time INTEGER NOT NULL,
status TEXT NOT NULL,
plane_id TEXT,
@@ -83,6 +86,18 @@ function migrateColumns(raw: RawSqlJsDatabase): void {
if (!cols('packages').has('weight_kg')) {
raw.exec('ALTER TABLE packages ADD COLUMN weight_kg REAL NOT NULL DEFAULT 2000');
}
// v0.37.0 (ISS-073): package special status — nullable, no default; pre-existing rows stay NULL = none.
if (!cols('packages').has('special')) {
raw.exec('ALTER TABLE packages ADD COLUMN special TEXT');
}
// v0.39.0 (ISS-069): plane health — pre-existing rows backfill to full health.
if (!cols('planes').has('health')) {
raw.exec('ALTER TABLE planes ADD COLUMN health REAL NOT NULL DEFAULT 100');
}
// v0.39.0 (ISS-069): repair gate — nullable, no default; pre-existing rows stay NULL = not in repair.
if (!cols('planes').has('repair_until_sim_time')) {
raw.exec('ALTER TABLE planes ADD COLUMN repair_until_sim_time INTEGER');
}
// v0.30.0 (ISS-057): last-selected speed — pre-existing rows backfill to the row's OWN
// current time_scale so a legacy DB resumes at the speed it was running, not the default.
if (!cols('sim_state').has('last_selected_time_scale')) {
+11 -1
View File
@@ -7,7 +7,9 @@ import { integer, real, sqliteTable, text } from 'drizzle-orm/sqlite-core';
export const planes = sqliteTable('planes', {
id: text('id').primaryKey().notNull(),
model: text('model').notNull(),
status: text('status', { enum: ['available', 'flying'] }).notNull(),
// v0.39.0 (ISS-069): 'repairing' = grounded in the shop until repairUntilSimTime passes; the
// engine flips it back to 'available' on a tick. All status guards key off 'available'.
status: text('status', { enum: ['available', 'flying', 'repairing'] }).notNull(),
fuel: real('fuel').notNull(),
maxFuel: real('max_fuel').notNull(),
locationIcao: text('location_icao').notNull(),
@@ -24,6 +26,12 @@ export const planes = sqliteTable('planes', {
// of ICAO strings ('[]' when empty). The ACTIVE leg stays destIcao +
// departureSimTime; on landing the engine auto-chains queue[0] if fuel covers it.
queue: text('queue').notNull().default('[]'),
// Structural health in [0, 100] (v0.39.0, ISS-069) — wears down by WEAR_PER_KM per km of
// landed legs; at 0 the plane is broken and burns BROKEN_BURN_MULTIPLIER × more fuel.
health: real('health').notNull().default(100),
// Sim-clock epoch ms when an active repair finishes (v0.39.0, ISS-069) — set with status
// 'repairing', cleared by the engine's flip-back pass; NULL = not in repair.
repairUntilSimTime: integer('repair_until_sim_time'),
});
// Cargo packages (v0.12.0) replace the old contracts table end-to-end: a package
@@ -40,6 +48,8 @@ export const packages = sqliteTable('packages', {
// Package weight in kg (v0.27.0, ISS-045) — drives load validation (plane payload)
// and the per-kg part of the payout. Pre-existing rows backfill to the default.
weightKg: real('weight_kg').notNull().default(2000),
// Special status (v0.37.0, ISS-073) — key into lib/game/specials.ts SPECIALS; NULL = none.
special: text('special'),
deadlineSimTime: integer('deadline_sim_time').notNull(),
// 'expired' = an open OR loaded package whose deadline passed on a tick. Loaded
// expired packages keep occupying their slot, deliver no reward, and abandon free.
+3
View File
@@ -22,6 +22,8 @@ const basePlane = (id: string): PlaneState => ({
departureSimTime: null,
lat: 50.03,
lon: 8.56,
health: 100, // v0.39.0 (ISS-069) — full-health fixture default
repairUntilSimTime: null,
queue: [],
});
@@ -35,6 +37,7 @@ const basePackage = (id: string): PackageState => ({
cargoType: 'mail',
cargoCount: 2,
weightKg: 600,
special: null, // v0.37.0 (ISS-073) — no special status by default in fixtures
deadlineSimTime: 0,
status: 'open',
planeId: null,
+10 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { formatCash, formatDeadlineRelative, formatDuration, formatFuel, formatSimClock } from './format';
import { formatCargo, formatCash, formatDeadlineRelative, formatDuration, formatFuel, formatSimClock } from './format';
describe('formatCash', () => {
it('formats EUR with thousands separators and two decimals', () => {
@@ -21,6 +21,15 @@ describe('formatFuel', () => {
});
});
describe('formatCargo', () => {
it('shows cargo type + tonnage with one decimal, like the loadout display', () => {
expect(formatCargo('Mail', 10_000)).toBe('Mail 10.0t');
// (250 / 1000).toFixed(1) === '0.3' — half rounds up at this value.
expect(formatCargo('Electronics', 250)).toBe('Electronics 0.3t');
expect(formatCargo('Mail', 40)).toBe('Mail 0.0t');
});
});
describe('formatSimClock', () => {
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');
+10
View File
@@ -11,6 +11,16 @@ export function formatFuel(fuel: number, maxFuel: number): string {
return `${Math.round(fuel)} / ${maxFuel} L`;
}
// Job-list cargo label: "Mail 10.0t" — type + tonnage, one decimal like the loadout display.
export function formatCargo(cargoType: string, weightKg: number): string {
return `${cargoType} ${(weightKg / 1000).toFixed(1)}t`;
}
// Special-status badge label (v0.37.0, ISS-073): "priority" → "Priority".
export function formatSpecialLabel(special: string): string {
return special.charAt(0).toUpperCase() + special.slice(1);
}
// The sim clock is epoch ms (seeded from real time) — shown as a UTC wall-clock with
// seconds so the display is deterministic regardless of the viewer's timezone.
export function formatSimClock(clockMs: number): string {
+242 -19
View File
@@ -6,9 +6,22 @@ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { openGameDb, type GameDb } from '@/lib/db/client';
import * as schema from '@/lib/db/schema';
import { loadAirportsGeoJson } from '@/lib/airports';
import { haversineKm } from '@/lib/geo';
import { requiredFuelFor } from '@/lib/sim/movement';
import { FUEL_PRICE_PER_L, PLANE_TYPES, SELL_FRACTION } from './catalog';
import { haversineKm, initialBearingDeg } from '@/lib/geo';
import { weatherFor } from '@/lib/weather';
import { requiredFuelFor, windBurnFactor } from '@/lib/sim/movement';
import {
BROKEN_BURN_MULTIPLIER,
FUEL_DISCOUNT_PER_REP,
FUEL_PRICE_PER_L,
MAX_FUEL_DISCOUNT,
MAX_PLANE_DISCOUNT,
PLANE_DISCOUNT_PER_REP,
PLANE_TYPES,
REPAIR_COST,
REPAIR_DURATION_MS,
repRate,
SELL_FRACTION,
} from './catalog';
import { HUB_ICAO } from './seed';
import {
abandonPackage,
@@ -20,6 +33,7 @@ import {
loadPackage,
queueLeg,
refuelPlane,
repairPlane,
sellPlane,
} from './actions';
@@ -223,6 +237,30 @@ describe('flyPlane', () => {
await expectActionError(flyPlane(inst, 'plane-starter', 'LEMD'), 409, /insufficient fuel/i);
});
it('uses the ×1.5 broken burn for fuel feasibility when health is 0 (v0.39.0, ISS-069)', async () => {
const inst = await freshDb('fly-broken.db'); // EDDF → LEMD
const fc = await loadAirportsGeoJson();
const from = fc.features.find((f) => f.properties.icao === HUB_ICAO)!.properties;
const to = fc.features.find((f) => f.properties.icao === 'LEMD')!.properties;
const km = haversineKm(from.lat, from.lon, to.lat, to.lon);
const type = PLANE_TYPES.atr72;
const w = weatherFor(HUB_ICAO);
const f = windBurnFactor(w.windKt, w.windDirDeg, initialBearingDeg(from.lat, from.lon, to.lat, to.lon));
// The tank holds EXACTLY the healthy requirement — a broken plane needs more than that.
await inst.db.update(schema.planes)
.set({ health: 0, fuel: requiredFuelFor(type.burnPerKm * f, type.emptyWeightKg, type.maxFuel, km) })
.where(eq(schema.planes.id, 'plane-starter'));
await expectActionError(flyPlane(inst, 'plane-starter', 'LEMD'), 409, /insufficient fuel/i);
// Same tank at full health is flyable — the broken multiplier is what blocked it.
const inst2 = await freshDb('fly-broken-healthy.db');
await inst2.db.update(schema.planes)
.set({ fuel: requiredFuelFor(type.burnPerKm * f, type.emptyWeightKg, type.maxFuel, km) })
.where(eq(schema.planes.id, 'plane-starter'));
await flyPlane(inst2, 'plane-starter', 'LEMD'); // must not throw
});
});
describe('queueLeg (v0.25.0, ISS-043)', () => {
@@ -261,6 +299,37 @@ describe('queueLeg (v0.25.0, ISS-043)', () => {
const inst = await freshDb('qleg-unknownicao.db');
await expectActionError(queueLeg(inst, 'plane-starter', 'ZZZZ'), 400, /unknown airport/);
});
it('rejects a destination already in the queue with 409 (ISS-065)', async () => {
const inst = await freshDb('qleg-dup.db');
await queueLeg(inst, 'plane-starter', 'LEMD');
await expectActionError(queueLeg(inst, 'plane-starter', 'LEMD'), 409, /already in the flight queue/);
const plane = (await inst.db.select().from(schema.planes))[0];
expect(JSON.parse(plane.queue)).toEqual(['LEMD']); // rejected leg not appended
});
it('rejects re-queuing the active leg destination of a flying plane with 409 (ISS-065)', async () => {
const inst = await freshDb('qleg-dupflying.db');
await inst.db.update(schema.planes)
.set({ status: 'flying', departureSimTime: T0, destIcao: 'LEMD' })
.where(eq(schema.planes.id, 'plane-starter'));
await expectActionError(queueLeg(inst, 'plane-starter', 'LEMD'), 409, /already flying to LEMD/);
const plane = (await inst.db.select().from(schema.planes))[0];
expect(JSON.parse(plane.queue)).toEqual([]); // rejected leg not appended
});
it('still appends a different known airport with success (ISS-065 sanity)', async () => {
const inst = await freshDb('qleg-dupother.db');
await queueLeg(inst, 'plane-starter', 'LEMD');
await expect(queueLeg(inst, 'plane-starter', 'EHAM')).resolves.toBeUndefined();
const plane = (await inst.db.select().from(schema.planes))[0];
expect(JSON.parse(plane.queue)).toEqual(['LEMD', 'EHAM']);
});
});
describe('cancelQueuedLeg (v0.25.0, ISS-043)', () => {
@@ -455,6 +524,38 @@ describe('buyPlane', () => {
const inst = await freshDb('buy-poor.db'); // seeded cash is 20k < 200k
await expectActionError(buyPlane(inst, 'atr72'), 409, /insufficient funds/i);
});
it('deducts the reputation-scaled cost at elevated rep (v0.38.0)', async () => {
const inst = await freshDb('buy-rep.db');
// rep 30 → rate exactly 0.2 → ATR costs 160k, not 200k.
await inst.db.update(schema.account).set({ cash: 500_000, reputation: 30 });
const before = (await inst.db.select().from(schema.planes)).length;
await buyPlane(inst, 'atr72');
const rate = repRate(30, PLANE_DISCOUNT_PER_REP, MAX_PLANE_DISCOUNT);
expect(rate).toBeCloseTo(0.2, 12); // sanity pin on the formula input
const expectedCost = Math.round(PLANE_TYPES.atr72.price * (1 - rate) * 100) / 100;
expect(expectedCost).toBe(160_000); // 200k × 0.8 — exact value pin
const account = (await inst.db.select().from(schema.account))[0];
expect(account.cash).toBeCloseTo(500_000 - expectedCost, 6);
expect((await inst.db.select().from(schema.planes)).length).toBe(before + 1);
});
it('buys with cash between the discounted and full price; rejects below the discounted cost (v0.38.0)', async () => {
const inst = await freshDb('buy-boundary.db');
// rep 30 → discounted cost 160k: 170k is enough now, though it was not before v0.38.0.
await inst.db.update(schema.account).set({ cash: 170_000, reputation: 30 });
const { planeId } = await buyPlane(inst, 'atr72');
expect(planeId).toBeTruthy(); // the old full-price check would have rejected this
const account = (await inst.db.select().from(schema.account))[0];
expect(account.cash).toBeCloseTo(170_000 - 160_000, 6);
const inst2 = await freshDb('buy-boundary-poor.db'); // just below the discounted cost
await inst2.db.update(schema.account).set({ cash: 159_999.99, reputation: 30 });
await expectActionError(buyPlane(inst2, 'atr72'), 409, /insufficient funds/i);
});
});
describe('sellPlane', () => {
@@ -553,6 +654,39 @@ describe('refuelPlane', () => {
await expectActionError(refuelPlane(inst, 'plane-starter'), 409, /insufficient funds/i);
});
it('applies the reputation-scaled discount to the price term only, in both modes (v0.38.0)', async () => {
const inst = await freshDb('refuel-rep.db');
// Elevated rep → non-zero rate; expectations computed with the SAME helpers refuelPlane uses.
await inst.db.update(schema.account).set({ reputation: 30 });
const rate = repRate(30, FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT);
expect(rate).toBeGreaterThan(0);
// 'full': exact discounted cost, tank math untouched.
await inst.db.update(schema.planes).set({ fuel: 1_000 }).where(eq(schema.planes.id, 'plane-starter'));
const cashBefore = (await inst.db.select().from(schema.account))[0].cash;
const { cost } = await refuelPlane(inst, 'plane-starter');
expect(cost).toBe(Math.round((PLANE_TYPES.atr72.maxFuel - 1_000) * FUEL_PRICE_PER_L * (1 - rate) * 100) / 100);
const plane = (await inst.db.select().from(schema.planes))[0];
expect(plane.fuel).toBe(PLANE_TYPES.atr72.maxFuel); // target unchanged by the discount
expect((await inst.db.select().from(schema.account))[0].cash).toBeCloseTo(cashBefore - cost, 6);
// 'forFlight': same discount on the price term; burn/target math (wind factor, ×1.5 margin) untouched.
await insertOpenPackage(inst, 'p-ff-r', { destIcao: 'LEMD' }); // EDDF → LEMD
await loadPackage(inst, 'p-ff-r', 'plane-starter');
const required = await adjustedRequiredForHubToLemd();
const startFuel = Math.round(required / 2);
await inst.db.update(schema.planes).set({ fuel: startFuel }).where(eq(schema.planes.id, 'plane-starter'));
const cashBefore2 = (await inst.db.select().from(schema.account))[0].cash;
const { cost: ffCost } = await refuelPlane(inst, 'plane-starter', { mode: 'forFlight', packageId: 'p-ff-r' });
const targetFuel = Math.min(PLANE_TYPES.atr72.maxFuel, Math.max(startFuel, required * 1.5));
expect(ffCost).toBe(Math.round((targetFuel - startFuel) * FUEL_PRICE_PER_L * (1 - rate) * 100) / 100);
const plane2 = (await inst.db.select().from(schema.planes))[0];
expect(plane2.fuel).toBeCloseTo(targetFuel, 6); // topped up to exactly the 150% target
expect((await inst.db.select().from(schema.account))[0].cash).toBeCloseTo(cashBefore2 - ffCost, 6);
});
it('rejects an unknown id with 404', async () => {
const inst = await freshDb('refuel-unknown.db');
await expectActionError(refuelPlane(inst, 'ghost'), 404);
@@ -560,17 +694,29 @@ describe('refuelPlane', () => {
// --- forFlight mode (v0.16.0) ---
it('forFlight adds exactly the liters needed for the package leg and deducts L × price', async () => {
const inst = await freshDb('refuel-forflight-ok.db');
await insertOpenPackage(inst, 'p-ff', { destIcao: 'LEMD' }); // EDDF → LEMD
await loadPackage(inst, 'p-ff', 'plane-starter');
// Wind-adjusted requirement for the hub → LEMD leg (v0.36.0) — computed with the SAME helpers
// refuelPlane uses, so the expectations stay exact without hardcoded literals.
async function adjustedRequiredForHubToLemd() {
const fc = await loadAirportsGeoJson();
const from = fc.features.find((f) => f.properties.icao === HUB_ICAO)!.properties;
const to = fc.features.find((f) => f.properties.icao === 'LEMD')!.properties;
const km = haversineKm(from.lat, from.lon, to.lat, to.lon);
const type = PLANE_TYPES.atr72;
const required = requiredFuelFor(type.burnPerKm, type.emptyWeightKg, type.maxFuel, km);
const w = weatherFor(HUB_ICAO);
return requiredFuelFor(
type.burnPerKm * windBurnFactor(w.windKt, w.windDirDeg, initialBearingDeg(from.lat, from.lon, to.lat, to.lon)),
type.emptyWeightKg,
type.maxFuel,
km,
);
}
it('forFlight buys 150% of the weather-adjusted leg requirement and deducts L × price', async () => {
const inst = await freshDb('refuel-forflight-ok.db');
await insertOpenPackage(inst, 'p-ff', { destIcao: 'LEMD' }); // EDDF → LEMD
await loadPackage(inst, 'p-ff', 'plane-starter');
const required = await adjustedRequiredForHubToLemd();
// Partial tank below the requirement so L > 0.
const startFuel = Math.round(required / 2);
@@ -579,31 +725,28 @@ describe('refuelPlane', () => {
const { cost } = await refuelPlane(inst, 'plane-starter', { mode: 'forFlight', packageId: 'p-ff' });
expect(cost).toBe(Math.round((required - startFuel) * FUEL_PRICE_PER_L * 100) / 100);
// ISS-071: target is 150% of the requirement (below tank capacity for this leg).
const targetFuel = Math.min(PLANE_TYPES.atr72.maxFuel, Math.max(startFuel, required * 1.5));
expect(cost).toBe(Math.round((targetFuel - startFuel) * FUEL_PRICE_PER_L * 100) / 100);
const plane = (await inst.db.select().from(schema.planes))[0];
expect(plane.fuel).toBeCloseTo(required, 6); // topped up to exactly the leg requirement
expect(plane.fuel).toBeCloseTo(targetFuel, 6); // topped up to exactly the 150% target
const account = (await inst.db.select().from(schema.account))[0];
expect(account.cash).toBeCloseTo(cashBefore - cost, 6);
});
it('forFlight leaves an exactly-feasible tank — flyPlane to the package dest succeeds right after', async () => {
it('forFlight leaves a tank with a 50% margin — flyPlane to the package dest succeeds right after', async () => {
const inst = await freshDb('refuel-ff-flyafter.db');
await insertOpenPackage(inst, 'p-fa', { destIcao: 'LEMD' }); // EDDF → LEMD
await loadPackage(inst, 'p-fa', 'plane-starter');
const fc = await loadAirportsGeoJson();
const from = fc.features.find((f) => f.properties.icao === HUB_ICAO)!.properties;
const to = fc.features.find((f) => f.properties.icao === 'LEMD')!.properties;
const km = haversineKm(from.lat, from.lon, to.lat, to.lon);
const type = PLANE_TYPES.atr72;
const required = requiredFuelFor(type.burnPerKm, type.emptyWeightKg, type.maxFuel, km);
const required = await adjustedRequiredForHubToLemd();
// Empty-ish tank: well below the leg requirement.
await inst.db.update(schema.planes).set({ fuel: Math.round(required / 4) }).where(eq(schema.planes.id, 'plane-starter'));
await refuelPlane(inst, 'plane-starter', { mode: 'forFlight', packageId: 'p-fa' });
// The leg must now be flyable — no epsilon-short 409 (regression guard for the float-exact target).
// The leg must now be flyable — the 150% margin covers flyPlane's strict `>` check.
await flyPlane(inst, 'plane-starter', 'LEMD');
const plane = (await inst.db.select().from(schema.planes))[0];
expect(plane.status).toBe('flying');
@@ -664,3 +807,83 @@ describe('refuelPlane', () => {
);
});
});
describe('repairPlane (v0.39.0, ISS-069)', () => {
it('charges exactly REPAIR_COST, restores health to 100 and grounds the plane for REPAIR_DURATION_MS', async () => {
const inst = await freshDb('repair-ok.db');
await inst.db.update(schema.planes).set({ health: 42 }).where(eq(schema.planes.id, 'plane-starter'));
const cashBefore = (await inst.db.select().from(schema.account))[0].cash;
const { cost } = await repairPlane(inst, 'plane-starter');
expect(cost).toBe(REPAIR_COST);
const plane = (await inst.db.select().from(schema.planes))[0];
expect(plane.status).toBe('repairing');
expect(plane.health).toBe(100); // restored to full…
expect(plane.repairUntilSimTime).toBe(T0 + REPAIR_DURATION_MS); // …with the gate set from the seeded clock (no tick in direct calls)
const account = (await inst.db.select().from(schema.account))[0];
expect(account.cash).toBeCloseTo(cashBefore - REPAIR_COST, 6);
});
it('is a free no-op when health is already full', async () => {
const inst = await freshDb('repair-full.db'); // seeded starter at 100
const cashBefore = (await inst.db.select().from(schema.account))[0].cash;
const { cost } = await repairPlane(inst, 'plane-starter');
expect(cost).toBe(0);
const plane = (await inst.db.select().from(schema.planes))[0];
expect(plane.status).toBe('available'); // not grounded…
expect(plane.health).toBe(100);
expect(plane.repairUntilSimTime ?? null).toBeNull(); // …and no gate set
expect((await inst.db.select().from(schema.account))[0].cash).toBe(cashBefore);
});
it('rejects a flying plane with 409', async () => {
const inst = await freshDb('repair-flying.db');
await inst.db.update(schema.planes)
.set({ status: 'flying', departureSimTime: T0, destIcao: 'LEMD' })
.where(eq(schema.planes.id, 'plane-starter'));
await expectActionError(repairPlane(inst, 'plane-starter'), 409, /only grounded planes can be repaired/);
});
it('rejects a plane that is already repairing with 409', async () => {
const inst = await freshDb('repair-busy.db');
await inst.db.update(schema.planes)
.set({ status: 'repairing', repairUntilSimTime: T0 + REPAIR_DURATION_MS })
.where(eq(schema.planes.id, 'plane-starter'));
await expectActionError(repairPlane(inst, 'plane-starter'), 409, /only grounded planes can be repaired/);
});
it('blocks load/fly/refuel/sell while repairing (the existing status guards)', async () => {
const inst = await freshDb('repair-blocks.db');
await inst.db.update(schema.planes).set({ health: 50 }).where(eq(schema.planes.id, 'plane-starter'));
await repairPlane(inst, 'plane-starter');
await insertOpenPackage(inst); // open package at the hub — loadable if the plane were grounded
await expectActionError(loadPackage(inst, 'p-open', 'plane-starter'), 409, /repairing/);
await expectActionError(flyPlane(inst, 'plane-starter', 'LEMD'), 409, /repairing/);
await expectActionError(refuelPlane(inst, 'plane-starter'), 409, /repairing/);
// A second plane so the "last remaining" guard cannot fire before the status one.
await inst.db.insert(schema.planes).values({
id: 'plane-extra', model: 'atr72', status: 'available', fuel: 100, maxFuel: PLANE_TYPES.atr72.maxFuel,
locationIcao: HUB_ICAO, destIcao: null, lat: 50, lon: 8, departureSimTime: null,
});
await expectActionError(sellPlane(inst, 'plane-starter'), 409, /repairing/);
});
it('rejects the repair when cash is below REPAIR_COST with 409', async () => {
const inst = await freshDb('repair-poor.db');
await inst.db.update(schema.planes).set({ health: 50 }).where(eq(schema.planes.id, 'plane-starter'));
await inst.db.update(schema.account).set({ cash: REPAIR_COST - 1 });
await expectActionError(repairPlane(inst, 'plane-starter'), 409, /insufficient funds/i);
});
it('rejects an unknown id with 404', async () => {
const inst = await freshDb('repair-unknown.db');
await expectActionError(repairPlane(inst, 'ghost'), 404);
});
});
+91 -20
View File
@@ -3,11 +3,24 @@ import { and, eq, inArray } from 'drizzle-orm';
import * as schema from '@/lib/db/schema';
import { persist, type GameDb } from '@/lib/db/client';
import { loadAirportsGeoJson } from '@/lib/airports';
import { haversineKm } from '@/lib/geo';
import { requiredFuelFor } from '@/lib/sim/movement';
import { haversineKm, initialBearingDeg } from '@/lib/geo';
import { weatherFor } from '@/lib/weather';
import { requiredFuelFor, windBurnFactor } from '@/lib/sim/movement';
import { runAction } from '@/lib/sim/engine';
import { getStateSnapshot, parseIcaoQueue } from './state';
import { FUEL_PRICE_PER_L, PLANE_TYPES, SELL_FRACTION } from './catalog';
import {
conditionBurnMultiplier,
FUEL_DISCOUNT_PER_REP,
FUEL_PRICE_PER_L,
MAX_FUEL_DISCOUNT,
MAX_PLANE_DISCOUNT,
PLANE_DISCOUNT_PER_REP,
PLANE_TYPES,
REPAIR_COST,
REPAIR_DURATION_MS,
repRate,
SELL_FRACTION,
} from './catalog';
import { abandonPenalty } from './penalty';
import { HUB_ICAO } from './seed';
@@ -106,8 +119,12 @@ export async function flyPlane(inst: GameDb, planeId: string, destIcao: string):
const type = PLANE_TYPES[plane.model];
if (!type) throw new Error(`unknown plane model ${plane.model}`); // → 500, cannot happen for seeded data
const distanceKm = haversineKm(origin.lat, origin.lon, destFeature.properties.lat, destFeature.properties.lon);
// Weighted burn model (v0.16.0): the leg needs enough fuel to cover it arriving empty.
const requiredL = requiredFuelFor(type.burnPerKm, type.emptyWeightKg, type.maxFuel, distanceKm);
// 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.
// v0.39.0 (ISS-069): a broken plane (health ≤ 0) burns BROKEN_BURN_MULTIPLIER × more per km.
const w = weatherFor(plane.locationIcao);
const burnPerKm = type.burnPerKm * windBurnFactor(w.windKt, w.windDirDeg, initialBearingDeg(origin.lat, origin.lon, destFeature.properties.lat, destFeature.properties.lon)) * conditionBurnMultiplier(plane.health);
const requiredL = requiredFuelFor(burnPerKm, type.emptyWeightKg, type.maxFuel, distanceKm);
if (requiredL > plane.fuel) {
throw new GameActionError(409, `insufficient fuel: the leg needs ${Math.round(requiredL)} L but the plane has ${Math.round(plane.fuel)} L`);
}
@@ -153,7 +170,16 @@ export async function queueLeg(inst: GameDb, planeId: string, destIcao: string):
throw new GameActionError(400, `unknown airport ${destIcao}`);
}
// ISS-065: the same route can't be queued twice — re-queuing the active leg's destination or a
// leg already in the queue would only produce pointless repeated hops on landing.
if (plane.status === 'flying' && plane.destIcao === destIcao) {
throw new GameActionError(409, `already flying to ${destIcao}`);
}
const queue = parseIcaoQueue(plane.queue);
if (queue.includes(destIcao)) {
throw new GameActionError(409, `${destIcao} is already in the flight queue`);
}
queue.push(destIcao);
await inst.db.update(schema.planes).set({ queue: JSON.stringify(queue) }).where(eq(schema.planes.id, planeId));
await persist(inst);
@@ -231,16 +257,19 @@ export async function ignorePackage(inst: GameDb, packageId: string): Promise<vo
// --- Market (POST /api/market/buy-plane {model}) ---
// Buy a catalog model at full price; it arrives 'available' with a full tank at
// the home base (EDDF).
// Buy a catalog model at its reputation-scaled price (v0.38.0, ISS-072 — discount grows with
// reputation, exactly full price at the starting value); it arrives 'available' with a full tank
// at the home base (EDDF).
export async function buyPlane(inst: GameDb, model: string): Promise<{ planeId: string }> {
const type = PLANE_TYPES[model];
if (!type) throw new GameActionError(400, `unknown plane model "${model}"`);
const account = (await inst.db.select().from(schema.account))[0];
if (!account) throw new Error('account row missing');
if (account.cash < type.price) {
throw new GameActionError(409, `insufficient funds: a ${type.name} costs ${type.price}, you have ${Math.round(account.cash)}`);
// v0.38.0 (ISS-072): the EFFECTIVE cost drives both the funds check and the deduction.
const cost = Math.round(type.price * (1 - repRate(account.reputation, PLANE_DISCOUNT_PER_REP, MAX_PLANE_DISCOUNT)) * 100) / 100;
if (account.cash < cost) {
throw new GameActionError(409, `insufficient funds: a ${type.name} costs ${cost}, you have ${Math.round(account.cash)}`);
}
const fc = await loadAirportsGeoJson();
@@ -259,8 +288,9 @@ export async function buyPlane(inst: GameDb, model: string): Promise<{ planeId:
lat: home.lat,
lon: home.lon,
departureSimTime: null,
health: 100, // new planes arrive at full health (v0.39.0, ISS-069)
});
await inst.db.update(schema.account).set({ cash: account.cash - type.price });
await inst.db.update(schema.account).set({ cash: Math.round((account.cash - cost) * 100) / 100 });
await persist(inst);
return { planeId };
}
@@ -303,9 +333,10 @@ export async function sellPlane(inst: GameDb, planeId: string): Promise<{ payout
// --- Refuel (POST /api/planes/:planeId/refuel) ---
// Refuel modes (v0.16.0): 'full' fills the tank to maxFuel; 'forFlight' adds just
// enough fuel for one loaded package's destination leg — required liters per the
// weighted burn model, independent of how much is already in the tank.
// Refuel modes (v0.16.0): 'full' fills the tank to maxFuel; 'forFlight' buys 150% of
// the fuel required for one loaded package's destination leg (ISS-071) — wind-adjusted
// requirement per the weighted burn model, capped at tank capacity, independent of how
// much is already in the tank.
export interface RefuelOptions {
mode?: 'full' | 'forFlight';
packageId?: string;
@@ -341,24 +372,32 @@ export async function refuelPlane(inst: GameDb, planeId: string, opts: RefuelOpt
const origin = await airportByIcao(plane.locationIcao);
const dest = await airportByIcao(pkg.destIcao);
const distanceKm = haversineKm(origin.lat, origin.lon, dest.lat, dest.lon);
const requiredL = requiredFuelFor(type.burnPerKm, type.emptyWeightKg, type.maxFuel, distanceKm);
// Same wind-adjusted requirement flyPlane checks (v0.36.0) — departure weather vs route heading;
// v0.39.0 (ISS-069): broken planes burn BROKEN_BURN_MULTIPLIER × more, same factor as flyPlane.
const w = weatherFor(plane.locationIcao);
const requiredL = requiredFuelFor(
type.burnPerKm * windBurnFactor(w.windKt, w.windDirDeg, initialBearingDeg(origin.lat, origin.lon, dest.lat, dest.lon)) * conditionBurnMultiplier(plane.health),
type.emptyWeightKg,
type.maxFuel,
distanceKm,
);
if (requiredL > plane.maxFuel) {
throw new GameActionError(409, `the leg to ${pkg.destIcao} needs ${Math.round(requiredL)} L even starting empty — it does not fit a full ${plane.maxFuel} L tank`);
}
// Land EXACTLY on the leg requirement when topping up: flyPlane re-computes this same value and
// compares with strict `>`, so fuel := requiredL guarantees the leg is feasible. The alternative
// (fuel + liters) can round a hair BELOW R in IEEE754 — f + fl(R − f) is not guaranteed ≥ R — and
// would 409 on an otherwise-feasible leg. Already enough → free no-op, tank untouched.
targetFuel = Math.max(plane.fuel, requiredL);
// ISS-071: buy 150% of the requirement (capped at tank capacity) so burn variance doesn't strand
// the plane. The margin makes flyPlane's strict `>` check pass with room to spare; already enough
// → free no-op, tank untouched.
targetFuel = Math.min(plane.maxFuel, Math.max(plane.fuel, requiredL * 1.5));
} else {
// Exactly maxFuel (as before v0.16.0) — clients/tests compare against it with strict equality.
targetFuel = plane.maxFuel;
}
const liters = Math.max(0, targetFuel - plane.fuel);
const cost = Math.round(liters * FUEL_PRICE_PER_L * 100) / 100;
const account = (await inst.db.select().from(schema.account))[0];
if (!account) throw new Error('account row missing');
// v0.38.0 (ISS-072): rep-scaled discount on the PRICE term only — the burn/target math above is untouched.
const cost = Math.round(liters * FUEL_PRICE_PER_L * (1 - repRate(account.reputation, FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT)) * 100) / 100;
if (cost > 0 && account.cash < cost) {
throw new GameActionError(409, `insufficient funds: refueling costs ${cost}, you have ${Math.round(account.cash)}`);
}
@@ -371,6 +410,38 @@ export async function refuelPlane(inst: GameDb, planeId: string, opts: RefuelOpt
return { cost };
}
// --- Repair (POST /api/planes/:planeId/repair) — v0.39.0 (ISS-069) ---
// Restore a worn-out plane to full health for the flat REPAIR_COST; it is grounded in 'repairing'
// status until sim.clockMs + REPAIR_DURATION_MS, when the engine's tick flips it back to
// 'available'. Only GROUNDED planes can be repaired (409 otherwise); an already-full-health
// repair is a free no-op (mirrors refuel's). 404 unknown plane; 409 not grounded / insufficient funds.
export async function repairPlane(inst: GameDb, planeId: string): Promise<{ cost: number }> {
const rows = await inst.db.select().from(schema.planes).where(eq(schema.planes.id, planeId));
const plane = rows[0];
if (!plane) throw new GameActionError(404, `plane ${planeId} not found`);
if (plane.status !== 'available') {
throw new GameActionError(409, `plane is ${plane.status}, only grounded planes can be repaired`);
}
// Free no-op when the plane is already at full health — nothing to repair.
if (plane.health >= 100) return { cost: 0 };
const sim = (await inst.db.select().from(schema.simState))[0];
if (!sim) throw new Error('sim_state row missing');
const account = (await inst.db.select().from(schema.account))[0];
if (!account) throw new Error('account row missing');
if (account.cash < REPAIR_COST) {
throw new GameActionError(409, `insufficient funds: repair costs ${REPAIR_COST}, you have ${Math.round(account.cash)}`);
}
await inst.db.update(schema.planes)
.set({ health: 100, status: 'repairing', repairUntilSimTime: sim.clockMs + REPAIR_DURATION_MS })
.where(eq(schema.planes.id, planeId));
await inst.db.update(schema.account).set({ cash: Math.round((account.cash - REPAIR_COST) * 100) / 100 });
await persist(inst);
return { cost: REPAIR_COST };
}
// --- Route helper (response convention, v0.8.0) ---
// Run an action under the sim lock — tick FIRST so validation/mutation see current
+40 -1
View File
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest';
import { PLANE_TYPES, STARTER_MODEL } from './catalog';
import { BROKEN_BURN_MULTIPLIER, conditionBurnMultiplier, FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT, MAX_PLANE_DISCOUNT, PLANE_DISCOUNT_PER_REP, PLANE_TYPES, repRate, STARTER_MODEL } from './catalog';
import { STARTING_REPUTATION } from './seed';
// v0.28.0 (ISS-047) — catalog sanity: the progression ladder must stay well-formed as types are added.
describe('PLANE_TYPES', () => {
@@ -22,3 +23,41 @@ describe('PLANE_TYPES', () => {
expect(PLANE_TYPES[STARTER_MODEL]).toBeDefined();
});
});
// v0.38.0 (ISS-072) — the shared rep-discount shape: linear above the starting value, capped.
describe('repRate', () => {
it('is exactly 0 at and below the starting reputation', () => {
expect(repRate(STARTING_REPUTATION, PLANE_DISCOUNT_PER_REP, MAX_PLANE_DISCOUNT)).toBe(0);
expect(repRate(0, PLANE_DISCOUNT_PER_REP, MAX_PLANE_DISCOUNT)).toBe(0);
expect(repRate(-5, FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT)).toBe(0); // below-start clamps too
});
it('is linear in reputation above the starting value (exact mid-range values)', () => {
expect(repRate(STARTING_REPUTATION + 10, PLANE_DISCOUNT_PER_REP, MAX_PLANE_DISCOUNT)).toBeCloseTo(0.1, 12); // 0.01 × 10
expect(repRate(STARTING_REPUTATION + 30, PLANE_DISCOUNT_PER_REP, MAX_PLANE_DISCOUNT)).toBeCloseTo(0.3, 12); // 0.01 × 30
expect(repRate(STARTING_REPUTATION + 40, FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT)).toBeCloseTo(0.2, 12); // 0.005 × 40 = cap
});
it('clamps at the cap', () => {
expect(repRate(STARTING_REPUTATION + 50, PLANE_DISCOUNT_PER_REP, MAX_PLANE_DISCOUNT)).toBe(MAX_PLANE_DISCOUNT); // 0.5 → capped to 0.4
expect(repRate(1_000_000, FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT)).toBe(MAX_FUEL_DISCOUNT);
});
});
// v0.39.0 (ISS-069) — the broken-state burn factor: ×BROKEN_BURN_MULTIPLIER at/below zero health, exactly 1 above it.
describe('conditionBurnMultiplier', () => {
it('is exactly 1 for any positive health', () => {
expect(conditionBurnMultiplier(100)).toBe(1);
expect(conditionBurnMultiplier(42)).toBe(1);
expect(conditionBurnMultiplier(0.5)).toBe(1); // worn but not broken
});
it('is exactly BROKEN_BURN_MULTIPLIER at and below zero health', () => {
expect(conditionBurnMultiplier(0)).toBe(BROKEN_BURN_MULTIPLIER);
expect(conditionBurnMultiplier(-3)).toBe(BROKEN_BURN_MULTIPLIER); // defensive — wear clamps at 0, but the gate is <= 0
});
it('pins the broken multiplier at ×1.5', () => {
expect(BROKEN_BURN_MULTIPLIER).toBe(1.5);
});
});
+39
View File
@@ -1,5 +1,9 @@
import { STARTING_REPUTATION } from './seed';
// Aircraft catalog — in-code constants for now (no DB table yet). One type is
// enough for the MVP; add more entries to PLANE_TYPES as they are designed.
// ponytail: circular import with seed.ts is safe — neither module touches the other's
// bindings at top level, only inside function bodies.
export interface PlaneType {
id: string;
@@ -102,6 +106,41 @@ export const FUEL_PRICE_PER_L = 0.8;
// Fraction of the catalog price paid out when selling a plane (v0.8.0).
export const SELL_FRACTION = 0.5;
// Shared rep-discount shape (v0.38.0, ISS-072): linear in reputation above the starting value,
// capped — exactly 0 at/below STARTING_REPUTATION so default-rep behavior is unchanged.
export function repRate(reputation: number, perPoint: number, cap: number): number {
return Math.min(cap, perPoint * Math.max(0, reputation - STARTING_REPUTATION));
}
// ponytail: balance-tunable — plane purchase discount per rep point above the starting value.
export const PLANE_DISCOUNT_PER_REP = 0.01;
// ponytail: balance-tunable — max plane purchase discount (reached at rep ≥ 50).
export const MAX_PLANE_DISCOUNT = 0.4;
// ponytail: balance-tunable — refuel price discount per rep point above the starting value.
export const FUEL_DISCOUNT_PER_REP = 0.005;
// ponytail: balance-tunable — max refuel price discount (reached at rep ≥ 50).
export const MAX_FUEL_DISCOUNT = 0.2;
// --- Maintenance (v0.39.0, ISS-069) ---
// ponytail: balance-tunable — health lost per km of landed legs (~13 mid-range EDDF↔LEMD hops to break from full).
export const WEAR_PER_KM = 0.005;
// ponytail: balance-tunable — burn multiplier for a broken plane (health ≤ 0): it still flies, just thirsty.
export const BROKEN_BURN_MULTIPLIER = 1.5;
// Broken-state burn factor for one plane's health — the third multiplicative term alongside wind at every
// per-plane burn site (flyPlane feasibility, refuel forFlight, engine landed fuel + auto-chain, live display).
export function conditionBurnMultiplier(health: number): number {
return health <= 0 ? BROKEN_BURN_MULTIPLIER : 1;
}
// ponytail: balance-tunable — flat repair cost in € (per-type if balance demands it).
export const REPAIR_COST = 1_500;
// ponytail: balance-tunable — how long a repair grounds the plane, sim ms (3 game days).
export const REPAIR_DURATION_MS = 3 * 86_400_000;
// Snapshot shape of one catalog entry in GET /api/state (v0.10.0): `model` is the
// id used by buy/dispatch, `label` the display name — clients compute all market
// previews (buy price, sell payout, refuel cost) from these + the economy constants.
+104 -21
View File
@@ -1,9 +1,12 @@
import { describe, expect, it } from 'vitest';
import { haversineKm } from '@/lib/geo';
import { PLANE_TYPES, STARTER_MODEL } from './catalog';
import { PLANE_TYPES, repRate, STARTER_MODEL } from './catalog';
import { SPECIALS } from './specials';
import {
BASE_REWARD,
buildSeed,
JOB_BONUS_PER_REP,
MAX_JOB_BONUS,
PACKAGE_COUNT_MAX,
PACKAGE_COUNT_MIN,
generatePackage,
@@ -46,6 +49,7 @@ describe('buildSeed', () => {
lat: 50.0333,
lon: 8.5706,
queue: [], // v0.25.0 (ISS-043) — no queued legs on first run
health: 100, // v0.39.0 (ISS-069) — full health on first run
});
});
@@ -71,14 +75,14 @@ describe('buildSeed', () => {
expect(new Set(seed.packages.map((c) => c.id)).size).toBe(seed.packages.length);
});
it('applies the reward formula: round(500 + 2.5 * km + 0.5 * weightKg)', () => {
it('applies the reward formula: round(500 + 2.5 * km + 0.5 * weightKg), × special multiplier', () => {
const seed = seedWith(42);
for (const c of seed.packages) {
const o = AIRPORTS.find((a) => a.icao === c.originIcao)!;
const d = AIRPORTS.find((a) => a.icao === c.destIcao)!;
expect(c.reward).toBe(
Math.round(BASE_REWARD + REWARD_PER_KM * haversineKm(o.lat, o.lon, d.lat, d.lon) + REWARD_PER_KG * c.weightKg),
);
// v0.37.0 (ISS-073): special packages pay the registry multiplier on top of the base formula.
const mult = c.special ? SPECIALS[c.special]?.rewardMultiplier ?? 1 : 1;
expect(c.reward).toBe(Math.round(rewardFor(haversineKm(o.lat, o.lon, d.lat, d.lon), c.weightKg) * mult));
}
});
@@ -94,11 +98,13 @@ describe('buildSeed', () => {
}
});
it('sets deadlines inside the 6–72h game-time window after now', () => {
it('sets deadlines inside the 6–72h game-time window after now (scaled by any special status)', () => {
const seed = seedWith(42);
for (const c of seed.packages) {
expect(c.deadlineSimTime).toBeGreaterThan(NOW + 6 * 3_600_000 - 1);
expect(c.deadlineSimTime).toBeLessThanOrEqual(NOW + 72 * 3_600_000);
// v0.37.0 (ISS-073): a special's deadlineScale tightens the drawn window (e.g. priority → 3–36h).
const scale = c.special ? SPECIALS[c.special]?.deadlineScale ?? 1 : 1;
expect(c.deadlineSimTime).toBeGreaterThan(NOW + 6 * 3_600_000 * scale - 1);
expect(c.deadlineSimTime).toBeLessThanOrEqual(NOW + 72 * 3_600_000 * scale);
}
});
@@ -143,13 +149,14 @@ describe('generatePackage (shared seed/spawn builder)', () => {
const o = AIRPORTS.find((a) => a.icao === c.originIcao)!;
const d = AIRPORTS.find((a) => a.icao === c.destIcao)!;
expect(c.reward).toBe(
Math.round(BASE_REWARD + REWARD_PER_KM * haversineKm(o.lat, o.lon, d.lat, d.lon) + REWARD_PER_KG * c.weightKg),
);
// v0.37.0 (ISS-073): special packages pay the registry multiplier on top of the base formula.
const mult = c.special ? SPECIALS[c.special]?.rewardMultiplier ?? 1 : 1;
expect(c.reward).toBe(Math.round(rewardFor(haversineKm(o.lat, o.lon, d.lat, d.lon), c.weightKg) * mult));
// Same 6–72h game-time deadline window as the seed.
expect(c.deadlineSimTime).toBeGreaterThan(NOW + 6 * 3_600_000 - 1);
expect(c.deadlineSimTime).toBeLessThanOrEqual(NOW + 72 * 3_600_000);
// Same 6–72h game-time deadline window as the seed — scaled by any special status.
const scale = c.special ? SPECIALS[c.special]?.deadlineScale ?? 1 : 1;
expect(c.deadlineSimTime).toBeGreaterThan(NOW + 6 * 3_600_000 * scale - 1);
expect(c.deadlineSimTime).toBeLessThanOrEqual(NOW + 72 * 3_600_000 * scale);
});
it('is deterministic for a fixed RNG and honors the id/nowMs inputs', () => {
@@ -191,9 +198,9 @@ describe('generatePackage (shared seed/spawn builder)', () => {
const o = AIRPORTS.find((a) => a.icao === c.originIcao)!;
const d = AIRPORTS.find((a) => a.icao === c.destIcao)!;
expect(c.reward).toBe(
Math.round(BASE_REWARD + REWARD_PER_KM * haversineKm(o.lat, o.lon, d.lat, d.lon) + REWARD_PER_KG * c.weightKg),
);
// v0.37.0 (ISS-073): the rolled special (if any) multiplies the base reward.
const mult = c.special ? SPECIALS[c.special]?.rewardMultiplier ?? 1 : 1;
expect(c.reward).toBe(Math.round(rewardFor(haversineKm(o.lat, o.lon, d.lat, d.lon), c.weightKg) * mult));
});
it('destIcao forces the destination; origin stays random but distinct', () => {
@@ -209,9 +216,9 @@ describe('generatePackage (shared seed/spawn builder)', () => {
const o = AIRPORTS[0]; // EDDF
const d = AIRPORTS[1]; // EHAM
expect(c.reward).toBe(
Math.round(BASE_REWARD + REWARD_PER_KM * haversineKm(o.lat, o.lon, d.lat, d.lon) + REWARD_PER_KG * c.weightKg),
);
// v0.37.0 (ISS-073): the rolled special (if any) multiplies the base reward.
const mult = c.special ? SPECIALS[c.special]?.rewardMultiplier ?? 1 : 1;
expect(c.reward).toBe(Math.round(rewardFor(haversineKm(o.lat, o.lon, d.lat, d.lon), c.weightKg) * mult));
});
it('is deterministic for a fixed RNG with forced endpoints', () => {
@@ -242,7 +249,9 @@ describe('generatePackage (shared seed/spawn builder)', () => {
const o = AIRPORTS.find((a) => a.icao === c.originIcao)!;
const d = AIRPORTS.find((a) => a.icao === c.destIcao)!;
expect(c.reward).toBe(Math.round(BASE_REWARD + REWARD_PER_KM * haversineKm(o.lat, o.lon, d.lat, d.lon) + REWARD_PER_KG * 1_234));
// v0.37.0 (ISS-073): the rolled special (if any) multiplies the base reward.
const mult = c.special ? SPECIALS[c.special]?.rewardMultiplier ?? 1 : 1;
expect(c.reward).toBe(Math.round(rewardFor(haversineKm(o.lat, o.lon, d.lat, d.lon), 1_234) * mult));
// Deterministic for a fixed RNG…
const again = generatePackage({ id: 'c-w', airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW, weightKg: 1_234 });
@@ -252,4 +261,78 @@ describe('generatePackage (shared seed/spawn builder)', () => {
expect(c.deadlineSimTime).not.toBe(unforced.deadlineSimTime);
});
});
describe('special status (v0.37.0, ISS-073)', () => {
it('a forced special passes through; an explicit null forces none; omitted rolls a registry key or null', () => {
const base = { id: 'c-sp', airports: AIRPORTS, nowMs: NOW };
expect(generatePackage({ ...base, rng: mulberry32(42), special: 'priority' }).special).toBe('priority');
expect(generatePackage({ ...base, rng: mulberry32(42), special: null }).special).toBeNull();
const rolled = generatePackage({ ...base, rng: mulberry32(42) });
expect(rolled.special === null || SPECIALS[rolled.special] !== undefined).toBe(true);
});
it('applies the registry reward multiplier to the base reward', () => {
const c = generatePackage({ id: 'c-sp-r', airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW, originIcao: 'EDDF', destIcao: 'EHAM', weightKg: 1_000, special: 'priority' });
const km = haversineKm(AIRPORTS[0].lat, AIRPORTS[0].lon, AIRPORTS[1].lat, AIRPORTS[1].lon); // EDDF → EHAM
expect(c.reward).toBe(Math.round(rewardFor(km, 1_000) * SPECIALS.priority.rewardMultiplier));
});
it('scales the drawn deadline window by the registry factor', () => {
const c = generatePackage({ id: 'c-sp-d', airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW, special: 'priority' });
expect(c.deadlineSimTime).toBeGreaterThan(NOW + 6 * 3_600_000 * SPECIALS.priority.deadlineScale - 1);
expect(c.deadlineSimTime).toBeLessThanOrEqual(NOW + 72 * 3_600_000 * SPECIALS.priority.deadlineScale);
});
it('a forced deadline offset skips the special scaling', () => {
const c = generatePackage({ id: 'c-sp-f', airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW, special: 'priority', deadlineOffsetMs: 10_000 });
expect(c.deadlineSimTime).toBe(NOW + 10_000); // forced offset untouched by the scale
});
it('a forced special (including explicit null) skips the appended RNG draw', () => {
const countDraws = (special?: string | null): number => {
let n = 0;
const base = mulberry32(42);
generatePackage({ id: 'c-sp-n', airports: AIRPORTS, rng: () => { n += 1; return base(); }, nowMs: NOW, special });
return n;
};
// origin, dest, cargoType, cargoCount, weight jitter, deadline window — the fixed order minus the appended draw.
expect(countDraws('priority')).toBe(6);
expect(countDraws(null)).toBe(6); // explicit null skips too
expect(countDraws()).toBeGreaterThanOrEqual(7); // pickSpecial draws 1–2 extra
});
});
describe('reputation job bonus (v0.38.0, ISS-072)', () => {
it('multiplies the reward by 1 + repRate at elevated reputation', () => {
const c = generatePackage({ id: 'c-rep', airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW, originIcao: 'EDDF', destIcao: 'EHAM', weightKg: 1_000, special: null, reputation: 60 });
const km = haversineKm(AIRPORTS[0].lat, AIRPORTS[0].lon, AIRPORTS[1].lat, AIRPORTS[1].lon); // EDDF → EHAM
expect(repRate(60, JOB_BONUS_PER_REP, MAX_JOB_BONUS)).toBeCloseTo(0.5, 12); // rep 60 → ×1.5
expect(c.reward).toBe(Math.round(rewardFor(km, 1_000) * (1 + repRate(60, JOB_BONUS_PER_REP, MAX_JOB_BONUS))));
});
it('defaults to the starting reputation when omitted — byte-identical output', () => {
const a = generatePackage({ id: 'c-r0', airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW });
const b = generatePackage({ id: 'c-r0', airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW, reputation: STARTING_REPUTATION });
expect(a).toEqual(b); // no bonus at the starting value
});
it('composes with the special multiplier (base × jobBonus × special)', () => {
const c = generatePackage({ id: 'c-rep-sp', airports: AIRPORTS, rng: mulberry32(42), nowMs: NOW, originIcao: 'EDDF', destIcao: 'EHAM', weightKg: 1_000, special: 'priority', reputation: 60 });
const km = haversineKm(AIRPORTS[0].lat, AIRPORTS[0].lon, AIRPORTS[1].lat, AIRPORTS[1].lon); // EDDF → EHAM
expect(c.reward).toBe(
Math.round(rewardFor(km, 1_000) * (1 + repRate(60, JOB_BONUS_PER_REP, MAX_JOB_BONUS)) * SPECIALS.priority.rewardMultiplier),
);
});
it('consumes no extra RNG draw', () => {
const countDraws = (reputation?: number): number => {
let n = 0;
const base = mulberry32(42);
generatePackage({ id: 'c-rep-n', airports: AIRPORTS, rng: () => { n += 1; return base(); }, nowMs: NOW, special: null, reputation });
return n;
};
expect(countDraws()).toBe(6); // forced-null special → the fixed order minus the appended draw
expect(countDraws(60)).toBe(6); // the rep hook consumes no draws
});
});
});
+36 -5
View File
@@ -1,5 +1,6 @@
import { haversineKm } from '@/lib/geo';
import { PLANE_TYPES, STARTER_MODEL } from './catalog';
import { PLANE_TYPES, repRate, STARTER_MODEL } from './catalog';
import { pickSpecial, SPECIALS } from './specials';
// First-run seed. Everything here is a pure function of (airports, rng, nowMs)
// so it is fully deterministic and unit-testable: the RNG is injected, never
@@ -41,6 +42,8 @@ export interface SeedPlane {
// Upcoming legs not yet started (v0.25.0, ISS-043) — serialized to the queue
// column as JSON text on insert; defaults to an empty queue.
queue?: string[];
// Structural health in [0, 100] (v0.39.0, ISS-069) — new planes start at full health.
health: number;
}
export interface SeedPackage {
@@ -52,6 +55,8 @@ export interface SeedPackage {
cargoCount: number;
// Package weight in kg (v0.27.0, ISS-045) — unit weight × count with ±30% jitter.
weightKg: number;
// Special status (v0.37.0, ISS-073) — key into lib/game/specials.ts SPECIALS; null = none.
special: string | null;
deadlineSimTime: number;
status: 'open';
planeId: null;
@@ -89,6 +94,13 @@ export const BASE_REWARD = 500;
export const REWARD_PER_KM = 2.5;
export const REWARD_PER_KG = 0.5;
// Job reward bonus (v0.38.0, ISS-072): reward ×= 1 + repRate(reputation, …) — better jobs at
// higher reputation, exactly ×1 at the starting value.
// ponytail: balance-tunable — job reward bonus per rep point above the starting value.
export const JOB_BONUS_PER_REP = 0.01;
// ponytail: balance-tunable — max job reward bonus (up to ×2 at rep ≥ 110).
export const MAX_JOB_BONUS = 1.0;
// Package deadlines: now + a plausible game-time window of 6–72 hours.
const DEADLINE_MIN_HOURS = 6;
const DEADLINE_MAX_HOURS = 72;
@@ -116,12 +128,17 @@ export function rewardFor(km: number, weightKg: number): number {
// weight = unit weight × count with ±30% jitter (v0.27.0), deadline now + 6–72h
// game time (or now + deadlineOffsetMs when given — used by the e2e test hook to
// force a near-term expiry). The RNG call order per package is fixed: origin,
// dest, cargoType, cargoCount, weight jitter, [deadline window].
// dest, cargoType, cargoCount, weight jitter, [deadline window], [special].
// v0.8.0: originIcao/destIcao optionally FORCE an endpoint (spawn coverage rule +
// the e2e create-package hook); a forced endpoint consumes no RNG draws, so
// callers that omit both get byte-identical behavior to before.
// v0.27.0: weightKg optionally FORCES the package weight (e2e hook) — it skips the
// jitter draw, same pattern as forced endpoints.
// v0.37.0 (ISS-073): special optionally FORCES the special status (string or explicit null) —
// it skips the appended pickSpecial draw; otherwise ~SPECIAL_SPAWN_CHANCE of packages roll one.
// v0.38.0 (ISS-072): reputation optionally scales the reward (×(1 + repRate)) at the same spot
// as the special multiplier — a pure input, it consumes NO RNG draws and defaults to the
// starting value so seed-time behavior is byte-identical.
export function generatePackage(input: {
id: string;
airports: SeedAirport[];
@@ -131,6 +148,8 @@ export function generatePackage(input: {
originIcao?: string;
destIcao?: string;
weightKg?: number;
special?: string | null;
reputation?: number;
}): SeedPackage {
const { airports, rng, nowMs } = input;
if (airports.length < 2) throw new Error('package generation needs at least two airports');
@@ -165,17 +184,28 @@ export function generatePackage(input: {
// Weight (v0.27.0): unit weight × count with ±30% jitter, integer kg — drawn AFTER
// cargoCount so the fixed RNG order above holds; a forced weightKg skips the draw.
const weightKg = input.weightKg ?? Math.round(UNIT_WEIGHT_KG[cargoType] * cargoCount * (0.7 + 0.6 * rng()));
const deadlineMs =
input.deadlineOffsetMs ?? (DEADLINE_MIN_HOURS + rng() * (DEADLINE_MAX_HOURS - DEADLINE_MIN_HOURS)) * 3_600_000;
// Drawn deadline window — the existing draw, position unchanged; special scaling is applied
// below AFTER the appended special draw so the fixed order above holds.
const drawnWindowMs = (DEADLINE_MIN_HOURS + rng() * (DEADLINE_MAX_HOURS - DEADLINE_MIN_HOURS)) * 3_600_000;
// v0.37.0 (ISS-073): special status — the appended draw at the END of the fixed order above; a
// forced value (including explicit null) skips it, same pattern as the other overrides.
const special = input.special !== undefined ? input.special : pickSpecial(rng);
const mods = special !== null ? SPECIALS[special] : undefined;
// Forced deadlines skip the special scaling — consistent with overrides skipping draws.
const deadlineMs = input.deadlineOffsetMs ?? drawnWindowMs * (mods?.deadlineScale ?? 1);
// v0.38.0 (ISS-072): reputation bonus composed with the special multiplier, single round at the end.
const repBonus = 1 + repRate(input.reputation ?? STARTING_REPUTATION, JOB_BONUS_PER_REP, MAX_JOB_BONUS);
return {
id: input.id,
originIcao: origin.icao,
destIcao: dest.icao,
reward: rewardFor(km, weightKg),
reward: Math.round(rewardFor(km, weightKg) * repBonus * (mods?.rewardMultiplier ?? 1)),
cargoType,
cargoCount,
weightKg,
special,
deadlineSimTime: Math.round(nowMs + deadlineMs),
status: 'open',
planeId: null,
@@ -209,6 +239,7 @@ export function buildSeed(input: {
lat: hub.lat,
lon: hub.lon,
queue: [], // no queued legs on first run (v0.25.0)
health: 100, // full health on first run (v0.39.0, ISS-069)
};
const count = PACKAGE_COUNT_MIN + Math.floor(rng() * (PACKAGE_COUNT_MAX - PACKAGE_COUNT_MIN + 1));
+43
View File
@@ -0,0 +1,43 @@
import { describe, expect, it } from 'vitest';
import { mulberry32, type Rng } from './seed';
import { pickSpecial, SPECIALS, SPECIAL_SPAWN_CHANCE } from './specials';
describe('pickSpecial (v0.37.0, ISS-073)', () => {
it('returns null when the chance draw fails and consumes exactly one draw', () => {
let draws = 0;
const rng: Rng = () => { draws += 1; return 0.9; }; // ≥ SPECIAL_SPAWN_CHANCE → no special
expect(pickSpecial(rng)).toBeNull();
expect(draws).toBe(1);
});
it('picks among the registry keys when the chance draw passes', () => {
const seen = new Set<string>();
for (let i = 0; i < 2_000; i++) {
let call = 0;
// First call is the chance draw (< chance → roll); later calls sweep [0, 1) as index draws.
const rng: Rng = () => (call++ === 0 ? 0.1 : i / 2_000);
const s = pickSpecial(rng);
if (s !== null) seen.add(s);
}
expect([...seen].sort()).toEqual(Object.keys(SPECIALS).sort()); // every key reachable
});
it('never returns a status whose entry was removed; an empty registry consumes no draws', () => {
const withoutPriority = Object.fromEntries(Object.entries(SPECIALS).filter(([k]) => k !== 'priority'));
for (let i = 0; i < 500; i++) {
expect(pickSpecial(mulberry32(i), withoutPriority)).not.toBe('priority'); // removing an entry disables it
}
let draws = 0;
const rng: Rng = () => { draws += 1; return 0.0; }; // would pass the chance draw if consulted
expect(pickSpecial(rng, {})).toBeNull();
expect(draws).toBe(0);
});
it('rolls a special for roughly SPECIAL_SPAWN_CHANCE of packages (fixed RNG)', () => {
const rng = mulberry32(42);
let hits = 0;
for (let i = 0; i < 10_000; i++) if (pickSpecial(rng) !== null) hits++;
expect(hits / 10_000).toBeGreaterThan(SPECIAL_SPAWN_CHANCE - 0.05);
expect(hits / 10_000).toBeLessThan(SPECIAL_SPAWN_CHANCE + 0.05);
});
});
+34
View File
@@ -0,0 +1,34 @@
// Special-status registry (v0.37.0, ISS-073) — data-driven package modifiers: one entry per
// special status maps to reward/deadline/reputation tweaks applied at generation and delivery.
// Adding or removing a special is an edit of ONE entry here — no scattered branches elsewhere.
import type { Rng } from './seed';
export interface SpecialModifiers {
// Multiplier on the base reward (applied at package generation).
rewardMultiplier: number;
// Scale factor on the drawn deadline window (applied at generation; forced offsets skip it).
deadlineScale: number;
// Flat reputation bonus added to the distance-based gain at delivery.
repBonus: number;
}
export const SPECIALS: Record<string, SpecialModifiers> = {
priority: {
rewardMultiplier: 1.5, // ponytail: balance-tunable — a 50% premium for the tighter deadline below
deadlineScale: 0.5, // ponytail: balance-tunable — half the normal 6–72h window (3–36h)
repBonus: 0.5, // ponytail: balance-tunable — flat rep on top of the distance-based gain at delivery
},
};
// Fraction of generated packages that roll a special status (v0.37.0).
export const SPECIAL_SPAWN_CHANCE = 0.15;
// Pure spawn helper: one chance draw, then a uniform pick among the registry's keys. The registry
// is a PARAM defaulting to the real one so tests can pass a filtered/empty record — removing an
// entry cleanly disables that status (an empty registry consumes no draws at all).
export function pickSpecial(rng: Rng, registry: Record<string, SpecialModifiers> = SPECIALS): string | null {
const keys = Object.keys(registry);
if (keys.length === 0) return null;
if (rng() >= SPECIAL_SPAWN_CHANCE) return null;
return keys[Math.floor(rng() * keys.length)];
}
+29 -5
View File
@@ -1,16 +1,18 @@
import { loadAirportsGeoJson } from '@/lib/airports';
import { haversineKm } from '@/lib/geo';
import { haversineKm, initialBearingDeg } from '@/lib/geo';
import { weatherFor } from '@/lib/weather';
import { getGameDb } from '@/lib/db/client';
import * as schema from '@/lib/db/schema';
import { catalogEntries, FUEL_PRICE_PER_L, PLANE_TYPES, SELL_FRACTION, type CatalogEntry } from './catalog';
import { fuelAtWeighted, progressAt, positionAt } from '@/lib/sim/movement';
import { conditionBurnMultiplier, catalogEntries, FUEL_PRICE_PER_L, PLANE_TYPES, SELL_FRACTION, type CatalogEntry } from './catalog';
import { fuelAtWeighted, progressAt, positionAt, windBurnFactor } from '@/lib/sim/movement';
// Read-only game-state snapshot for client polling (GET /api/state).
export interface PlaneState {
id: string;
model: string;
status: 'available' | 'flying';
// v0.39.0 (ISS-069): 'repairing' = grounded in the shop until repairUntilSimTime passes.
status: 'available' | 'flying' | 'repairing';
fuel: number;
maxFuel: number;
locationIcao: string;
@@ -27,6 +29,11 @@ export interface PlaneState {
// Upcoming legs NOT yet started (v0.25.0, ISS-043) — ordered ICAOs; the active
// leg is destIcao. The engine auto-chains queue[0] on landing when fuel covers it.
queue: string[];
// Structural health in [0, 100] (v0.39.0, ISS-069) — wears down with distance flown; at 0 the
// plane is broken and burns BROKEN_BURN_MULTIPLIER × more fuel until repaired.
health: number;
// Sim-clock epoch ms when an active repair finishes (v0.39.0, ISS-069); null when not in repair.
repairUntilSimTime: number | null;
}
// Parse the planes.queue JSON column into an ICAO list (v0.25.0). Guards against
@@ -52,6 +59,8 @@ export interface PackageState {
cargoCount: number;
// Package weight in kg (v0.27.0, ISS-045) — drives load validation + per-kg payout.
weightKg: number;
// Special status (v0.37.0, ISS-073) — key into lib/game/specials.ts SPECIALS; null = none.
special: string | null;
deadlineSimTime: number;
// 'ignored' (v0.29.0, ISS-047 part B) = a dismissed open job — terminal, leaves the market.
status: 'open' | 'loaded' | 'delivered' | 'expired' | 'abandoned' | 'ignored';
@@ -102,7 +111,19 @@ export async function getStateSnapshot(): Promise<StateSnapshot> {
const distanceKm = haversineKm(from.lat, from.lon, to.lat, to.lon);
const progress = progressAt(p.departureSimTime, distanceKm, type.speedKmh, simRow.clockMs);
const pos = positionAt(from.lat, from.lon, to.lat, to.lon, progress);
return { ...pos, fuel: fuelAtWeighted(p.fuel, type.burnPerKm, type.emptyWeightKg, type.maxFuel, distanceKm, progress) };
// Wind-adjusted burn (v0.36.0) — weather is a pure function of the departure ICAO, so this
// recomputes exactly the factor the engine used for the leg; no stored state needed.
// v0.39.0 (ISS-069): broken planes burn BROKEN_BURN_MULTIPLIER × more — p.health is frozen at
// departure (wear lands only), so this stays bit-consistent with the engine's landed fuel.
const w = weatherFor(p.locationIcao);
return { ...pos, fuel: fuelAtWeighted(
p.fuel,
type.burnPerKm * windBurnFactor(w.windKt, w.windDirDeg, initialBearingDeg(from.lat, from.lon, to.lat, to.lon)) * conditionBurnMultiplier(p.health),
type.emptyWeightKg,
type.maxFuel,
distanceKm,
progress,
) };
};
return {
@@ -127,6 +148,8 @@ export async function getStateSnapshot(): Promise<StateSnapshot> {
lat: live?.lat ?? p.lat,
lon: live?.lon ?? p.lon,
queue: parseIcaoQueue(p.queue),
health: p.health, // v0.39.0 (ISS-069) — stored value; wear accumulates at landing only
repairUntilSimTime: p.repairUntilSimTime,
};
}),
packages: packageRows.map((p) => ({
@@ -139,6 +162,7 @@ export async function getStateSnapshot(): Promise<StateSnapshot> {
cargoType: p.cargoType,
cargoCount: p.cargoCount,
weightKg: p.weightKg,
special: p.special,
deadlineSimTime: p.deadlineSimTime,
status: p.status,
planeId: p.planeId,
+38 -1
View File
@@ -1,5 +1,42 @@
import { describe, expect, it } from 'vitest';
import { haversineKm } from './geo';
import { haversineKm, initialBearingDeg } from './geo';
describe('initialBearingDeg', () => {
it('is due north (0) for a point directly above', () => {
expect(initialBearingDeg(0, 0, 10, 0)).toBe(0);
});
it('is due east (90) along the equator', () => {
expect(initialBearingDeg(0, -10, 0, 10)).toBeCloseTo(90, 9);
});
it('is due south (180) for a point directly below', () => {
expect(initialBearingDeg(10, 0, 0, 0)).toBeCloseTo(180, 9);
});
it('is due west (270) along the equator — normalized into [0, 360)', () => {
expect(initialBearingDeg(0, 10, 0, -10)).toBeCloseTo(270, 9);
});
it('always normalizes to [0, 360) for arbitrary pairs', () => {
const pairs: Array<[number, number, number, number]> = [
[50.03, 8.57, 40.47, -3.56], // FRA → MAD (southwest-ish)
[40.47, -3.56, 50.03, 8.57], // reverse: northeast-ish
[65.66, -18.07, 50.03, 8.57], // far north → south-east wrap case
];
for (const [a1, a2, b1, b2] of pairs) {
const brg = initialBearingDeg(a1, a2, b1, b2);
expect(brg).toBeGreaterThanOrEqual(0);
expect(brg).toBeLessThan(360);
}
});
it('FRA → MAD points roughly south-southwest (between 180 and 270)', () => {
const brg = initialBearingDeg(50.03, 8.57, 40.47, -3.56);
expect(brg).toBeGreaterThan(180);
expect(brg).toBeLessThan(270);
});
});
describe('haversineKm', () => {
it('returns 0 for identical points', () => {
+13
View File
@@ -1,5 +1,18 @@
const EARTH_RADIUS_KM = 6371;
// Initial great-circle bearing from point 1 to point 2 (v0.36.0), degrees true [0, 360).
export function initialBearingDeg(lat1: number, lon1: number, lat2: number, lon2: number): number {
const toRad = (d: number) => (d * Math.PI) / 180;
const phi1 = toRad(lat1);
const phi2 = toRad(lat2);
const dLon = toRad(lon2 - lon1);
const theta = Math.atan2(
Math.sin(dLon) * Math.cos(phi2),
Math.cos(phi1) * Math.sin(phi2) - Math.sin(phi1) * Math.cos(phi2) * Math.cos(dLon),
);
return (theta * 180 / Math.PI + 360) % 360;
}
// Great-circle distance between two WGS84 points (haversine), in km.
export function haversineKm(lat1: number, lon1: number, lat2: number, lon2: number): number {
const toRad = (d: number) => (d * Math.PI) / 180;
+17 -17
View File
@@ -34,8 +34,8 @@ describe('planesGeoJson', () => {
const fc = planesGeoJson(
snapshot({
planes: [
{ id: 'plane-starter', model: 'atr72', status: 'available', fuel: 6300, maxFuel: 6300, locationIcao: 'EDDF', locationName: 'Frankfurt Main Airport', destIcao: null, destName: null, departureSimTime: null, lat: 50.03, lon: 8.56, queue: [] },
{ id: 'plane-2', model: 'atr72', status: 'flying', fuel: 4000, maxFuel: 6300, locationIcao: 'EDDF', locationName: 'Frankfurt Main Airport', destIcao: 'EDDV', destName: 'Hannover Airport', departureSimTime: 1000, lat: 51.2, lon: 9.1, queue: [] },
{ id: 'plane-starter', model: 'atr72', status: 'available', fuel: 6300, maxFuel: 6300, locationIcao: 'EDDF', locationName: 'Frankfurt Main Airport', destIcao: null, destName: null, departureSimTime: null, lat: 50.03, lon: 8.56, health: 100, repairUntilSimTime: null, queue: [] },
{ id: 'plane-2', model: 'atr72', status: 'flying', fuel: 4000, maxFuel: 6300, locationIcao: 'EDDF', locationName: 'Frankfurt Main Airport', destIcao: 'EDDV', destName: 'Hannover Airport', departureSimTime: 1000, lat: 51.2, lon: 9.1, health: 100, repairUntilSimTime: null, queue: [] },
],
}),
);
@@ -61,7 +61,7 @@ describe('planesGeoJson', () => {
snapshot({
catalog: [],
planes: [
{ id: 'p1', model: 'mystery', status: 'available', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'x', destIcao: null, destName: null, departureSimTime: null, lat: 0, lon: 0, queue: [] },
{ id: 'p1', model: 'mystery', status: 'available', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'x', destIcao: null, destName: null, departureSimTime: null, lat: 0, lon: 0, health: 100, repairUntilSimTime: null, queue: [] },
],
}),
);
@@ -72,7 +72,7 @@ describe('planesGeoJson', () => {
const fc = planesGeoJson(
snapshot({
planes: [
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'x', destIcao: null, destName: null, departureSimTime: 0, lat: 0, lon: 0, queue: [] },
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'x', destIcao: null, destName: null, departureSimTime: 0, lat: 0, lon: 0, health: 100, repairUntilSimTime: null, queue: [] },
],
}),
);
@@ -89,7 +89,7 @@ describe('routesGeoJson (v0.12.0 — one line per flying plane\'s current leg)',
const fc = routesGeoJson(
snapshot({
planes: [
{ id: 'p1', model: 'atr72', status: 'available', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: null, destName: null, departureSimTime: null, lat: 50.03, lon: 8.56, queue: [] },
{ id: 'p1', model: 'atr72', status: 'available', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: null, destName: null, departureSimTime: null, lat: 50.03, lon: 8.56, health: 100, repairUntilSimTime: null, queue: [] },
],
}),
AIRPORTS,
@@ -101,7 +101,7 @@ describe('routesGeoJson (v0.12.0 — one line per flying plane\'s current leg)',
const fc = routesGeoJson(
snapshot({
planes: [
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'EHAM', destName: 'A', departureSimTime: 0, lat: 51, lon: 9, queue: [] },
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'EHAM', destName: 'A', departureSimTime: 0, lat: 51, lon: 9, health: 100, repairUntilSimTime: null, queue: [] },
],
}),
AIRPORTS,
@@ -118,7 +118,7 @@ describe('routesGeoJson (v0.12.0 — one line per flying plane\'s current leg)',
const fc = routesGeoJson(
snapshot({
planes: [
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'ZZZZ', locationName: '?', destIcao: 'EDDV', destName: 'H', departureSimTime: 0, lat: 0, lon: 0, queue: [] },
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'ZZZZ', locationName: '?', destIcao: 'EDDV', destName: 'H', departureSimTime: 0, lat: 0, lon: 0, health: 100, repairUntilSimTime: null, queue: [] },
],
}),
AIRPORTS,
@@ -130,8 +130,8 @@ describe('routesGeoJson (v0.12.0 — one line per flying plane\'s current leg)',
const fc = routesGeoJson(
snapshot({
planes: [
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'EDDV', destName: 'H', departureSimTime: 0, lat: 51, lon: 9, queue: [] },
{ id: 'p2', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDV', locationName: 'H', destIcao: 'EHAM', destName: 'A', departureSimTime: 0, lat: 52.4, lon: 9.6, queue: [] },
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'EDDV', destName: 'H', departureSimTime: 0, lat: 51, lon: 9, health: 100, repairUntilSimTime: null, queue: [] },
{ id: 'p2', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDV', locationName: 'H', destIcao: 'EHAM', destName: 'A', departureSimTime: 0, lat: 52.4, lon: 9.6, health: 100, repairUntilSimTime: null, queue: [] },
],
}),
AIRPORTS,
@@ -144,7 +144,7 @@ describe('queuedRoutesGeoJson (ISS-043 v0.26.0 — dotted queue line: dest → q
it('draws nothing for a grounded plane, even with a non-empty queue', () => {
const fc = queuedRoutesGeoJson(
[
{ id: 'p1', model: 'atr72', status: 'available', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: null, destName: null, departureSimTime: null, lat: 50.03, lon: 8.56, queue: ['EHAM'] },
{ id: 'p1', model: 'atr72', status: 'available', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: null, destName: null, departureSimTime: null, lat: 50.03, lon: 8.56, health: 100, repairUntilSimTime: null, queue: ['EHAM'] },
],
AIRPORTS,
);
@@ -154,7 +154,7 @@ describe('queuedRoutesGeoJson (ISS-043 v0.26.0 — dotted queue line: dest → q
it('draws nothing for a flying plane with an empty queue', () => {
const fc = queuedRoutesGeoJson(
[
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'EHAM', destName: 'A', departureSimTime: 0, lat: 51, lon: 9, queue: [] },
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'EHAM', destName: 'A', departureSimTime: 0, lat: 51, lon: 9, health: 100, repairUntilSimTime: null, queue: [] },
],
AIRPORTS,
);
@@ -165,7 +165,7 @@ describe('queuedRoutesGeoJson (ISS-043 v0.26.0 — dotted queue line: dest → q
const fc = queuedRoutesGeoJson(
[
// Active leg EDDF→EHAM stays in routesGeoJson; the queue line is ONLY EHAM → EDDV → LEMD.
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'EHAM', destName: 'A', departureSimTime: 0, lat: 51, lon: 9, queue: ['EDDV', 'LEMD'] },
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'EHAM', destName: 'A', departureSimTime: 0, lat: 51, lon: 9, health: 100, repairUntilSimTime: null, queue: ['EDDV', 'LEMD'] },
],
AIRPORTS,
);
@@ -182,8 +182,8 @@ describe('queuedRoutesGeoJson (ISS-043 v0.26.0 — dotted queue line: dest → q
it('skips a plane whose dest or a queued ICAO is missing from the airport index', () => {
const fc = queuedRoutesGeoJson(
[
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'ZZZZ', destName: '?', departureSimTime: 0, lat: 51, lon: 9, queue: ['EHAM'] },
{ id: 'p2', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'EHAM', destName: 'A', departureSimTime: 0, lat: 51, lon: 9, queue: ['ZZZZ'] },
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'ZZZZ', destName: '?', departureSimTime: 0, lat: 51, lon: 9, health: 100, repairUntilSimTime: null, queue: ['EHAM'] },
{ id: 'p2', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'EHAM', destName: 'A', departureSimTime: 0, lat: 51, lon: 9, health: 100, repairUntilSimTime: null, queue: ['ZZZZ'] },
],
AIRPORTS,
);
@@ -193,9 +193,9 @@ describe('queuedRoutesGeoJson (ISS-043 v0.26.0 — dotted queue line: dest → q
it('one feature per qualifying plane — grounded and empty-queue planes draw nothing', () => {
const fc = queuedRoutesGeoJson(
[
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'EHAM', destName: 'A', departureSimTime: 0, lat: 51, lon: 9, queue: ['LEMD'] },
{ id: 'p2', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EHAM', locationName: 'A', destIcao: 'EDDV', destName: 'H', departureSimTime: 0, lat: 52.3, lon: 4.7, queue: [] },
{ id: 'p3', model: 'atr72', status: 'available', fuel: 1, maxFuel: 2, locationIcao: 'EDDV', locationName: 'H', destIcao: null, destName: null, departureSimTime: null, lat: 52.46, lon: 9.68, queue: ['LEMD'] },
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'EHAM', destName: 'A', departureSimTime: 0, lat: 51, lon: 9, health: 100, repairUntilSimTime: null, queue: ['LEMD'] },
{ id: 'p2', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EHAM', locationName: 'A', destIcao: 'EDDV', destName: 'H', departureSimTime: 0, lat: 52.3, lon: 4.7, health: 100, repairUntilSimTime: null, queue: [] },
{ id: 'p3', model: 'atr72', status: 'available', fuel: 1, maxFuel: 2, locationIcao: 'EDDV', locationName: 'H', destIcao: null, destName: null, departureSimTime: null, lat: 52.46, lon: 9.68, health: 100, repairUntilSimTime: null, queue: ['LEMD'] },
],
AIRPORTS,
);
+2 -1
View File
@@ -10,7 +10,8 @@ import { positionAt } from './sim/movement';
export interface PlaneFeatureProps {
id: string;
modelLabel: string;
status: 'available' | 'flying';
// v0.39.0 (ISS-069): 'repairing' planes render on the grounded layer (flying === false).
status: 'available' | 'flying' | 'repairing';
fuelText: string; // "1234 / 6300 L"
routeText: string; // "Origin → Destination" while flying, '' when grounded
flying: boolean;
+11 -3
View File
@@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest';
import { openApiSpec } from './openapi';
describe('OpenAPI spec', () => {
it('documents exactly the thirteen public endpoints (4 read + 9 player actions)', () => {
it('documents exactly the fourteen public endpoints (4 read + 10 player actions)', () => {
expect(Object.keys(openApiSpec.paths).sort()).toEqual([
'/api/airports',
'/api/market/buy-plane',
@@ -15,6 +15,7 @@ describe('OpenAPI spec', () => {
'/api/planes/{planeId}/queue', // v0.25.0 (ISS-043)
'/api/planes/{planeId}/queue/cancel', // v0.25.0 (ISS-043)
'/api/planes/{planeId}/refuel',
'/api/planes/{planeId}/repair', // v0.39.0 (ISS-069)
'/api/sim',
'/api/state',
'/api/version',
@@ -115,6 +116,7 @@ describe('OpenAPI spec', () => {
'destIcao',
'destName',
'fuel',
'health', // v0.39.0 (ISS-069) — structural health in [0, 100]
'id',
'lat',
'locationIcao',
@@ -123,6 +125,7 @@ describe('OpenAPI spec', () => {
'maxFuel',
'model',
'queue', // v0.25.0 (ISS-043) — upcoming legs not yet started
'repairUntilSimTime', // v0.39.0 (ISS-069) — active repair gate; null when not repairing
'status',
]);
@@ -139,6 +142,7 @@ describe('OpenAPI spec', () => {
'originName',
'planeId',
'reward',
'special', // v0.37.0 (ISS-073) — special status from the specials registry
'status',
'weightKg', // v0.27.0 (ISS-045) — package weight in kg
]);
@@ -173,7 +177,7 @@ describe('OpenAPI spec', () => {
expect(Object.keys(error)).toEqual(['error']);
});
it('documents the eight action endpoints: snapshot on 200, Error on their 4xx paths', () => {
it('documents the nine action endpoints: snapshot on 200, Error on their 4xx paths', () => {
// The spec object is a literal type; these helpers view it as plain JSON.
const post = (p: string) =>
((openApiSpec.paths as Record<string, { post?: unknown }>)[p]?.post ?? {}) as {
@@ -192,6 +196,7 @@ describe('OpenAPI spec', () => {
'/api/market/buy-plane',
'/api/market/sell-plane',
'/api/planes/{planeId}/refuel',
'/api/planes/{planeId}/repair', // v0.39.0 (ISS-069)
]) {
const ok = post(p).responses['200'] as { content: Record<string, { schema: { $ref: string } }> };
expect(ok.content['application/json'].schema.$ref).toBe('#/components/schemas/StateSnapshot');
@@ -202,11 +207,12 @@ describe('OpenAPI spec', () => {
expect(errorCodes('/api/packages/{id}/load')).toEqual(['400', '404', '409']);
expect(errorCodes('/api/packages/{id}/abandon')).toEqual(['404', '409']);
expect(errorCodes('/api/planes/{planeId}/fly')).toEqual(['400', '404', '409']);
expect(errorCodes('/api/planes/{planeId}/queue')).toEqual(['400', '404']); // v0.25.0 — no 409: queueing works grounded or flying
expect(errorCodes('/api/planes/{planeId}/queue')).toEqual(['400', '404', '409']); // v0.34.0 (ISS-065) — 409 on duplicate leg / active-leg re-queue
expect(errorCodes('/api/planes/{planeId}/queue/cancel')).toEqual(['400', '404']); // v0.25.0
expect(errorCodes('/api/market/buy-plane')).toEqual(['400', '409']);
expect(errorCodes('/api/market/sell-plane')).toEqual(['400', '404', '409']);
expect(errorCodes('/api/planes/{planeId}/refuel')).toEqual(['400', '404', '409']); // v0.16.0 optional body
expect(errorCodes('/api/planes/{planeId}/repair')).toEqual(['404', '409']); // v0.39.0 (ISS-069) — no body, so no 400
// Request bodies: load/fly/buy/sell take their dedicated schemas; abandon takes none;
// refuel's is OPTIONAL (v0.16.0) — no body still means "fill to full".
@@ -232,6 +238,7 @@ describe('OpenAPI spec', () => {
const refuelBody = post('/api/planes/{planeId}/refuel').requestBody;
expect(refuelBody?.content['application/json'].schema.$ref).toBe('#/components/schemas/RefuelRequest');
expect(refuelBody?.required).toBe(false); // optional — no body still means "fill to full"
expect(post('/api/planes/{planeId}/repair').requestBody).toBeUndefined(); // v0.39.0 (ISS-069) — no body, like abandon
// Path parameters are declared on the parameterized routes.
const paramNames = (p: string) => post(p).parameters?.map((x) => x.name);
@@ -241,6 +248,7 @@ describe('OpenAPI spec', () => {
expect(paramNames('/api/planes/{planeId}/queue')).toEqual(['planeId']); // v0.25.0 (ISS-043)
expect(paramNames('/api/planes/{planeId}/queue/cancel')).toEqual(['planeId']); // v0.25.0 (ISS-043)
expect(paramNames('/api/planes/{planeId}/refuel')).toEqual(['planeId']);
expect(paramNames('/api/planes/{planeId}/repair')).toEqual(['planeId']); // v0.39.0 (ISS-069)
// Request schema shapes.
const load = openApiSpec.components.schemas.LoadPackageRequest;
+51 -8
View File
@@ -260,6 +260,11 @@ export const openApiSpec = {
description: 'Unknown plane id',
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
},
409: {
description:
'The destination is already in the flight queue, or (while flying) it equals the active leg\'s destination (v0.34.0, ISS-065)',
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
},
},
},
},
@@ -301,7 +306,7 @@ export const openApiSpec = {
post: {
summary: 'Buy a plane from the market',
description:
'Buy an aircraft at its full catalog price (v0.8.0). The new plane arrives "available" with a full tank at the home base (EDDF). Only models in the in-code catalog are known.',
'Buy an aircraft at its catalog price minus a reputation-scaled discount (v0.38.0, ISS-072 — higher reputation buys cheaper; no discount at the starting reputation). The new plane arrives "available" with a full tank at the home base (EDDF). Only models in the in-code catalog are known.',
operationId: 'buyPlane',
requestBody: {
required: true,
@@ -365,7 +370,7 @@ export const openApiSpec = {
post: {
summary: 'Refuel a grounded plane',
description:
'Refuel a grounded plane at the fuel price per added liter, deducted from cash (v0.8.0). The body is optional (v0.16.0): no body or mode "full" fills to maxFuel; mode "forFlight" with packageId adds just enough for that loaded package\'s destination leg — liters = required fuel for the great-circle distance (weight-proportional burn model) minus what is already in the tank.',
'Refuel a grounded plane at the fuel price per added liter, deducted from cash (v0.8.0); the per-liter price is reduced by a reputation-scaled discount (v0.38.0, ISS-072 — higher reputation refuels cheaper; no discount at the starting reputation). The body is optional (v0.16.0): no body or mode "full" fills to maxFuel; mode "forFlight" with packageId buys 150% of the weather-adjusted fuel required for that loaded package\'s destination leg, capped at tank capacity — liters = min(maxFuel, max(current fuel, 1.5 × required)) − current fuel, where required is the weight-proportional burn model over the great-circle distance with a ±20% wind adjustment (v0.36.0).',
operationId: 'refuelPlane',
parameters: [
{ name: 'planeId', in: 'path', required: true, schema: { type: 'string' } },
@@ -399,6 +404,31 @@ export const openApiSpec = {
},
},
},
'/api/planes/{planeId}/repair': {
post: {
summary: 'Repair a grounded plane',
description:
'Restore a worn-out plane to full health for the flat repair cost (v0.39.0, ISS-069): the plane is grounded in "repairing" status until 3 sim days pass, when the engine\'s tick flips it back to "available". While repairing it cannot load, fly, refuel or be sold; a broken plane (health ≤ 0) still flies but burns 50% more fuel. A full-health repair is a free no-op (cost 0). No body.',
operationId: 'repairPlane',
parameters: [
{ name: 'planeId', in: 'path', required: true, schema: { type: 'string' } },
],
responses: {
200: {
description: 'Repair started (or free no-op) — full state snapshot (health 100, status "repairing" with the gate set, cash reduced by the flat cost)',
content: { 'application/json': { schema: { $ref: '#/components/schemas/StateSnapshot' } } },
},
404: {
description: 'Unknown plane id',
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
},
409: {
description: 'Plane is not grounded (flying or already repairing), or cash is insufficient for the repair cost',
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
},
},
},
},
},
components: {
schemas: {
@@ -471,7 +501,7 @@ export const openApiSpec = {
description: 'Aircraft catalog (v0.10.0) — static per build, one entry per plane type.',
items: { $ref: '#/components/schemas/CatalogEntry' },
},
fuelPricePerL: { type: 'number', description: 'Jet-A1 price in €/liter; refuel cost = (maxFuel − fuel) × this.' },
fuelPricePerL: { type: 'number', description: 'Jet-A1 price in €/liter; refuel cost = (maxFuel − fuel) × this, reduced by a reputation-scaled discount at refuel time (v0.38.0).' },
sellFraction: { type: 'number', description: 'Fraction of the catalog price paid out when selling a plane.' },
},
required: ['account', 'simState', 'planes', 'packages', 'catalog', 'fuelPricePerL', 'sellFraction'],
@@ -481,9 +511,9 @@ export const openApiSpec = {
properties: {
model: { type: 'string', description: 'Catalog model id (e.g. "atr72") — used by buy/fly.' },
label: { type: 'string', description: 'Display name (e.g. "ATR 72-600").' },
price: { type: 'number', description: 'Market price in € (buy cost; sell pays out sellFraction × this).' },
price: { type: 'number', description: 'Market price in € — the full catalog list price. Buying costs this minus a reputation-scaled discount at purchase time (v0.38.0); selling pays out sellFraction × this.' },
maxFuel: { type: 'number', description: 'Fuel capacity, liters.' },
burnPerKm: { type: 'number', description: 'Cruise fuel burn at a full tank, liters/km (v0.16.0 weighted model calibrates to this).' },
burnPerKm: { type: 'number', description: 'Cruise fuel burn at a full tank, liters/km (v0.16.0 weighted model calibrates to this). Actual per-leg burn varies ±20% with wind — departure weather vs route heading (v0.36.0).' },
emptyWeightKg: { type: 'number', description: 'Operating empty weight, kg — with burnPerKm and maxFuel it drives the v0.16.0 weight-proportional burn model.' },
speedKmh: { type: 'number', description: 'Cruise speed, km/h.' },
maxPayloadKg: { type: 'number', description: 'Max payload in kg — total package weight that can be loaded on one plane (v0.27.0, replaces the v0.12.0 slot count).' },
@@ -494,7 +524,7 @@ export const openApiSpec = {
type: 'object',
properties: {
cash: { type: 'number' },
reputation: { type: 'number' },
reputation: { type: 'number', description: 'Reputation — grows with delivered packages; drives the purchase/fuel discounts and job reward quality (v0.38.0, ISS-072).' },
},
required: ['cash', 'reputation'],
},
@@ -570,7 +600,7 @@ export const openApiSpec = {
mode: {
enum: ['full', 'forFlight'],
description:
'"full" (default) fills the tank to maxFuel; "forFlight" adds just enough fuel for one loaded package\'s destination leg.',
'"full" (default) fills the tank to maxFuel; "forFlight" buys 150% of the weather-adjusted fuel required for one loaded package\'s destination leg, capped at tank capacity (v0.36.0).',
},
packageId: {
type: 'string',
@@ -590,7 +620,7 @@ export const openApiSpec = {
properties: {
id: { type: 'string' },
model: { type: 'string' },
status: { enum: ['available', 'flying'] },
status: { enum: ['available', 'flying', 'repairing'] }, // v0.39.0 (ISS-069) — "repairing" = grounded in the shop until repairUntilSimTime passes
fuel: { type: 'number' },
maxFuel: { type: 'number' },
locationIcao: { type: 'string' },
@@ -615,6 +645,16 @@ export const openApiSpec = {
description:
'Upcoming flight legs NOT yet started (v0.25.0) — ordered ICAOs; the active leg is destIcao. The engine auto-chains queue[0] on landing when fuel covers it.',
},
health: {
type: 'number',
description:
'Structural health in [0, 100] (v0.39.0) — wears down by a fixed amount per km of landed legs; at 0 the plane is broken and burns 50% more fuel until repaired.',
},
repairUntilSimTime: {
type: ['integer', 'null'],
description:
'Sim-clock epoch ms when an active repair finishes (v0.39.0) — set with status "repairing", cleared by the engine\'s flip-back; null when not in repair.',
},
},
required: [
'id',
@@ -630,6 +670,8 @@ export const openApiSpec = {
'lat',
'lon',
'queue',
'health', // v0.39.0 (ISS-069) — structural health in [0, 100]
'repairUntilSimTime', // v0.39.0 (ISS-069) — active repair gate; null when not repairing
],
},
PackageState: {
@@ -644,6 +686,7 @@ export const openApiSpec = {
cargoType: { type: 'string' },
cargoCount: { type: 'integer' },
weightKg: { type: 'number', description: 'Package weight in kg (v0.27.0) — drives load validation and the per-kg payout part.' },
special: { type: ['string', 'null'], description: 'Special status from the specials registry (v0.37.0); null = none.' },
deadlineSimTime: { type: 'integer', description: 'Sim-clock epoch ms (game time)' },
// 'expired' = an open OR loaded package whose deadline passed on a tick;
// loaded expired packages keep their slot, deliver no reward, abandon free.
+158 -18
View File
@@ -6,14 +6,17 @@ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { openGameDb } from '@/lib/db/client';
import * as schema from '@/lib/db/schema';
import { loadAirportsGeoJson } from '@/lib/airports';
import { haversineKm } from '@/lib/geo';
import { PLANE_TYPES } from '@/lib/game/catalog';
import { haversineKm, initialBearingDeg } from '@/lib/geo';
import { weatherFor } from '@/lib/weather';
import { BROKEN_BURN_MULTIPLIER, PLANE_TYPES, WEAR_PER_KM } from '@/lib/game/catalog';
import { SPECIALS } from '@/lib/game/specials';
import { MARKET_TARGET_OPEN_PACKAGES, mulberry32 } from '@/lib/game/seed';
import { arrivalTimeFor, fuelAtWeighted, requiredFuelFor } from './movement';
import { arrivalTimeFor, fuelAtWeighted, requiredFuelFor, windBurnFactor } from './movement';
import {
clampTimeScale,
deliverPackages,
expirePackages,
finishRepairs,
MAX_TICK_ELAPSED_MS,
MAX_TIME_SCALE,
MIN_TIME_SCALE,
@@ -165,7 +168,7 @@ describe('deliverPackages (v0.12.0 arrival delivery)', () => {
});
it('delivers in-deadline cargo, unbinds expired cargo for free, keeps other-dest cargo bound', async () => {
const { inst, to, km, type } = await setupFlight('deliver-mixed.db');
const { inst, from, to, km, type } = await setupFlight('deliver-mixed.db');
const accountBefore = (await inst.db.select().from(schema.account))[0]!;
// Three packages on the plane: one in-deadline for TO, one expired for TO,
@@ -184,8 +187,11 @@ describe('deliverPackages (v0.12.0 arrival delivery)', () => {
expect(plane.locationIcao).toBe(TO);
expect(plane.lat).toBe(to.lat);
expect(plane.lon).toBe(to.lon);
// Remaining fuel per the v0.16.0 weight-proportional burn model (closed form).
expect(plane.fuel).toBeCloseTo(fuelAtWeighted(type.maxFuel, type.burnPerKm, type.emptyWeightKg, type.maxFuel, km, 1), 6);
// Remaining fuel per the v0.16.0 weight-proportional burn model (closed form), wind-adjusted
// (v0.36.0) by the departure airport's weather vs the route heading.
const w = weatherFor(FROM);
const f = windBurnFactor(w.windKt, w.windDirDeg, initialBearingDeg(from.lat, from.lon, to.lat, to.lon));
expect(plane.fuel).toBeCloseTo(fuelAtWeighted(type.maxFuel, type.burnPerKm * f, type.emptyWeightKg, type.maxFuel, km, 1), 6);
expect(plane.departureSimTime).toBeNull();
expect(plane.destIcao).toBeNull(); // flight context cleared on landing
@@ -230,6 +236,26 @@ describe('deliverPackages (v0.12.0 arrival delivery)', () => {
); // rep per delivered package
});
it('adds the registry rep bonus for a special package at delivery (v0.37.0)', async () => {
const { inst, km } = await setupFlight('deliver-special.db');
const accountBefore = (await inst.db.select().from(schema.account))[0]!;
await inst.db.insert(schema.packages).values([
{ id: 'p-prio', originIcao: FROM, destIcao: TO, reward: 5_000, cargoType: 'Mail', cargoCount: 10, special: 'priority', deadlineSimTime: T0 + 24 * 3_600_000, status: 'loaded', planeId: 'plane-starter', createdSimTime: T0 },
]);
const clock = T0 + travelMs(km, PLANE_TYPES.atr72.speedKmh); // exactly at arrival
expect(await deliverPackages(inst, clock)).toBe(1);
const account = (await inst.db.select().from(schema.account))[0]!;
expect(account.cash).toBeCloseTo(accountBefore.cash + 5_000, 9);
// Distance-based gain + the registry rep bonus — double-rounded like every other award.
expect(account.reputation).toBeCloseTo(
Math.round((accountBefore.reputation + reputationGainFor(km) + SPECIALS.priority.repBonus) * 100) / 100,
9,
);
});
it('leaves planes without route info alone', async () => {
const inst = await freshDb('deliver-noroute.db');
// Flying plane whose dest_icao is missing — defensive skip, no crash.
@@ -241,14 +267,19 @@ describe('deliverPackages (v0.12.0 arrival delivery)', () => {
// --- Auto-chain on landing (v0.25.0, ISS-043) ---
it('auto-chains to the next queued leg when fuel covers it', async () => {
const { inst, to, km, type } = await setupFlight('chain-ok.db'); // full tank EDDF→LEMD
const { inst, from, to, km, type } = await setupFlight('chain-ok.db'); // full tank EDDF→LEMD
const fc = await loadAirportsGeoJson();
const nextP = fc.features.find((f) => f.properties.icao === 'EHAM')!.properties;
const nextKm = haversineKm(to.lat, to.lon, nextP.lat, nextP.lon);
// Precondition sanity: a full-tank departure leaves enough for the LEMD→EHAM hop.
const landedFuel = fuelAtWeighted(type.maxFuel, type.burnPerKm, type.emptyWeightKg, type.maxFuel, km, 1);
expect(requiredFuelFor(type.burnPerKm, type.emptyWeightKg, type.maxFuel, nextKm)).toBeLessThanOrEqual(landedFuel);
// Precondition sanity: a full-tank departure leaves enough for the LEMD→EHAM hop — both legs
// wind-adjusted (v0.36.0) exactly as deliverPackages computes them.
const wFrom = weatherFor(FROM);
const fOut = windBurnFactor(wFrom.windKt, wFrom.windDirDeg, initialBearingDeg(from.lat, from.lon, to.lat, to.lon));
const landedFuel = fuelAtWeighted(type.maxFuel, type.burnPerKm * fOut, type.emptyWeightKg, type.maxFuel, km, 1);
const wTo = weatherFor(TO); // the chained leg departs from the arrival airport
const fNext = windBurnFactor(wTo.windKt, wTo.windDirDeg, initialBearingDeg(to.lat, to.lon, nextP.lat, nextP.lon));
expect(requiredFuelFor(type.burnPerKm * fNext, type.emptyWeightKg, type.maxFuel, nextKm)).toBeLessThanOrEqual(landedFuel);
await inst.db.update(schema.planes)
.set({ queue: JSON.stringify(['EHAM', 'EDDV']) })
@@ -268,14 +299,14 @@ describe('deliverPackages (v0.12.0 arrival delivery)', () => {
});
it('grounds when fuel cannot cover the next queued leg, keeping the queue intact', async () => {
const { inst, km, type } = await setupFlight('chain-nofuel.db');
const fc = await loadAirportsGeoJson();
const nextP = fc.features.find((f) => f.properties.icao === 'EHAM')!.properties;
const { inst, from, to, km, type } = await setupFlight('chain-nofuel.db');
// Depart with exactly the fuel for EDDF→LEMD → lands empty, cannot start LEMD→EHAM.
// Depart with exactly the wind-adjusted fuel for EDDF→LEMD (v0.36.0) → lands empty, cannot start LEMD→EHAM.
const w = weatherFor(FROM);
const f = windBurnFactor(w.windKt, w.windDirDeg, initialBearingDeg(from.lat, from.lon, to.lat, to.lon));
await inst.db.update(schema.planes)
.set({
fuel: requiredFuelFor(type.burnPerKm, type.emptyWeightKg, type.maxFuel, km),
fuel: requiredFuelFor(type.burnPerKm * f, type.emptyWeightKg, type.maxFuel, km),
queue: JSON.stringify(['EHAM']),
})
.where(eq(schema.planes.id, 'plane-starter'));
@@ -301,6 +332,114 @@ describe('deliverPackages (v0.12.0 arrival delivery)', () => {
});
});
describe('wear on landed legs + broken burn (v0.39.0, ISS-069)', () => {
const FROM = 'EDDF';
const TO = 'LEMD';
// Starter flying EDDF→LEMD with a full tank — same shape as the deliverPackages setup above.
async function setupFlight(name: string) {
const inst = await freshDb(name);
const fc = await loadAirportsGeoJson();
const from = fc.features.find((f) => f.properties.icao === FROM)!.properties;
const to = fc.features.find((f) => f.properties.icao === TO)!.properties;
const km = haversineKm(from.lat, from.lon, to.lat, to.lon);
const type = PLANE_TYPES.atr72;
await inst.db.update(schema.planes)
.set({ status: 'flying', destIcao: TO, departureSimTime: T0, fuel: type.maxFuel })
.where(eq(schema.planes.id, 'plane-starter'));
return { inst, from, to, km, type };
}
it('drops health by exactly WEAR_PER_KM × km on a landed leg', async () => {
const { inst, km } = await setupFlight('wear-leg.db');
expect(await deliverPackages(inst, T0 + travelMs(km, PLANE_TYPES.atr72.speedKmh))).toBe(1);
const plane = (await inst.db.select().from(schema.planes))[0];
expect(plane.health).toBeCloseTo(100 - WEAR_PER_KM * km, 9); // one mid-range hop ≈ −9.8 health
});
it('clamps at 0 across multiple legs', async () => {
const { inst, from, to, km, type } = await setupFlight('wear-clamp.db');
// Start below one leg's wear so the first landing clamps…
await inst.db.update(schema.planes)
.set({ health: WEAR_PER_KM * km / 2 })
.where(eq(schema.planes.id, 'plane-starter'));
expect(await deliverPackages(inst, T0 + travelMs(km, type.speedKmh))).toBe(1);
let plane = (await inst.db.select().from(schema.planes))[0];
expect(plane.health).toBe(0); // clamped — never negative…
// …and a second leg keeps it pinned at 0.
const eham = (await loadAirportsGeoJson()).features.find((f) => f.properties.icao === 'EHAM')!.properties;
const nextKm = haversineKm(to.lat, to.lon, eham.lat, eham.lon);
await inst.db.update(schema.planes)
.set({ status: 'flying', destIcao: 'EHAM', departureSimTime: T0 + travelMs(km, type.speedKmh), fuel: type.maxFuel })
.where(eq(schema.planes.id, 'plane-starter'));
expect(await deliverPackages(inst, T0 + travelMs(km, type.speedKmh) + travelMs(nextKm, type.speedKmh))).toBe(1);
plane = (await inst.db.select().from(schema.planes))[0];
expect(plane.health).toBe(0); // still clamped after the second leg
});
it('a broken plane burns ×1.5 more fuel on the leg (landed fuel)', async () => {
const { inst, from, to, km, type } = await setupFlight('wear-broken.db');
await inst.db.update(schema.planes).set({ health: 0 }).where(eq(schema.planes.id, 'plane-starter'));
expect(await deliverPackages(inst, T0 + travelMs(km, type.speedKmh))).toBe(1);
const w = weatherFor(FROM);
const f = windBurnFactor(w.windKt, w.windDirDeg, initialBearingDeg(from.lat, from.lon, to.lat, to.lon));
const plane = (await inst.db.select().from(schema.planes))[0];
// The ×1.5-adjusted closed form — the same factor livePlane() shows in flight…
expect(plane.fuel).toBeCloseTo(
fuelAtWeighted(type.maxFuel, type.burnPerKm * f * BROKEN_BURN_MULTIPLIER, type.emptyWeightKg, type.maxFuel, km, 1),
6,
);
// …and strictly less than the healthy control on the identical leg.
const control = await setupFlight('wear-broken-control.db');
expect(await deliverPackages(control.inst, T0 + travelMs(km, type.speedKmh))).toBe(1);
const healthy = (await control.inst.db.select().from(schema.planes))[0];
expect(healthy.fuel).toBeCloseTo(fuelAtWeighted(type.maxFuel, type.burnPerKm * f, type.emptyWeightKg, type.maxFuel, km, 1), 6);
expect(plane.fuel).toBeLessThan(healthy.fuel); // the broken plane genuinely burned more
});
});
describe('finishRepairs (v0.39.0, ISS-069)', () => {
it('flips repairing planes back to available once the gate passes, clearing the gate', async () => {
const inst = await freshDb('repair-finish.db');
await inst.db.update(schema.planes)
.set({ status: 'repairing', repairUntilSimTime: T0 + 5_000 })
.where(eq(schema.planes.id, 'plane-starter'));
expect(await finishRepairs(inst, T0 + 4_999)).toBe(0); // gate not reached yet…
let plane = (await inst.db.select().from(schema.planes))[0];
expect(plane.status).toBe('repairing');
expect(await finishRepairs(inst, T0 + 5_000)).toBe(1); // <= is inclusive at the clock
plane = (await inst.db.select().from(schema.planes))[0];
expect(plane.status).toBe('available');
expect(plane.repairUntilSimTime ?? null).toBeNull(); // gate cleared
});
it('runs inside tickOnce even when nothing landed: a past gate flips the plane back', async () => {
const inst = await freshDb('repair-tick.db');
await inst.db.update(schema.planes)
.set({ status: 'repairing', repairUntilSimTime: T0 + 1_500 })
.where(eq(schema.planes.id, 'plane-starter'));
vi.setSystemTime(T0 + 2_000); // one clamped tick at scale 1 → clock T0+2000 ≥ gate; nothing is flying
await tickOnce(inst);
const plane = (await inst.db.select().from(schema.planes))[0];
expect(plane.status).toBe('available');
expect(plane.repairUntilSimTime ?? null).toBeNull();
});
});
describe('slow to 1× when a landing leaves any plane on the ground (ISS-057)', () => {
const FROM = 'EDDF';
const TO = 'LEMD';
@@ -424,9 +563,10 @@ describe('spawnOpenPackages', () => {
expect(icaoSet.has(p.destIcao)).toBe(true);
expect(p.originIcao).not.toBe(p.destIcao);
expect(p.planeId).toBeNull();
// Same deadline window as the seed: now + 6–72h game time.
expect(p.deadlineSimTime).toBeGreaterThan(clock + 6 * 3_600_000 - 1);
expect(p.deadlineSimTime).toBeLessThanOrEqual(clock + 72 * 3_600_000);
// Same deadline window as the seed: now + 6–72h game time — scaled by any special status (v0.37.0).
const scale = p.special ? SPECIALS[p.special]?.deadlineScale ?? 1 : 1;
expect(p.deadlineSimTime).toBeGreaterThan(clock + 6 * 3_600_000 * scale - 1);
expect(p.deadlineSimTime).toBeLessThanOrEqual(clock + 72 * 3_600_000 * scale);
}
// Already at target → nothing more to spawn.
+72 -11
View File
@@ -3,8 +3,10 @@ import { eq, inArray } from 'drizzle-orm';
import { getGameDb, persist, type GameDb } from '@/lib/db/client';
import * as schema from '@/lib/db/schema';
import { loadAirportsGeoJson } from '@/lib/airports';
import { haversineKm } from '@/lib/geo';
import { PLANE_TYPES } from '@/lib/game/catalog';
import { haversineKm, initialBearingDeg } from '@/lib/geo';
import { weatherFor } from '@/lib/weather';
import { conditionBurnMultiplier, PLANE_TYPES, WEAR_PER_KM } from '@/lib/game/catalog';
import { SPECIALS } from '@/lib/game/specials';
import { parseIcaoQueue } from '@/lib/game/state';
import {
generatePackage,
@@ -13,7 +15,7 @@ import {
type Rng,
type SeedAirport,
} from '@/lib/game/seed';
import { arrivalTimeFor, fuelAtWeighted, requiredFuelFor } from './movement';
import { arrivalTimeFor, fuelAtWeighted, requiredFuelFor, windBurnFactor } from './movement';
// Server-authoritative sim engine (v0.7.0) — tick-on-read: there is NO background
// timer. Every read/action endpoint calls tickNow() first; the clock advances by
@@ -77,6 +79,9 @@ export async function tickOnce(inst: GameDb): Promise<schema.SimStateRow> {
await expirePackages(inst, clockMs);
// ISS-057: remember the landings so the tick can slow down when a plane is left on ground.
landedThisTick = await deliverPackages(inst, clockMs);
// v0.39.0 (ISS-069): repairs whose gate has passed flip back to 'available' — runs even when
// nothing landed this tick; BEFORE the spawns so a just-repaired plane gets coverage work too.
await finishRepairs(inst, clockMs);
await spawnOpenPackages(inst, clockMs);
// AFTER delivery on purpose: a plane that just landed gets work spawned
// at its new location within the same tick.
@@ -124,6 +129,23 @@ export async function expirePackages(inst: GameDb, clockMs: number): Promise<num
return n;
}
// Repairs whose gate has passed flip the plane back to 'available' and clear the gate
// (v0.39.0, ISS-069) — same shape as expirePackages: one guarded pass over the rows,
// `<=` inclusive at the clock. Returns the count flipped.
export async function finishRepairs(inst: GameDb, clockMs: number): Promise<number> {
const rows = await inst.db.select().from(schema.planes).where(eq(schema.planes.status, 'repairing'));
let n = 0;
for (const p of rows) {
if (p.repairUntilSimTime !== null && p.repairUntilSimTime <= clockMs) {
await inst.db.update(schema.planes)
.set({ status: 'available', repairUntilSimTime: null })
.where(eq(schema.planes.id, p.id));
n++;
}
}
return n;
}
// Finalize in-flight planes whose arrival time has passed (v0.12.0 delivery):
// for every package bound to the plane and destined for its dest_icao — 'loaded'
// ones still inside their deadline are delivered (cash += reward + reputation
@@ -156,8 +178,22 @@ export async function deliverPackages(inst: GameDb, clockMs: number): Promise<nu
const distanceKm = haversineKm(from.lat, from.lon, to.lat, to.lon);
if (clockMs < arrivalTimeFor(p.departureSimTime, distanceKm, type.speedKmh)) continue;
// Arrived: progress is exactly 1 here, so remaining fuel is the full-leg weighted burn.
const landedFuel = fuelAtWeighted(p.fuel, type.burnPerKm, type.emptyWeightKg, type.maxFuel, distanceKm, 1);
// 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.
// 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 landedFuel = fuelAtWeighted(
p.fuel,
type.burnPerKm * windBurnFactor(w.windKt, w.windDirDeg, initialBearingDeg(from.lat, from.lon, to.lat, to.lon)) * conditionBurnMultiplier(p.health),
type.emptyWeightKg,
type.maxFuel,
distanceKm,
1,
);
// Wear (v0.39.0, ISS-069): health drops by WEAR_PER_KM per km of this landed leg, clamped at 0.
const newHealth = Math.max(0, p.health - WEAR_PER_KM * distanceKm);
// Deliver every bound package whose destination is the airport just reached —
// runs whether or not a queued leg chains next (ISS-043).
@@ -168,9 +204,12 @@ export async function deliverPackages(inst: GameDb, clockMs: number): Promise<nu
// Fresh account read per award so multiple deliveries in one tick all count.
const fresh = (await inst.db.select().from(schema.account))[0];
if (!fresh) throw new Error('account row missing');
// v0.37.0 (ISS-073): special packages add the registry's flat rep bonus on top of the
// distance-based gain — same double-rounding convention as before (gain rounded to cents, total re-rounded).
const repBonus = pk.special ? SPECIALS[pk.special]?.repBonus ?? 0 : 0;
await inst.db.update(schema.account).set({
cash: fresh.cash + pk.reward,
reputation: Math.round((fresh.reputation + reputationGainFor(distanceKm)) * 100) / 100,
reputation: Math.round((fresh.reputation + reputationGainFor(distanceKm) + repBonus) * 100) / 100,
});
} else if (pk.status === 'expired') {
// Expired cargo keeps no value — just free the slot.
@@ -188,9 +227,21 @@ export async function deliverPackages(inst: GameDb, clockMs: number): Promise<nu
if (queue.length > 0) {
const nextDest = queue[0];
const next = byIcao.get(nextDest); // unknown ICAO in the queue → cannot chain
if (next && requiredFuelFor(type.burnPerKm, type.emptyWeightKg, type.maxFuel, haversineKm(to.lat, to.lon, next.lat, next.lon)) <= landedFuel) {
queue.shift();
chainedTo = nextDest;
if (next) {
// Wind-adjusted requirement for the NEXT leg — it departs from `to` (v0.36.0). v0.39.0 (ISS-069):
// the chained leg starts AFTER this landing's wear, so its burn uses newHealth — exactly what
// livePlane()/the next landing will compute for that flight.
const wNext = weatherFor(to.icao);
const nextRequiredL = requiredFuelFor(
type.burnPerKm * windBurnFactor(wNext.windKt, wNext.windDirDeg, initialBearingDeg(to.lat, to.lon, next.lat, next.lon)) * conditionBurnMultiplier(newHealth),
type.emptyWeightKg,
type.maxFuel,
haversineKm(to.lat, to.lon, next.lat, next.lon),
);
if (nextRequiredL <= landedFuel) {
queue.shift();
chainedTo = nextDest;
}
}
}
await inst.db.update(schema.planes)
@@ -202,6 +253,7 @@ export async function deliverPackages(inst: GameDb, clockMs: number): Promise<nu
lat: to.lat,
lon: to.lon,
fuel: landedFuel,
health: newHealth, // v0.39.0 (ISS-069) — this leg's wear persists into the chained flight
destIcao: chainedTo,
departureSimTime: clockMs, // the arrival instant — position is exactly `to`
queue: JSON.stringify(queue),
@@ -212,6 +264,7 @@ export async function deliverPackages(inst: GameDb, clockMs: number): Promise<nu
lat: to.lat,
lon: to.lon,
fuel: landedFuel,
health: newHealth, // v0.39.0 (ISS-069) — this leg's wear persists on the grounded plane
departureSimTime: null,
destIcao: null,
queue: JSON.stringify(queue),
@@ -245,9 +298,14 @@ export async function spawnOpenPackages(
lon: f.properties.lon,
}));
const rand = rng ?? mulberry32(randomInt(0, 2 ** 31));
// v0.38.0 (ISS-072): spawned jobs carry the account's CURRENT reputation quality — read once per spawn batch.
const account = (await inst.db.select().from(schema.account))[0];
if (!account) throw new Error('account row missing');
for (let i = 0; i < needed; i++) {
await inst.db.insert(schema.packages).values(generatePackage({ id: randomUUID(), airports, rng: rand, nowMs: clockMs }));
await inst.db.insert(schema.packages).values(
generatePackage({ id: randomUUID(), airports, rng: rand, nowMs: clockMs, reputation: account.reputation }),
);
}
return needed;
}
@@ -289,12 +347,15 @@ export async function spawnCoveragePackages(
lon: f.properties.lon,
}));
const rand = rng ?? mulberry32(randomInt(0, 2 ** 31));
// v0.38.0 (ISS-072): same reputation quality as spawnOpenPackages — read once per batch.
const account = (await inst.db.select().from(schema.account))[0];
if (!account) throw new Error('account row missing');
let n = 0;
for (const icao of missing) {
if (!airports.some((a) => a.icao === icao)) continue; // defensive — the dataset is the source of all ICACs
await inst.db.insert(schema.packages).values(
generatePackage({ id: randomUUID(), airports, rng: rand, nowMs: clockMs, originIcao: icao }),
generatePackage({ id: randomUUID(), airports, rng: rand, nowMs: clockMs, originIcao: icao, reputation: account.reputation }),
);
n++;
}
+48
View File
@@ -10,6 +10,7 @@ import {
progressAt,
requiredFuelFor,
travelDurationMs,
windBurnFactor,
} from './movement';
// Synthetic in-flight fixtures — no DB needed: everything is a pure function of
@@ -166,6 +167,53 @@ describe('weighted burn model (v0.16.0)', () => {
});
});
describe('windBurnFactor (v0.36.0, ISS-070)', () => {
it('full headwind at max wind is exactly +20%', () => {
// bearing == windDirDeg: the wind blows straight from ahead.
expect(windBurnFactor(30, 90, 90)).toBe(1.2);
});
it('full tailwind at max wind is exactly −20%', () => {
// windDirDeg = bearing + 180 (mod 360): the wind blows from behind.
expect(windBurnFactor(30, 270, 90)).toBe(0.8);
});
it('a pure crosswind (90° off) leaves burn unchanged', () => {
expect(windBurnFactor(30, 0, 90)).toBeCloseTo(1, 9);
expect(windBurnFactor(30, 180, 90)).toBeCloseTo(1, 9);
});
it('small wind stays near 1', () => {
const f = windBurnFactor(2, 45, 45); // worst case: full headwind at min wind
expect(f).toBeGreaterThan(1.01);
expect(f).toBeLessThan(1.02);
});
it('stays within [0.8, 1.2] for the whole weather range', () => {
for (const kt of [2, 15, 30]) {
for (const off of [0, 45, 90, 135, 180, 270, 315]) {
const f = windBurnFactor(kt, 100, (100 + off) % 360);
expect(f).toBeGreaterThanOrEqual(0.8 - 1e-12);
expect(f).toBeLessThanOrEqual(1.2 + 1e-12);
}
}
});
it('applied as burnPerKm × f, requiredFuelFor and fuelAtWeighted scale accordingly (pinned)', () => {
// Fixed inputs: ATR numbers, 1000 km leg / half of the FRA→MAD leg.
expect(requiredFuelFor(BURN * 1.2, WE, TANK, 1_000)).toBeCloseTo(1444.911589, 6);
expect(requiredFuelFor(BURN * 0.8, WE, TANK, 1_000)).toBeCloseTo(944.348095, 6);
expect(fuelAtWeighted(TANK, BURN * 1.2, WE, TANK, KM, 0.5)).toBeCloseTo(4867.313136, 6);
expect(fuelAtWeighted(TANK, BURN * 0.8, WE, TANK, KM, 0.5)).toBeCloseTo(5331.677856, 6);
// Headwind always costs more than tailwind over the same leg…
expect(requiredFuelFor(BURN * 1.2, WE, TANK, KM)).toBeGreaterThan(requiredFuelFor(BURN * 0.8, WE, TANK, KM));
expect(fuelAtWeighted(TANK, BURN * 1.2, WE, TANK, KM, 1)).toBeLessThan(fuelAtWeighted(TANK, BURN * 0.8, WE, TANK, KM, 1));
// …and a neutral factor (f = 1) reproduces the unadjusted model exactly.
expect(requiredFuelFor(BURN * windBurnFactor(30, 0, 90), WE, TANK, KM)).toBeCloseTo(requiredFuelFor(BURN, WE, TANK, KM), 9);
});
});
describe('maxRangeKm (v0.28.0)', () => {
it('ATR 72-600 full tank ≈ 4439 km (±5%)', () => {
const r = maxRangeKm(BURN, WE, TANK);
+9
View File
@@ -119,6 +119,15 @@ export function requiredFuelFor(
return (emptyWeightKg / rho) * (Math.exp(k * rho * distanceKm) - 1);
}
// Wind adjustment to per-km burn (v0.36.0, ISS-070): the headwind component of a wind blowing
// FROM `windDirDeg` against a leg heading `bearingDeg`, as a multiplier on burnPerKm —
// factor = 1 + headwindKt/150, exactly [0.8, 1.2] over the weather range 2–30 kt (±20%).
// Pure; callers apply it as burnPerKm * factor so every call site agrees on one value per leg.
export function windBurnFactor(windKt: number, windDirDeg: number, bearingDeg: number): number {
const headwindKt = windKt * Math.cos(((bearingDeg - windDirDeg) * Math.PI) / 180);
return 1 + headwindKt / 150;
}
// Max range from a FULL tank under the weighted model — solve requiredFuelFor(d) = maxFuel for d:
// (We/ρ)(e^{kρd} − 1) = F → d = ln(1 + ρF/We) · (We + ρF)/(ρ·burnPerKm).
// Returns raw km (call sites round); zero fuel gives exactly 0. Pure — display-only, the sim
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "flight-ops",
"version": "0.31.0",
"version": "0.39.0",
"private": true,
"scripts": {
"dev": "next dev -p 3000",