Compare commits

..
Author SHA1 Message Date
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
25 changed files with 545 additions and 273 deletions
+15
View File
@@ -1,5 +1,20 @@
# Changelog # Changelog
## 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 ## 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 - **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 - **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
+16 -6
View File
@@ -6,8 +6,9 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import * as schema from '@/lib/db/schema'; import * as schema from '@/lib/db/schema';
import { getGameDb } from '@/lib/db/client'; import { getGameDb } from '@/lib/db/client';
import { loadAirportsGeoJson } from '@/lib/airports'; import { loadAirportsGeoJson } from '@/lib/airports';
import { haversineKm } from '@/lib/geo'; import { haversineKm, initialBearingDeg } from '@/lib/geo';
import { requiredFuelFor } from '@/lib/sim/movement'; import { weatherFor } from '@/lib/weather';
import { requiredFuelFor, windBurnFactor } from '@/lib/sim/movement';
import { FUEL_PRICE_PER_L, PLANE_TYPES } from '@/lib/game/catalog'; import { FUEL_PRICE_PER_L, PLANE_TYPES } from '@/lib/game/catalog';
import { HUB_ICAO } from '@/lib/game/seed'; import { HUB_ICAO } from '@/lib/game/seed';
import { POST } from './route'; import { POST } from './route';
@@ -124,7 +125,7 @@ describe('POST /api/planes/:planeId/refuel', () => {
expect(res.status).toBe(400); expect(res.status).toBe(400);
}); });
it('forFlight tops up exactly the liters needed for the loaded package leg', async () => { it('forFlight buys 150% of the weather-adjusted leg requirement', async () => {
const { db } = await getGameDb(); const { db } = await getGameDb();
await db.update(schema.account).set({ cash: 50_000 }); await db.update(schema.account).set({ cash: 50_000 });
// A loaded EDDF → LEMD package on the starter (direct row — loadPackage is covered in actions tests). // A loaded EDDF → LEMD package on the starter (direct row — loadPackage is covered in actions tests).
@@ -138,7 +139,14 @@ describe('POST /api/planes/:planeId/refuel', () => {
const to = fc.features.find((f) => f.properties.icao === 'LEMD')!.properties; const to = fc.features.find((f) => f.properties.icao === 'LEMD')!.properties;
const km = haversineKm(from.lat, from.lon, to.lat, to.lon); const km = haversineKm(from.lat, from.lon, to.lat, to.lon);
const type = PLANE_TYPES.atr72; const type = PLANE_TYPES.atr72;
const required = requiredFuelFor(type.burnPerKm, type.emptyWeightKg, type.maxFuel, km); // Same wind-adjusted requirement the server computes (v0.36.0).
const w = weatherFor(HUB_ICAO);
const required = requiredFuelFor(
type.burnPerKm * windBurnFactor(w.windKt, w.windDirDeg, initialBearingDeg(from.lat, from.lon, to.lat, to.lon)),
type.emptyWeightKg,
type.maxFuel,
km,
);
const startFuel = Math.round(required / 2); // partial tank so L > 0 const startFuel = Math.round(required / 2); // partial tank so L > 0
await db.update(schema.planes).set({ fuel: startFuel }).where(eq(schema.planes.id, 'plane-starter')); await db.update(schema.planes).set({ fuel: startFuel }).where(eq(schema.planes.id, 'plane-starter'));
@@ -151,9 +159,11 @@ describe('POST /api/planes/:planeId/refuel', () => {
account: { cash: number }; account: { cash: number };
planes: Array<{ id: string; fuel: number }>; planes: Array<{ id: string; fuel: number }>;
}; };
const cost = Math.round((required - startFuel) * FUEL_PRICE_PER_L * 100) / 100; // ISS-071: target is 150% of the requirement, capped at tank capacity.
const targetFuel = Math.min(type.maxFuel, Math.max(startFuel, required * 1.5));
const cost = Math.round((targetFuel - startFuel) * FUEL_PRICE_PER_L * 100) / 100;
expect(body.account.cash).toBeCloseTo(cashBefore - cost, 6); expect(body.account.cash).toBeCloseTo(cashBefore - cost, 6);
expect(body.planes.find((p) => p.id === 'plane-starter')!.fuel).toBeCloseTo(required, 6); expect(body.planes.find((p) => p.id === 'plane-starter')!.fuel).toBeCloseTo(targetFuel, 6);
// Clean up the test package. // Clean up the test package.
await db.delete(schema.packages).where(eq(schema.packages.id, 'p-ff-api')); await db.delete(schema.packages).where(eq(schema.packages.id, 'p-ff-api'));
+5 -12
View File
@@ -376,17 +376,10 @@ body {
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
} }
/* v0.12.0 — on-board blocks (per plane); moved to the left pane in ISS-021 (v0.20.0) */ /* ISS-054 (v0.33.0) — per-plane cargo rows nested inside the Fleet list's plane cards: two lines,
.onboard-block { a full-width fly-to button then refuel + abandon side by side below it; long destination names
background: rgba(30, 41, 59, 0.6); truncate with an ellipsis. Transparent sub-rows with a top border under the card header. */
border-radius: 6px; .plane-row .contract-row {
padding: 8px;
margin-bottom: 8px;
}
/* ISS-028 (v0.20.0 part 2) — on-board cargo rows are two lines: a full-width fly-to button,
then refuel + abandon side by side below it; long destination names truncate with an ellipsis. */
.onboard-block .contract-row {
background: transparent; background: transparent;
padding: 6px 0 0; padding: 6px 0 0;
border-top: 1px solid rgba(148, 163, 184, 0.2); border-top: 1px solid rgba(148, 163, 184, 0.2);
@@ -395,7 +388,7 @@ body {
gap: 5px; gap: 5px;
} }
.onboard-block .contract-row .contract-sub { .plane-row .contract-row .contract-sub {
margin-top: 0; /* row spacing comes from the container's gap */ margin-top: 0; /* row spacing comes from the container's gap */
} }
+136 -161
View File
@@ -19,7 +19,7 @@ interface LeftPaneProps {
// The shell's single-poll refresh — called after a sim-control POST so the pane updates immediately. // The shell's single-poll refresh — called after a sim-control POST so the pane updates immediately.
onRefresh: () => void; onRefresh: () => void;
// ISS-021 (v0.20.0): push the full snapshot returned by an action endpoint into the shared shell // ISS-021 (v0.20.0): push the full snapshot returned by an action endpoint into the shared shell
// state — needed now that the On board section's refuel/fly/abandon actions live in this pane. // state — needed now that the per-plane cargo rows' refuel/fly/abandon actions live in this pane.
applySnapshot: (snapshot: StateSnapshot) => void; applySnapshot: (snapshot: StateSnapshot) => void;
// ISS-002 (v0.15.0): report fleet-row hovers to the shell so the map can highlight the plane. // ISS-002 (v0.15.0): report fleet-row hovers to the shell so the map can highlight the plane.
onHover: (target: HoverTarget | null) => void; onHover: (target: HoverTarget | null) => void;
@@ -92,7 +92,7 @@ export async function retrySimPost(
export default function LeftPane({ state, loading, error, onRefresh, applySnapshot, onHover, onError }: LeftPaneProps) { export default function LeftPane({ state, loading, error, onRefresh, applySnapshot, onHover, onError }: LeftPaneProps) {
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
// ISS-021 (v0.20.0): shared action runner for the On board section (moved here from RightPane). // ISS-021 (v0.20.0): shared action runner for the per-plane cargo/queue actions (moved here from RightPane).
const { runAction, busy: actionsBusy, actionError } = useGameAction(state, applySnapshot, onError); const { runAction, busy: actionsBusy, actionError } = useGameAction(state, applySnapshot, onError);
// ISS-023 (v0.21.0): Relocate selections — moved here from the right-pane market row; same // ISS-023 (v0.21.0): Relocate selections — moved here from the right-pane market row; same
@@ -195,15 +195,8 @@ export default function LeftPane({ state, loading, error, onRefresh, applySnapsh
const paused = state?.simState.paused ?? false; const paused = state?.simState.paused ?? false;
const timeScale = state?.simState.timeScale ?? 1; const timeScale = state?.simState.timeScale ?? 1;
// On board (v0.12.0, moved from the right pane in ISS-021 v0.20.0): every plane that carries // Snapshot clock — feeds the deadline/penalty previews in the per-plane cargo rows.
// cargo or is mid-flight gets a block — its loaded/expired packages with fly-to-destination +
// abandon controls, and flying planes show their current leg.
const clockMs = state?.simState.clockMs ?? 0; const clockMs = state?.simState.clockMs ?? 0;
// ISS-043 (v0.26.0): a plane with queued legs always shows its block too — the queue is the
// reason it's here even when grounded with nothing on board.
const onboard = state
? state.planes.filter((p) => p.status === 'flying' || cargoWeightFor(state, p.id) > 0 || (p.queue?.length ?? 0) > 0)
: [];
// ISS-023 (v0.21.0): Relocate — any plane can be repositioned to any airport via the existing fly // 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 // endpoint (pure repositioning, no package needed since v0.12.0). Moved here from the right-pane
@@ -340,161 +333,143 @@ export default function LeftPane({ state, loading, error, onRefresh, applySnapsh
<p className="pane-muted">No planes.</p> <p className="pane-muted">No planes.</p>
) : ( ) : (
<ul data-testid="fleet-list" className="fleet-list"> <ul data-testid="fleet-list" className="fleet-list">
{state.planes.map((p) => ( {state.planes.map((p) => {
// ISS-002: hovering a fleet row highlights the plane on the map. // ISS-054 (v0.33.0): the merged per-plane block — status/fuel/loadout plus the former
<li // On board section's cargo rows and queue legs, so all plane info lives in one place.
key={p.id} const cargo = state.packages.filter(
data-testid={`plane-${p.id}`} (pkg) => pkg.planeId === p.id && (pkg.status === 'loaded' || pkg.status === 'expired'),
className="plane-row" );
onMouseEnter={() => onHover({ kind: 'plane', id: p.id })} return (
onMouseLeave={() => onHover(null)} // ISS-002: hovering a fleet row highlights the plane on the map. Handlers live only on
> // the outer <li> — React's enter/leave don't fire when moving to a child, so this covers
<div className="plane-main"> // the whole block without flicker between sub-elements; cargo rows override with their
<span className="plane-model">{PLANE_TYPES[p.model]?.name ?? p.model}</span> // own package hover (below).
<span className={`plane-status status-${p.status}`}>{p.status}</span> <li
</div> key={p.id}
<div className="fuel-bar" aria-hidden="true"> data-testid={`plane-${p.id}`}
<div style={{ width: `${Math.max(0, Math.min(100, (p.fuel / p.maxFuel) * 100))}%` }} /> className="plane-row"
</div> onMouseEnter={() => onHover({ kind: 'plane', id: p.id })}
<div className="plane-sub"> onMouseLeave={() => onHover(null)}
<span data-testid={`plane-fuel-${p.id}`}>{formatFuel(p.fuel, p.maxFuel)}</span> >
<span data-testid={`plane-loadout-${p.id}`}>{loadoutFor(state, p)}</span> <div className="plane-main">
<span className="plane-loc">{planeLocation(p)}</span> <span className="plane-model">{PLANE_TYPES[p.model]?.name ?? p.model}</span>
</div> {p.status === 'flying' ? (
</li> <>
))} <span className="plane-status status-flying">in flight</span>
{p.destName && (
<span data-testid={`plane-leg-${p.id}`} className="contract-route">
{p.locationName} → {p.destName}
</span>
)}
</>
) : (
<span className="plane-status status-available">{p.locationIcao}</span>
)}
</div>
<div className="fuel-bar" aria-hidden="true">
<div style={{ width: `${Math.max(0, Math.min(100, (p.fuel / p.maxFuel) * 100))}%` }} />
</div>
<div className="plane-sub">
<span data-testid={`plane-fuel-${p.id}`}>{formatFuel(p.fuel, p.maxFuel)}</span>
<span data-testid={`plane-loadout-${p.id}`}>{loadoutFor(state, p)}</span>
<span className="plane-loc">{planeLocation(p)}</span>
</div>
{cargo.map((pkg) => {
const penalty = abandonPenalty(pkg, clockMs);
return (
// ISS-025 (v0.22.0): hovering a loaded package draws its full origin→dest trace on the map —
// same as job-list rows — and keeps this plane's halo lit via planeId. Leaving the row restores
// the block's plain plane highlight; leaving the whole block still ends at null (the child's
// mouseleave fires before the parent's).
<div
key={pkg.id}
data-testid={`onboard-package-${pkg.id}`}
className="contract-row"
onMouseEnter={() => onHover({ kind: 'package', originIcao: pkg.originIcao, destIcao: pkg.destIcao, planeId: p.id })}
onMouseLeave={() => onHover({ kind: 'plane', id: p.id })}
>
<button
type="button"
data-testid={`fly-to-${pkg.id}`}
className="pane-btn onboard-fly-btn"
disabled={actionsBusy}
// ISS-053 (v0.32.0): a flying plane can't start another leg — the click queues it instead,
// so "Fly to …" works from any state and the queue needs no dedicated input.
onClick={() => void runAction(`/api/planes/${p.id}/${p.status === 'flying' ? 'queue' : 'fly'}`, { destIcao: pkg.destIcao })}
>
<span className="onboard-fly-label" title={`Fly to ${pkg.destName}`}>
Fly to {pkg.destName}
</span>
</button>
<div className="onboard-actions">
<button
type="button"
data-testid={`refuel-for-${pkg.id}`}
className="pane-btn"
disabled={actionsBusy || p.status !== 'available'}
onClick={() => void runAction(`/api/planes/${p.id}/refuel`, { mode: 'forFlight', packageId: pkg.id })}
>
Refuel
</button>
<button
type="button"
data-testid={`abandon-${pkg.id}`}
className="pane-btn"
disabled={actionsBusy || p.status !== 'available'}
onClick={() => void runAction(`/api/packages/${pkg.id}/abandon`)}
>
Abandon{' '}
<span data-testid={`abandon-penalty-${pkg.id}`}>−{formatCash(penalty)}</span>
</button>
</div>
<div className="contract-sub">
<span className="contract-route">{pkg.originName} → {pkg.destName}</span>
<span>{formatCash(pkg.reward)}</span>
{pkg.status === 'expired' ? (
<span data-testid={`onboard-expired-${pkg.id}`}>expired</span>
) : (
<span>{formatDeadlineRelative(pkg.deadlineSimTime, clockMs)}</span>
)}
</div>
</div>
);
})}
{/* ISS-043 (v0.26.0): flight queue — the not-yet-started legs with cancel.
ISS-053 (v0.32.0): the dedicated add input is gone — fly-to and Relocate post to
/queue while a plane is flying, so no separate entry point is needed.
ISS-054 (v0.33.0): rendered only when non-empty. */}
{p.queue.length > 0 && (
<div className="queue-subsection">
<span className="queue-heading">Queue</span>
{/* ISS-055 (v0.31.0): testids scoped by plane id — with two blocks the unscoped ids
duplicated in the DOM and broke Playwright strict mode. */}
{p.queue.map((icao, i) => (
<div key={`${icao}-${i}`} data-testid={`queue-leg-${p.id}-${i}`} className="queue-row">
<span className="contract-route">
{airportOptions?.find((a) => a.icao === icao)?.name ?? icao} ({icao})
</span>
<button
type="button"
data-testid={`queue-cancel-${p.id}-${i}`}
className="pane-btn"
disabled={actionsBusy}
onClick={() => void runAction(`/api/planes/${p.id}/queue/cancel`, { index: i })}
>
Cancel
</button>
</div>
))}
</div>
)}
</li>
);
})}
</ul> </ul>
)} )}
</section> </section>
{/* ISS-021 (v0.20.0): moved here from the right pane — between Fleet and Time. */}
<section className="pane-section">
<h3 className="pane-heading">On board</h3>
{onboard.length === 0 ? (
<p className="pane-muted">Nothing on board.</p>
) : (
onboard.map((plane) => {
const cargo = state.packages.filter(
(pkg) => pkg.planeId === plane.id && (pkg.status === 'loaded' || pkg.status === 'expired'),
);
return (
// ISS-002: hovering the on-board block (including its cargo rows) highlights the plane.
// Handlers live only on the outer div — React's enter/leave don't fire when moving to a
// child, so this covers the whole block without flicker between sub-elements.
<div
key={plane.id}
data-testid={`onboard-${plane.id}`}
className="onboard-block"
onMouseEnter={() => onHover({ kind: 'plane', id: plane.id })}
onMouseLeave={() => onHover(null)}
>
<div className="contract-main">
<span className="plane-model">{PLANE_TYPES[plane.model]?.name ?? plane.model}</span>
{plane.status === 'flying' ? (
<>
<span className="plane-status status-flying">in flight</span>
{plane.destName && (
<span data-testid={`plane-leg-${plane.id}`} className="contract-route">
{plane.locationName} → {plane.destName}
</span>
)}
</>
) : (
<span className="plane-status status-available">{plane.locationIcao}</span>
)}
</div>
{cargo.map((pkg) => {
const penalty = abandonPenalty(pkg, clockMs);
return (
// ISS-025 (v0.22.0): hovering a loaded package draws its full origin→dest trace on the map —
// same as job-list rows — and keeps this plane's halo lit via planeId. Leaving the row restores
// the block's plain plane highlight; leaving the whole block still ends at null (the child's
// mouseleave fires before the parent's).
<div
key={pkg.id}
data-testid={`onboard-package-${pkg.id}`}
className="contract-row"
onMouseEnter={() => onHover({ kind: 'package', originIcao: pkg.originIcao, destIcao: pkg.destIcao, planeId: plane.id })}
onMouseLeave={() => onHover({ kind: 'plane', id: plane.id })}
>
<button
type="button"
data-testid={`fly-to-${pkg.id}`}
className="pane-btn onboard-fly-btn"
disabled={actionsBusy}
// 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/${plane.id}/${plane.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 || 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.
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. */}
<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>
</div>
);
})
)}
</section>
{/* ISS-023 (v0.21.0): moved here from the right-pane market row — it belongs with {/* ISS-023 (v0.21.0): moved here from the right-pane market row — it belongs with
everything else about our planes, between On board and Time. */} everything else about our planes, between Fleet and Time. */}
{/* ISS-027 (v0.22.0): hovering the section previews the planned relocation on the map — {/* ISS-027 (v0.22.0): hovering the section previews the planned relocation on the map —
dashed trace from the selected plane's airport to the destination + a ring on the target. dashed trace from the selected plane's airport to the destination + a ring on the target.
With no planes 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. */}
@@ -562,7 +537,7 @@ export default function LeftPane({ state, loading, error, onRefresh, applySnapsh
<section className="pane-section" data-testid="refuel-section"> <section className="pane-section" data-testid="refuel-section">
<h3 className="pane-heading">Refuel</h3> <h3 className="pane-heading">Refuel</h3>
{/* Moved verbatim from the right-pane Market section — same testids/labels; the per-package {/* Moved verbatim from the right-pane Market section — same testids/labels; the per-package
"Refuel" button in On board (mode 'forFlight') is a different feature and stays there. */} "Refuel" button in the fleet rows (mode 'forFlight') is a different feature and stays there. */}
<div className="market-row"> <div className="market-row">
{refuelable.length === 0 ? ( {refuelable.length === 0 ? (
<span data-testid="market-refuel-none" className="pane-muted"> <span data-testid="market-refuel-none" className="pane-muted">
+3 -2
View File
@@ -4,7 +4,7 @@ import { useState } from 'react';
import type { PackageState, PlaneState, StateSnapshot } from '@/lib/game/state'; import type { PackageState, PlaneState, StateSnapshot } from '@/lib/game/state';
import { PLANE_TYPES, SELL_FRACTION } from '@/lib/game/catalog'; import { PLANE_TYPES, SELL_FRACTION } from '@/lib/game/catalog';
import { maxRangeKm } from '@/lib/sim/movement'; import { maxRangeKm } from '@/lib/sim/movement';
import { formatCash, formatDeadlineRelative } from '@/lib/format'; import { formatCargo, formatCash, formatDeadlineRelative } from '@/lib/format';
import { useGameAction } from '@/lib/useGameAction'; import { useGameAction } from '@/lib/useGameAction';
import type { HoverTarget } from './hoverTypes'; import type { HoverTarget } from './hoverTypes';
@@ -225,7 +225,8 @@ export default function RightPane({ state, loading, applySnapshot, onHover, onEr
</div> </div>
<div className="contract-sub"> <div className="contract-sub">
<span data-testid={`open-package-reward-${pkg.id}`}>{formatCash(pkg.reward)}</span> <span data-testid={`open-package-reward-${pkg.id}`}>{formatCash(pkg.reward)}</span>
<span>{pkg.cargoType} × {pkg.cargoCount}</span> {/* ISS-066 (v0.35.0): cargo shows as type + tonnage ("Mail 10.0t"), not "type × count". */}
<span data-testid={`open-package-cargo-${pkg.id}`}>{formatCargo(pkg.cargoType, pkg.weightKg)}</span>
<span data-testid={`open-package-deadline-${pkg.id}`}> <span data-testid={`open-package-deadline-${pkg.id}`}>
{formatDeadlineRelative(pkg.deadlineSimTime, clockMs)} {formatDeadlineRelative(pkg.deadlineSimTime, clockMs)}
</span> </span>
+15 -2
View File
@@ -1,5 +1,10 @@
import type { Page, Request } from '@playwright/test'; import type { Page, Request } from '@playwright/test';
import { expect, test } from '@playwright/test'; import { expect, test } from '@playwright/test';
// Real helpers (v0.36.0) — the wind-adjusted fuel math must match lib/ exactly; Playwright resolves
// the repo's tsconfig paths, so importing beats mirroring a third copy of the formulas.
import { initialBearingDeg } from '../lib/geo';
import { weatherFor } from '../lib/weather';
import { windBurnFactor } from '../lib/sim/movement';
// v0.12.0 package model against the live origin (https://flight.dev.kkhost.pl): // v0.12.0 package model against the live origin (https://flight.dev.kkhost.pl):
// the full core loop through the UI — hook creates a short package at EDDF → Load // the full core loop through the UI — hook creates a short package at EDDF → Load
@@ -219,10 +224,18 @@ test('full core loop via UI: load → fly to destination → exact reward + rep
const repGain = Math.round((REP_BASE + REP_PER_KM * exactKm) * 100) / 100; const repGain = Math.round((REP_BASE + REP_PER_KM * exactKm) * 100) / 100;
expect(s.account.reputation).toBeCloseTo(Math.round((before.account.reputation + repGain) * 100) / 100, 9); expect(s.account.reputation).toBeCloseTo(Math.round((before.account.reputation + repGain) * 100) / 100, 9);
// Fuel burned per the v0.16.0 weight-proportional model (closed form — no rounding drift). // Fuel burned per the v0.16.0 weight-proportional model (closed form — no rounding drift),
// wind-adjusted (v0.36.0) by departure weather vs route heading.
const entry = before.catalog.find((e) => e.model === 'atr72')!; const entry = before.catalog.find((e) => e.model === 'atr72')!;
const wHub = weatherFor(HUB);
expect(landed.fuel).toBeCloseTo( expect(landed.fuel).toBeCloseTo(
fuelAfter(before.planes.find((p) => p.id === planeId)!.fuel, entry.burnPerKm, entry.emptyWeightKg, entry.maxFuel, exactKm), fuelAfter(
before.planes.find((p) => p.id === planeId)!.fuel,
entry.burnPerKm * windBurnFactor(wHub.windKt, wHub.windDirDeg, initialBearingDeg(nearest.hubLat, nearest.hubLon, nearest.lat, nearest.lon)),
entry.emptyWeightKg,
entry.maxFuel,
exactKm,
),
6, 6,
); );
+3 -3
View File
@@ -439,7 +439,7 @@ test('hovering the Relocate section previews the planned route and target airpor
}).toBe(0); }).toBe(0);
}); });
test('hovering an on-board block of a flying plane highlights its route', async ({ page }) => { test('hovering the fleet row of a flying plane highlights its route', async ({ page }) => {
test.setTimeout(600_000); // self-heal + one flight at scale 1000 + fly-home cleanup test.setTimeout(600_000); // self-heal + one flight at scale 1000 + fly-home cleanup
await flyHomeViaApi(page); await flyHomeViaApi(page);
@@ -458,8 +458,8 @@ test('hovering an on-board block of a flying plane highlights its route', async
expect(fly.status()).toBe(200); expect(fly.status()).toBe(200);
await setScale(page, SCALE); await setScale(page, SCALE);
// The on-board block shows the plane in flight once a snapshot with status 'flying' lands. // The fleet row shows the plane in flight once a snapshot with status 'flying' lands (ISS-054).
const block = page.getByTestId('onboard-plane-starter'); const block = page.getByTestId('plane-plane-starter');
await expect(block).toContainText(/in flight/i, { timeout: 15_000 }); await expect(block).toContainText(/in flight/i, { timeout: 15_000 });
// Freeze mid-flight so the ~10 s leg can't land under us while we assert (same pattern as // Freeze mid-flight so the ~10 s leg can't land under us while we assert (same pattern as
+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). // Sim clock renders (UTC wall-clock of the game time).
await expect(page.getByTestId('sim-clock')).toContainText(/UTC/); await expect(page.getByTestId('sim-clock')).toContainText(/UTC/);
// ISS-021 (v0.20.0): the On board section moved to the left pane (between Fleet and Time) — // ISS-054 (v0.33.0): On board merged into the Fleet list — its heading is gone from both panes,
// its heading renders here… // per-plane cargo rows now render inside the fleet rows.
await expect(page.getByTestId('left-pane').getByRole('heading', { name: 'On board', exact: true })).toBeVisible(); await expect(page.getByTestId('left-pane').getByRole('heading', { name: 'On board', exact: true })).toHaveCount(0);
// …and no longer in the right pane.
await expect(page.getByTestId('right-pane').getByRole('heading', { name: 'On board', exact: true })).toHaveCount(0); await expect(page.getByTestId('right-pane').getByRole('heading', { name: 'On board', exact: true })).toHaveCount(0);
// ISS-035 (v0.23.0): the Refuel-to-full row moved to the left pane next to Relocate — its section // ISS-035 (v0.23.0): the Refuel-to-full row moved to the left pane next to Relocate — its section
+4
View File
@@ -233,6 +233,10 @@ test('queue display + cancel via UI (ISS-043 part 2)', async ({ page }) => {
}, { timeout: 15_000, message: 'waiting for the fly click to take effect (status "flying" + dest set)' }).toBe(true); }, { timeout: 15_000, message: 'waiting for the fly click to take effect (status "flying" + dest set)' }).toBe(true);
await postSim(page, { paused: true }); // pause-sim pattern: deterministic window for UI clicks await postSim(page, { paused: true }); // pause-sim pattern: deterministic window for UI clicks
// 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 // 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. // posts to /queue and the leg starts when it lands.
await page.getByTestId('relocate-plane').selectOption('plane-starter'); await page.getByTestId('relocate-plane').selectOption('plane-starter');
+39 -16
View File
@@ -1,5 +1,10 @@
import type { Page } from '@playwright/test'; import type { Page } from '@playwright/test';
import { expect, test } from '@playwright/test'; import { expect, test } from '@playwright/test';
// Real helpers (v0.36.0) — the wind-adjusted fuel math must match lib/ exactly; Playwright resolves
// the repo's tsconfig paths, so importing beats mirroring a third copy of the formulas.
import { initialBearingDeg } from '../lib/geo';
import { weatherFor } from '../lib/weather';
import { windBurnFactor } from '../lib/sim/movement';
// v0.12.0 — right Jobs & Market pane against the live origin: open packages render with // v0.12.0 — right Jobs & Market pane against the live origin: open packages render with
// names/reward/deadline and Load (UI click) binds one to the starter; the on-board list // names/reward/deadline and Load (UI click) binds one to the starter; the on-board list
@@ -119,22 +124,28 @@ async function nearestToHub(page: Page): Promise<{ icao: string; lat: number; lo
} }
// Farthest airport from EDDF whose leg still FITS A FULL TANK — the longest flyable leg (v0.16.0 // Farthest airport from EDDF whose leg still FITS A FULL TANK — the longest flyable leg (v0.16.0
// refuel-for-flight test). The dataset's absolute farthest can exceed the aircraft's range (UNNT is // refuel-for-flight test). The dataset's absolute farthest can exceed the aircraft's range, so a
// ~4803 km out and needs ~6955 L > the 6300 L tank), so a full-tank fly there would rightly 409; // full-tank fly there would rightly 409; this test wants a long-but-flyable leg instead. Wind-
// this test wants a long-but-flyable leg instead. // adjusted (v0.36.0): BOTH directions must fit — the outbound is flown from a full tank and the
// return leg is what the forFlight refuel below buys, which also 409s if it doesn't fit.
async function farthestFlyableToHub( async function farthestFlyableToHub(
page: Page, page: Page,
entry: CatalogEntry, entry: CatalogEntry,
): Promise<{ icao: string; lat: number; lon: number; hubLat: number; hubLon: number }> { ): Promise<{ icao: string; lat: number; lon: number; d: number; hubLat: number; hubLon: number }> {
const fc = (await (await page.request.get('/api/airports')).json()) as { const fc = (await (await page.request.get('/api/airports')).json()) as {
features: Array<{ properties: { icao: string; lat: number; lon: number } }>; features: Array<{ properties: { icao: string; lat: number; lon: number } }>;
}; };
const hub = fc.features.find((f) => f.properties.icao === HUB)!.properties; const hub = fc.features.find((f) => f.properties.icao === HUB)!.properties;
const wHub = weatherFor(HUB);
let farthest: { icao: string; lat: number; lon: number; d: number } | null = null; let farthest: { icao: string; lat: number; lon: number; d: number } | null = null;
for (const f of fc.features) { for (const f of fc.features) {
if (f.properties.icao === HUB) continue; if (f.properties.icao === HUB) continue;
const d = haversineKm(hub.lat, hub.lon, f.properties.lat, f.properties.lon); const d = haversineKm(hub.lat, hub.lon, f.properties.lat, f.properties.lon);
if (requiredFuelFor(entry.burnPerKm, entry.emptyWeightKg, entry.maxFuel, d) > entry.maxFuel) continue; // beyond range const wDest = weatherFor(f.properties.icao);
const outBurn = entry.burnPerKm * windBurnFactor(wHub.windKt, wHub.windDirDeg, initialBearingDeg(hub.lat, hub.lon, f.properties.lat, f.properties.lon));
if (requiredFuelFor(outBurn, entry.emptyWeightKg, entry.maxFuel, d) > entry.maxFuel) continue; // beyond range
const backBurn = entry.burnPerKm * windBurnFactor(wDest.windKt, wDest.windDirDeg, initialBearingDeg(f.properties.lat, f.properties.lon, hub.lat, hub.lon));
if (requiredFuelFor(backBurn, entry.emptyWeightKg, entry.maxFuel, d) > entry.maxFuel) continue; // return leg wouldn't fit a full tank either
if (!farthest || d > farthest.d) { if (!farthest || d > farthest.d) {
farthest = { icao: f.properties.icao, lat: f.properties.lat, lon: f.properties.lon, d }; farthest = { icao: f.properties.icao, lat: f.properties.lat, lon: f.properties.lon, d };
} }
@@ -158,9 +169,9 @@ function maxRangeKm(burnPerKm: number, emptyWeightKg: number, maxFuel: number):
} }
// Test hook (FLIGHT_TEST_HOOKS=1): create an open package on a known route. // Test hook (FLIGHT_TEST_HOOKS=1): create an open package on a known route.
async function createPackage(page: Page, fromIcao: string, toIcao: string, deadlineOffsetMs?: number): Promise<PackageState> { async function createPackage(page: Page, fromIcao: string, toIcao: string, deadlineOffsetMs?: number, weightKg?: number): Promise<PackageState> {
const created = await page.request.post('/api/test/create-package', { const created = await page.request.post('/api/test/create-package', {
data: { originIcao: fromIcao, destIcao: toIcao, deadlineOffsetMs }, data: { originIcao: fromIcao, destIcao: toIcao, deadlineOffsetMs, weightKg },
}); });
expect(created.status()).toBe(201); expect(created.status()).toBe(201);
return (await created.json()) as PackageState; return (await created.json()) as PackageState;
@@ -213,9 +224,12 @@ test('open packages render with names, reward and deadline; Load is enabled at a
// Deterministic target: a hook-created EDDF package — Load is enabled (starter at origin, free slot). // Deterministic target: a hook-created EDDF package — Load is enabled (starter at origin, free slot).
const nearest = await nearestToHub(page); const nearest = await nearestToHub(page);
const pkg = await createPackage(page, HUB, nearest.icao); const pkg = await createPackage(page, HUB, nearest.icao, undefined, 4_000);
const row = page.locator(`li[data-testid="open-package-${pkg.id}"]`); const row = page.locator(`li[data-testid="open-package-${pkg.id}"]`);
await expect(row, 'the hook-created package appears in the list').toBeVisible({ timeout: 15_000 }); await expect(row, 'the hook-created package appears in the list').toBeVisible({ timeout: 15_000 });
// ISS-066 (v0.35.0): cargo shows as type + tonnage ("… 4.0t"), not "type × count".
// 4 t is within the starter ATR's 4.8 t maxPayloadKg, so Load stays enabled below.
await expect(page.getByTestId(`open-package-cargo-${pkg.id}`)).toContainText('4.0t');
await expect(page.getByTestId(`open-package-reward-${pkg.id}`)).toContainText(eur(pkg.reward)); await expect(page.getByTestId(`open-package-reward-${pkg.id}`)).toContainText(eur(pkg.reward));
const loadBtn = page.getByTestId(`load-${pkg.id}`); const loadBtn = page.getByTestId(`load-${pkg.id}`);
await expect(loadBtn).toBeEnabled(); await expect(loadBtn).toBeEnabled();
@@ -268,8 +282,8 @@ test('on-board list: fly-to-destination + abandon with penalty preview; flying p
await expect(flyBtn).toBeEnabled(); await expect(flyBtn).toBeEnabled();
await flyBtn.click(); await flyBtn.click();
// The on-board block now shows the plane in flight with its current leg… // The fleet row now shows the plane in flight with its current leg (ISS-054)…
await expect(page.getByTestId('onboard-plane-starter')).toContainText(/in flight/i, { timeout: 10_000 }); await expect(page.getByTestId('plane-plane-starter')).toContainText(/in flight/i, { timeout: 10_000 });
const leg = page.getByTestId('plane-leg-plane-starter'); const leg = page.getByTestId('plane-leg-plane-starter');
await expect(leg).toBeVisible(); await expect(leg).toBeVisible();
await expect(leg).toContainText('→'); await expect(leg).toContainText('→');
@@ -362,7 +376,7 @@ test('market buy/sell render with previews from the catalog in /api/state; left-
} }
}); });
test('on-board row Refuel tops up just enough for the package leg; left-pane button says "Refuel to full"', async ({ page }) => { test('on-board row Refuel buys 150% of the weather-adjusted leg requirement; left-pane button says "Refuel to full"', async ({ page }) => {
test.setTimeout(300_000); // self-heal + one long flight each way at scale 1000 test.setTimeout(300_000); // self-heal + one long flight each way at scale 1000
await flyHomeViaApi(page); await flyHomeViaApi(page);
@@ -372,7 +386,7 @@ test('on-board row Refuel tops up just enough for the package leg; left-pane but
// Deterministic baseline: a full tank. // Deterministic baseline: a full tank.
expect((await page.request.post('/api/planes/plane-starter/refuel')).status()).toBe(200); expect((await page.request.post('/api/planes/plane-starter/refuel')).status()).toBe(200);
// Longest FLYABLE leg from the hub (fits a full tank) — long enough that "just enough" for it // Longest FLYABLE leg from the hub (fits a full tank) — long enough that 150% of its requirement
// exceeds what's left on arrival. // exceeds what's left on arrival.
const farthest = await farthestFlyableToHub(page, entry); const farthest = await farthestFlyableToHub(page, entry);
const pkg1 = await createPackage(page, HUB, farthest.icao, 24 * 3_600_000); const pkg1 = await createPackage(page, HUB, farthest.icao, 24 * 3_600_000);
@@ -393,12 +407,21 @@ test('on-board row Refuel tops up just enough for the package leg; left-pane but
const pkg2 = await createPackage(page, farthest.icao, HUB, 24 * 3_600_000); const pkg2 = await createPackage(page, farthest.icao, HUB, 24 * 3_600_000);
expect((await page.request.post(`/api/packages/${pkg2.id}/load`, { data: { planeId: 'plane-starter' } })).status()).toBe(200); expect((await page.request.post(`/api/packages/${pkg2.id}/load`, { data: { planeId: 'plane-starter' } })).status()).toBe(200);
// Expected "just enough" from the snapshot constants (mirrors requiredFuelFor in lib/sim/movement.ts). // Expected "150% of the requirement" from the snapshot constants (mirrors requiredFuelFor in
// lib/sim/movement.ts, wind-adjusted per v0.36.0 — departure weather at the far end vs heading home).
const pre = await getState(page); const pre = await getState(page);
const fuelBefore = pre.planes.find((p) => p.id === 'plane-starter')!.fuel; const fuelBefore = pre.planes.find((p) => p.id === 'plane-starter')!.fuel;
const cashBefore = pre.account.cash; const cashBefore = pre.account.cash;
const km = haversineKm(farthest.hubLat, farthest.hubLon, farthest.lat, farthest.lon); // symmetric: hub ↔ far end const km = haversineKm(farthest.hubLat, farthest.hubLon, farthest.lat, farthest.lon); // symmetric: hub ↔ far end
const liters = Math.max(0, requiredFuelFor(entry.burnPerKm, entry.emptyWeightKg, entry.maxFuel, km) - fuelBefore); const wFar = weatherFor(farthest.icao);
const required = requiredFuelFor(
entry.burnPerKm * windBurnFactor(wFar.windKt, wFar.windDirDeg, initialBearingDeg(farthest.lat, farthest.lon, farthest.hubLat, farthest.hubLon)),
entry.emptyWeightKg,
entry.maxFuel,
km,
);
const target = Math.min(entry.maxFuel, required * 1.5); // ISS-071: 150% margin, capped at tank capacity
const liters = Math.max(0, target - fuelBefore);
expect(liters).toBeGreaterThan(0); // the long leg genuinely needs more than what's left expect(liters).toBeGreaterThan(0); // the long leg genuinely needs more than what's left
const expectedCost = Math.round(liters * pre.fuelPricePerL * 100) / 100; const expectedCost = Math.round(liters * pre.fuelPricePerL * 100) / 100;
@@ -421,8 +444,8 @@ test('on-board row Refuel tops up just enough for the package leg; left-pane but
await expect(page.getByTestId('market-refuel')).toHaveText('Refuel to full'); await expect(page.getByTestId('market-refuel')).toHaveText('Refuel to full');
// Restore: fly home — pkg2's destination IS the hub, so it delivers on arrival. The tank holds // Restore: fly home — pkg2's destination IS the hub, so it delivers on arrival. The tank holds
// exactly the leg requirement, so this also proves "just enough" is enough. ISS-041: the far-end // 150% of the leg requirement (or a full tank), so the return is comfortably feasible. ISS-041:
// arrival above paused the sim, so re-set SCALE for this leg (setScale clears pause). // the far-end arrival above paused the sim, so re-set SCALE for this leg (setScale clears pause).
await setScale(page, SCALE); await setScale(page, SCALE);
expect((await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } })).status()).toBe(200); expect((await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } })).status()).toBe(200);
await expect await expect
+17 -5
View File
@@ -1,5 +1,10 @@
import type { Page } from '@playwright/test'; import type { Page } from '@playwright/test';
import { expect, test } from '@playwright/test'; import { expect, test } from '@playwright/test';
// Real helpers (v0.36.0) — the wind-adjusted fuel math must match lib/ exactly; Playwright resolves
// the repo's tsconfig paths, so importing beats mirroring a third copy of the formulas.
import { initialBearingDeg } from '../lib/geo';
import { weatherFor } from '../lib/weather';
import { windBurnFactor } from '../lib/sim/movement';
// v0.31.0 — ISS-055: gameplay with TWO planes in the fleet. The reported bug ("can't load cargo // 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 // if both planes are on the same airport") was UX-only: LoadControls' multi-plane path rendered a
@@ -114,18 +119,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 // 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 }> { 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 entry = (await getState(page)).catalog.find((e) => e.model === BUY_MODEL)!;
const fc = (await (await page.request.get('/api/airports')).json()) as { const fc = (await (await page.request.get('/api/airports')).json()) as {
features: Array<{ properties: { icao: string; lat: number; lon: number } }>; features: Array<{ properties: { icao: string; lat: number; lon: number } }>;
}; };
const hub = fc.features.find((f) => f.properties.icao === HUB)!.properties; const hub = fc.features.find((f) => f.properties.icao === HUB)!.properties;
const wHub = weatherFor(HUB);
let farthest: { icao: string; lat: number; lon: number; d: number } | null = null; let farthest: { icao: string; lat: number; lon: number; d: number } | null = null;
for (const f of fc.features) { for (const f of fc.features) {
if (f.properties.icao === HUB) continue; if (f.properties.icao === HUB) continue;
const d = haversineKm(hub.lat, hub.lon, f.properties.lat, f.properties.lon); const d = haversineKm(hub.lat, hub.lon, f.properties.lat, f.properties.lon);
if (requiredFuelFor(entry.burnPerKm, entry.emptyWeightKg, entry.maxFuel, d) > entry.maxFuel) continue; // beyond range const wDest = weatherFor(f.properties.icao);
const outBurn = entry.burnPerKm * windBurnFactor(wHub.windKt, wHub.windDirDeg, initialBearingDeg(hub.lat, hub.lon, f.properties.lat, f.properties.lon));
if (requiredFuelFor(outBurn, entry.emptyWeightKg, entry.maxFuel, d) > entry.maxFuel) continue; // beyond range
const backBurn = entry.burnPerKm * windBurnFactor(wDest.windKt, wDest.windDirDeg, initialBearingDeg(f.properties.lat, f.properties.lon, hub.lat, hub.lon));
if (requiredFuelFor(backBurn, entry.emptyWeightKg, entry.maxFuel, d) > entry.maxFuel) continue; // return leg wouldn't fit a full tank either
if (!farthest || d > farthest.d) { if (!farthest || d > farthest.d) {
farthest = { icao: f.properties.icao, lat: f.properties.lat, lon: f.properties.lon, d }; farthest = { icao: f.properties.icao, lat: f.properties.lat, lon: f.properties.lon, d };
} }
@@ -323,9 +335,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' }) }, { timeout: 15_000, message: 'waiting for the second package to load onto a plane' })
.toBe(secondId); .toBe(secondId);
// Both planes carry cargo simultaneously — both on-board blocks render. // Both planes carry cargo simultaneously — both fleet rows render their cargo (ISS-054).
await expect(page.getByTestId(`onboard-${STARTER}`), 'the starter block renders').toBeVisible({ timeout: 15_000 }); await expect(page.getByTestId(`plane-${STARTER}`), 'the starter row renders').toBeVisible({ timeout: 15_000 });
await expect(page.getByTestId(`onboard-${secondId}`), 'the second plane block renders').toBeVisible({ timeout: 15_000 }); await expect(page.getByTestId(`plane-${secondId}`), 'the second plane row renders').toBeVisible({ timeout: 15_000 });
}); });
test('two planes fly in parallel; the last landing slows to 1× and the next dispatch resumes', async ({ page }) => { test('two planes fly in parallel; the last landing slows to 1× and the next dispatch resumes', async ({ page }) => {
+10 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { formatCash, formatDeadlineRelative, formatDuration, formatFuel, formatSimClock } from './format'; import { formatCargo, formatCash, formatDeadlineRelative, formatDuration, formatFuel, formatSimClock } from './format';
describe('formatCash', () => { describe('formatCash', () => {
it('formats EUR with thousands separators and two decimals', () => { it('formats EUR with thousands separators and two decimals', () => {
@@ -21,6 +21,15 @@ describe('formatFuel', () => {
}); });
}); });
describe('formatCargo', () => {
it('shows cargo type + tonnage with one decimal, like the loadout display', () => {
expect(formatCargo('Mail', 10_000)).toBe('Mail 10.0t');
// (250 / 1000).toFixed(1) === '0.3' — half rounds up at this value.
expect(formatCargo('Electronics', 250)).toBe('Electronics 0.3t');
expect(formatCargo('Mail', 40)).toBe('Mail 0.0t');
});
});
describe('formatSimClock', () => { describe('formatSimClock', () => {
it('formats epoch ms as a UTC wall-clock string with seconds (deterministic across timezones)', () => { it('formats epoch ms as a UTC wall-clock string with seconds (deterministic across timezones)', () => {
expect(formatSimClock(Date.UTC(2026, 8, 17, 14, 5))).toBe('2026-09-17 14:05:00 UTC'); expect(formatSimClock(Date.UTC(2026, 8, 17, 14, 5))).toBe('2026-09-17 14:05:00 UTC');
+5
View File
@@ -11,6 +11,11 @@ export function formatFuel(fuel: number, maxFuel: number): string {
return `${Math.round(fuel)} / ${maxFuel} L`; return `${Math.round(fuel)} / ${maxFuel} L`;
} }
// Job-list cargo label: "Mail 10.0t" — type + tonnage, one decimal like the loadout display.
export function formatCargo(cargoType: string, weightKg: number): string {
return `${cargoType} ${(weightKg / 1000).toFixed(1)}t`;
}
// The sim clock is epoch ms (seeded from real time) — shown as a UTC wall-clock with // The sim clock is epoch ms (seeded from real time) — shown as a UTC wall-clock with
// seconds so the display is deterministic regardless of the viewer's timezone. // seconds so the display is deterministic regardless of the viewer's timezone.
export function formatSimClock(clockMs: number): string { export function formatSimClock(clockMs: number): string {
+59 -18
View File
@@ -6,8 +6,9 @@ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { openGameDb, type GameDb } from '@/lib/db/client'; import { openGameDb, type GameDb } from '@/lib/db/client';
import * as schema from '@/lib/db/schema'; import * as schema from '@/lib/db/schema';
import { loadAirportsGeoJson } from '@/lib/airports'; import { loadAirportsGeoJson } from '@/lib/airports';
import { haversineKm } from '@/lib/geo'; import { haversineKm, initialBearingDeg } from '@/lib/geo';
import { requiredFuelFor } from '@/lib/sim/movement'; import { weatherFor } from '@/lib/weather';
import { requiredFuelFor, windBurnFactor } from '@/lib/sim/movement';
import { FUEL_PRICE_PER_L, PLANE_TYPES, SELL_FRACTION } from './catalog'; import { FUEL_PRICE_PER_L, PLANE_TYPES, SELL_FRACTION } from './catalog';
import { HUB_ICAO } from './seed'; import { HUB_ICAO } from './seed';
import { import {
@@ -261,6 +262,37 @@ describe('queueLeg (v0.25.0, ISS-043)', () => {
const inst = await freshDb('qleg-unknownicao.db'); const inst = await freshDb('qleg-unknownicao.db');
await expectActionError(queueLeg(inst, 'plane-starter', 'ZZZZ'), 400, /unknown airport/); await expectActionError(queueLeg(inst, 'plane-starter', 'ZZZZ'), 400, /unknown airport/);
}); });
it('rejects a destination already in the queue with 409 (ISS-065)', async () => {
const inst = await freshDb('qleg-dup.db');
await queueLeg(inst, 'plane-starter', 'LEMD');
await expectActionError(queueLeg(inst, 'plane-starter', 'LEMD'), 409, /already in the flight queue/);
const plane = (await inst.db.select().from(schema.planes))[0];
expect(JSON.parse(plane.queue)).toEqual(['LEMD']); // rejected leg not appended
});
it('rejects re-queuing the active leg destination of a flying plane with 409 (ISS-065)', async () => {
const inst = await freshDb('qleg-dupflying.db');
await inst.db.update(schema.planes)
.set({ status: 'flying', departureSimTime: T0, destIcao: 'LEMD' })
.where(eq(schema.planes.id, 'plane-starter'));
await expectActionError(queueLeg(inst, 'plane-starter', 'LEMD'), 409, /already flying to LEMD/);
const plane = (await inst.db.select().from(schema.planes))[0];
expect(JSON.parse(plane.queue)).toEqual([]); // rejected leg not appended
});
it('still appends a different known airport with success (ISS-065 sanity)', async () => {
const inst = await freshDb('qleg-dupother.db');
await queueLeg(inst, 'plane-starter', 'LEMD');
await expect(queueLeg(inst, 'plane-starter', 'EHAM')).resolves.toBeUndefined();
const plane = (await inst.db.select().from(schema.planes))[0];
expect(JSON.parse(plane.queue)).toEqual(['LEMD', 'EHAM']);
});
}); });
describe('cancelQueuedLeg (v0.25.0, ISS-043)', () => { describe('cancelQueuedLeg (v0.25.0, ISS-043)', () => {
@@ -560,17 +592,29 @@ describe('refuelPlane', () => {
// --- forFlight mode (v0.16.0) --- // --- forFlight mode (v0.16.0) ---
it('forFlight adds exactly the liters needed for the package leg and deducts L × price', async () => { // Wind-adjusted requirement for the hub → LEMD leg (v0.36.0) — computed with the SAME helpers
const inst = await freshDb('refuel-forflight-ok.db'); // refuelPlane uses, so the expectations stay exact without hardcoded literals.
await insertOpenPackage(inst, 'p-ff', { destIcao: 'LEMD' }); // EDDF → LEMD async function adjustedRequiredForHubToLemd() {
await loadPackage(inst, 'p-ff', 'plane-starter');
const fc = await loadAirportsGeoJson(); const fc = await loadAirportsGeoJson();
const from = fc.features.find((f) => f.properties.icao === HUB_ICAO)!.properties; const from = fc.features.find((f) => f.properties.icao === HUB_ICAO)!.properties;
const to = fc.features.find((f) => f.properties.icao === 'LEMD')!.properties; const to = fc.features.find((f) => f.properties.icao === 'LEMD')!.properties;
const km = haversineKm(from.lat, from.lon, to.lat, to.lon); const km = haversineKm(from.lat, from.lon, to.lat, to.lon);
const type = PLANE_TYPES.atr72; const type = PLANE_TYPES.atr72;
const required = requiredFuelFor(type.burnPerKm, type.emptyWeightKg, type.maxFuel, km); const w = weatherFor(HUB_ICAO);
return requiredFuelFor(
type.burnPerKm * windBurnFactor(w.windKt, w.windDirDeg, initialBearingDeg(from.lat, from.lon, to.lat, to.lon)),
type.emptyWeightKg,
type.maxFuel,
km,
);
}
it('forFlight buys 150% of the weather-adjusted leg requirement and deducts L × price', async () => {
const inst = await freshDb('refuel-forflight-ok.db');
await insertOpenPackage(inst, 'p-ff', { destIcao: 'LEMD' }); // EDDF → LEMD
await loadPackage(inst, 'p-ff', 'plane-starter');
const required = await adjustedRequiredForHubToLemd();
// Partial tank below the requirement so L > 0. // Partial tank below the requirement so L > 0.
const startFuel = Math.round(required / 2); const startFuel = Math.round(required / 2);
@@ -579,31 +623,28 @@ describe('refuelPlane', () => {
const { cost } = await refuelPlane(inst, 'plane-starter', { mode: 'forFlight', packageId: 'p-ff' }); const { cost } = await refuelPlane(inst, 'plane-starter', { mode: 'forFlight', packageId: 'p-ff' });
expect(cost).toBe(Math.round((required - startFuel) * FUEL_PRICE_PER_L * 100) / 100); // ISS-071: target is 150% of the requirement (below tank capacity for this leg).
const targetFuel = Math.min(PLANE_TYPES.atr72.maxFuel, Math.max(startFuel, required * 1.5));
expect(cost).toBe(Math.round((targetFuel - startFuel) * FUEL_PRICE_PER_L * 100) / 100);
const plane = (await inst.db.select().from(schema.planes))[0]; const plane = (await inst.db.select().from(schema.planes))[0];
expect(plane.fuel).toBeCloseTo(required, 6); // topped up to exactly the leg requirement expect(plane.fuel).toBeCloseTo(targetFuel, 6); // topped up to exactly the 150% target
const account = (await inst.db.select().from(schema.account))[0]; const account = (await inst.db.select().from(schema.account))[0];
expect(account.cash).toBeCloseTo(cashBefore - cost, 6); expect(account.cash).toBeCloseTo(cashBefore - cost, 6);
}); });
it('forFlight leaves an exactly-feasible tank — flyPlane to the package dest succeeds right after', async () => { it('forFlight leaves a tank with a 50% margin — flyPlane to the package dest succeeds right after', async () => {
const inst = await freshDb('refuel-ff-flyafter.db'); const inst = await freshDb('refuel-ff-flyafter.db');
await insertOpenPackage(inst, 'p-fa', { destIcao: 'LEMD' }); // EDDF → LEMD await insertOpenPackage(inst, 'p-fa', { destIcao: 'LEMD' }); // EDDF → LEMD
await loadPackage(inst, 'p-fa', 'plane-starter'); await loadPackage(inst, 'p-fa', 'plane-starter');
const fc = await loadAirportsGeoJson(); const required = await adjustedRequiredForHubToLemd();
const from = fc.features.find((f) => f.properties.icao === HUB_ICAO)!.properties;
const to = fc.features.find((f) => f.properties.icao === 'LEMD')!.properties;
const km = haversineKm(from.lat, from.lon, to.lat, to.lon);
const type = PLANE_TYPES.atr72;
const required = requiredFuelFor(type.burnPerKm, type.emptyWeightKg, type.maxFuel, km);
// Empty-ish tank: well below the leg requirement. // Empty-ish tank: well below the leg requirement.
await inst.db.update(schema.planes).set({ fuel: Math.round(required / 4) }).where(eq(schema.planes.id, 'plane-starter')); await inst.db.update(schema.planes).set({ fuel: Math.round(required / 4) }).where(eq(schema.planes.id, 'plane-starter'));
await refuelPlane(inst, 'plane-starter', { mode: 'forFlight', packageId: 'p-fa' }); await refuelPlane(inst, 'plane-starter', { mode: 'forFlight', packageId: 'p-fa' });
// The leg must now be flyable — no epsilon-short 409 (regression guard for the float-exact target). // The leg must now be flyable — the 150% margin covers flyPlane's strict `>` check.
await flyPlane(inst, 'plane-starter', 'LEMD'); await flyPlane(inst, 'plane-starter', 'LEMD');
const plane = (await inst.db.select().from(schema.planes))[0]; const plane = (await inst.db.select().from(schema.planes))[0];
expect(plane.status).toBe('flying'); expect(plane.status).toBe('flying');
+33 -13
View File
@@ -3,8 +3,9 @@ import { and, eq, inArray } from 'drizzle-orm';
import * as schema from '@/lib/db/schema'; import * as schema from '@/lib/db/schema';
import { persist, type GameDb } from '@/lib/db/client'; import { persist, type GameDb } from '@/lib/db/client';
import { loadAirportsGeoJson } from '@/lib/airports'; import { loadAirportsGeoJson } from '@/lib/airports';
import { haversineKm } from '@/lib/geo'; import { haversineKm, initialBearingDeg } from '@/lib/geo';
import { requiredFuelFor } from '@/lib/sim/movement'; import { weatherFor } from '@/lib/weather';
import { requiredFuelFor, windBurnFactor } from '@/lib/sim/movement';
import { runAction } from '@/lib/sim/engine'; import { runAction } from '@/lib/sim/engine';
import { getStateSnapshot, parseIcaoQueue } from './state'; import { getStateSnapshot, parseIcaoQueue } from './state';
import { FUEL_PRICE_PER_L, PLANE_TYPES, SELL_FRACTION } from './catalog'; import { FUEL_PRICE_PER_L, PLANE_TYPES, SELL_FRACTION } from './catalog';
@@ -106,8 +107,11 @@ export async function flyPlane(inst: GameDb, planeId: string, destIcao: string):
const type = PLANE_TYPES[plane.model]; const type = PLANE_TYPES[plane.model];
if (!type) throw new Error(`unknown plane model ${plane.model}`); // → 500, cannot happen for seeded data if (!type) throw new Error(`unknown plane model ${plane.model}`); // → 500, cannot happen for seeded data
const distanceKm = haversineKm(origin.lat, origin.lon, destFeature.properties.lat, destFeature.properties.lon); const distanceKm = haversineKm(origin.lat, origin.lon, destFeature.properties.lat, destFeature.properties.lon);
// Weighted burn model (v0.16.0): the leg needs enough fuel to cover it arriving empty. // Weighted burn model (v0.16.0): the leg needs enough fuel to cover it arriving empty; wind-
const requiredL = requiredFuelFor(type.burnPerKm, type.emptyWeightKg, type.maxFuel, distanceKm); // adjusted (v0.36.0) by departure weather vs route heading — every call site uses this same factor.
const w = weatherFor(plane.locationIcao);
const burnPerKm = type.burnPerKm * windBurnFactor(w.windKt, w.windDirDeg, initialBearingDeg(origin.lat, origin.lon, destFeature.properties.lat, destFeature.properties.lon));
const requiredL = requiredFuelFor(burnPerKm, type.emptyWeightKg, type.maxFuel, distanceKm);
if (requiredL > plane.fuel) { if (requiredL > plane.fuel) {
throw new GameActionError(409, `insufficient fuel: the leg needs ${Math.round(requiredL)} L but the plane has ${Math.round(plane.fuel)} L`); throw new GameActionError(409, `insufficient fuel: the leg needs ${Math.round(requiredL)} L but the plane has ${Math.round(plane.fuel)} L`);
} }
@@ -153,7 +157,16 @@ export async function queueLeg(inst: GameDb, planeId: string, destIcao: string):
throw new GameActionError(400, `unknown airport ${destIcao}`); throw new GameActionError(400, `unknown airport ${destIcao}`);
} }
// ISS-065: the same route can't be queued twice — re-queuing the active leg's destination or a
// leg already in the queue would only produce pointless repeated hops on landing.
if (plane.status === 'flying' && plane.destIcao === destIcao) {
throw new GameActionError(409, `already flying to ${destIcao}`);
}
const queue = parseIcaoQueue(plane.queue); const queue = parseIcaoQueue(plane.queue);
if (queue.includes(destIcao)) {
throw new GameActionError(409, `${destIcao} is already in the flight queue`);
}
queue.push(destIcao); queue.push(destIcao);
await inst.db.update(schema.planes).set({ queue: JSON.stringify(queue) }).where(eq(schema.planes.id, planeId)); await inst.db.update(schema.planes).set({ queue: JSON.stringify(queue) }).where(eq(schema.planes.id, planeId));
await persist(inst); await persist(inst);
@@ -303,9 +316,10 @@ export async function sellPlane(inst: GameDb, planeId: string): Promise<{ payout
// --- Refuel (POST /api/planes/:planeId/refuel) --- // --- Refuel (POST /api/planes/:planeId/refuel) ---
// Refuel modes (v0.16.0): 'full' fills the tank to maxFuel; 'forFlight' adds just // Refuel modes (v0.16.0): 'full' fills the tank to maxFuel; 'forFlight' buys 150% of
// enough fuel for one loaded package's destination leg — required liters per the // the fuel required for one loaded package's destination leg (ISS-071) — wind-adjusted
// weighted burn model, independent of how much is already in the tank. // requirement per the weighted burn model, capped at tank capacity, independent of how
// much is already in the tank.
export interface RefuelOptions { export interface RefuelOptions {
mode?: 'full' | 'forFlight'; mode?: 'full' | 'forFlight';
packageId?: string; packageId?: string;
@@ -341,15 +355,21 @@ export async function refuelPlane(inst: GameDb, planeId: string, opts: RefuelOpt
const origin = await airportByIcao(plane.locationIcao); const origin = await airportByIcao(plane.locationIcao);
const dest = await airportByIcao(pkg.destIcao); const dest = await airportByIcao(pkg.destIcao);
const distanceKm = haversineKm(origin.lat, origin.lon, dest.lat, dest.lon); const distanceKm = haversineKm(origin.lat, origin.lon, dest.lat, dest.lon);
const requiredL = requiredFuelFor(type.burnPerKm, type.emptyWeightKg, type.maxFuel, distanceKm); // Same wind-adjusted requirement flyPlane checks (v0.36.0) — departure weather vs route heading.
const w = weatherFor(plane.locationIcao);
const requiredL = requiredFuelFor(
type.burnPerKm * windBurnFactor(w.windKt, w.windDirDeg, initialBearingDeg(origin.lat, origin.lon, dest.lat, dest.lon)),
type.emptyWeightKg,
type.maxFuel,
distanceKm,
);
if (requiredL > plane.maxFuel) { if (requiredL > plane.maxFuel) {
throw new GameActionError(409, `the leg to ${pkg.destIcao} needs ${Math.round(requiredL)} L even starting empty — it does not fit a full ${plane.maxFuel} L tank`); throw new GameActionError(409, `the leg to ${pkg.destIcao} needs ${Math.round(requiredL)} L even starting empty — it does not fit a full ${plane.maxFuel} L tank`);
} }
// Land EXACTLY on the leg requirement when topping up: flyPlane re-computes this same value and // ISS-071: buy 150% of the requirement (capped at tank capacity) so burn variance doesn't strand
// compares with strict `>`, so fuel := requiredL guarantees the leg is feasible. The alternative // the plane. The margin makes flyPlane's strict `>` check pass with room to spare; already enough
// (fuel + liters) can round a hair BELOW R in IEEE754 — f + fl(R − f) is not guaranteed ≥ R — and // → free no-op, tank untouched.
// would 409 on an otherwise-feasible leg. Already enough → free no-op, tank untouched. targetFuel = Math.min(plane.maxFuel, Math.max(plane.fuel, requiredL * 1.5));
targetFuel = Math.max(plane.fuel, requiredL);
} else { } else {
// Exactly maxFuel (as before v0.16.0) — clients/tests compare against it with strict equality. // Exactly maxFuel (as before v0.16.0) — clients/tests compare against it with strict equality.
targetFuel = plane.maxFuel; targetFuel = plane.maxFuel;
+14 -3
View File
@@ -1,9 +1,10 @@
import { loadAirportsGeoJson } from '@/lib/airports'; import { loadAirportsGeoJson } from '@/lib/airports';
import { haversineKm } from '@/lib/geo'; import { haversineKm, initialBearingDeg } from '@/lib/geo';
import { weatherFor } from '@/lib/weather';
import { getGameDb } from '@/lib/db/client'; import { getGameDb } from '@/lib/db/client';
import * as schema from '@/lib/db/schema'; import * as schema from '@/lib/db/schema';
import { catalogEntries, FUEL_PRICE_PER_L, PLANE_TYPES, SELL_FRACTION, type CatalogEntry } from './catalog'; import { catalogEntries, FUEL_PRICE_PER_L, PLANE_TYPES, SELL_FRACTION, type CatalogEntry } from './catalog';
import { fuelAtWeighted, progressAt, positionAt } from '@/lib/sim/movement'; import { fuelAtWeighted, progressAt, positionAt, windBurnFactor } from '@/lib/sim/movement';
// Read-only game-state snapshot for client polling (GET /api/state). // Read-only game-state snapshot for client polling (GET /api/state).
@@ -102,7 +103,17 @@ export async function getStateSnapshot(): Promise<StateSnapshot> {
const distanceKm = haversineKm(from.lat, from.lon, to.lat, to.lon); const distanceKm = haversineKm(from.lat, from.lon, to.lat, to.lon);
const progress = progressAt(p.departureSimTime, distanceKm, type.speedKmh, simRow.clockMs); const progress = progressAt(p.departureSimTime, distanceKm, type.speedKmh, simRow.clockMs);
const pos = positionAt(from.lat, from.lon, to.lat, to.lon, progress); const pos = positionAt(from.lat, from.lon, to.lat, to.lon, progress);
return { ...pos, fuel: fuelAtWeighted(p.fuel, type.burnPerKm, type.emptyWeightKg, type.maxFuel, distanceKm, progress) }; // Wind-adjusted burn (v0.36.0) — weather is a pure function of the departure ICAO, so this
// recomputes exactly the factor the engine used for the leg; no stored state needed.
const w = weatherFor(p.locationIcao);
return { ...pos, fuel: fuelAtWeighted(
p.fuel,
type.burnPerKm * windBurnFactor(w.windKt, w.windDirDeg, initialBearingDeg(from.lat, from.lon, to.lat, to.lon)),
type.emptyWeightKg,
type.maxFuel,
distanceKm,
progress,
) };
}; };
return { return {
+38 -1
View File
@@ -1,5 +1,42 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { haversineKm } from './geo'; import { haversineKm, initialBearingDeg } from './geo';
describe('initialBearingDeg', () => {
it('is due north (0) for a point directly above', () => {
expect(initialBearingDeg(0, 0, 10, 0)).toBe(0);
});
it('is due east (90) along the equator', () => {
expect(initialBearingDeg(0, -10, 0, 10)).toBeCloseTo(90, 9);
});
it('is due south (180) for a point directly below', () => {
expect(initialBearingDeg(10, 0, 0, 0)).toBeCloseTo(180, 9);
});
it('is due west (270) along the equator — normalized into [0, 360)', () => {
expect(initialBearingDeg(0, 10, 0, -10)).toBeCloseTo(270, 9);
});
it('always normalizes to [0, 360) for arbitrary pairs', () => {
const pairs: Array<[number, number, number, number]> = [
[50.03, 8.57, 40.47, -3.56], // FRA → MAD (southwest-ish)
[40.47, -3.56, 50.03, 8.57], // reverse: northeast-ish
[65.66, -18.07, 50.03, 8.57], // far north → south-east wrap case
];
for (const [a1, a2, b1, b2] of pairs) {
const brg = initialBearingDeg(a1, a2, b1, b2);
expect(brg).toBeGreaterThanOrEqual(0);
expect(brg).toBeLessThan(360);
}
});
it('FRA → MAD points roughly south-southwest (between 180 and 270)', () => {
const brg = initialBearingDeg(50.03, 8.57, 40.47, -3.56);
expect(brg).toBeGreaterThan(180);
expect(brg).toBeLessThan(270);
});
});
describe('haversineKm', () => { describe('haversineKm', () => {
it('returns 0 for identical points', () => { it('returns 0 for identical points', () => {
+13
View File
@@ -1,5 +1,18 @@
const EARTH_RADIUS_KM = 6371; const EARTH_RADIUS_KM = 6371;
// Initial great-circle bearing from point 1 to point 2 (v0.36.0), degrees true [0, 360).
export function initialBearingDeg(lat1: number, lon1: number, lat2: number, lon2: number): number {
const toRad = (d: number) => (d * Math.PI) / 180;
const phi1 = toRad(lat1);
const phi2 = toRad(lat2);
const dLon = toRad(lon2 - lon1);
const theta = Math.atan2(
Math.sin(dLon) * Math.cos(phi2),
Math.cos(phi1) * Math.sin(phi2) - Math.sin(phi1) * Math.cos(phi2) * Math.cos(dLon),
);
return (theta * 180 / Math.PI + 360) % 360;
}
// Great-circle distance between two WGS84 points (haversine), in km. // Great-circle distance between two WGS84 points (haversine), in km.
export function haversineKm(lat1: number, lon1: number, lat2: number, lon2: number): number { export function haversineKm(lat1: number, lon1: number, lat2: number, lon2: number): number {
const toRad = (d: number) => (d * Math.PI) / 180; const toRad = (d: number) => (d * Math.PI) / 180;
+1 -1
View File
@@ -202,7 +202,7 @@ describe('OpenAPI spec', () => {
expect(errorCodes('/api/packages/{id}/load')).toEqual(['400', '404', '409']); expect(errorCodes('/api/packages/{id}/load')).toEqual(['400', '404', '409']);
expect(errorCodes('/api/packages/{id}/abandon')).toEqual(['404', '409']); expect(errorCodes('/api/packages/{id}/abandon')).toEqual(['404', '409']);
expect(errorCodes('/api/planes/{planeId}/fly')).toEqual(['400', '404', '409']); expect(errorCodes('/api/planes/{planeId}/fly')).toEqual(['400', '404', '409']);
expect(errorCodes('/api/planes/{planeId}/queue')).toEqual(['400', '404']); // v0.25.0 — no 409: queueing works grounded or flying expect(errorCodes('/api/planes/{planeId}/queue')).toEqual(['400', '404', '409']); // v0.34.0 (ISS-065) — 409 on duplicate leg / active-leg re-queue
expect(errorCodes('/api/planes/{planeId}/queue/cancel')).toEqual(['400', '404']); // v0.25.0 expect(errorCodes('/api/planes/{planeId}/queue/cancel')).toEqual(['400', '404']); // v0.25.0
expect(errorCodes('/api/market/buy-plane')).toEqual(['400', '409']); expect(errorCodes('/api/market/buy-plane')).toEqual(['400', '409']);
expect(errorCodes('/api/market/sell-plane')).toEqual(['400', '404', '409']); expect(errorCodes('/api/market/sell-plane')).toEqual(['400', '404', '409']);
+8 -3
View File
@@ -260,6 +260,11 @@ export const openApiSpec = {
description: 'Unknown plane id', description: 'Unknown plane id',
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } }, content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
}, },
409: {
description:
'The destination is already in the flight queue, or (while flying) it equals the active leg\'s destination (v0.34.0, ISS-065)',
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
},
}, },
}, },
}, },
@@ -365,7 +370,7 @@ export const openApiSpec = {
post: { post: {
summary: 'Refuel a grounded plane', summary: 'Refuel a grounded plane',
description: description:
'Refuel a grounded plane at the fuel price per added liter, deducted from cash (v0.8.0). The body is optional (v0.16.0): no body or mode "full" fills to maxFuel; mode "forFlight" with packageId adds just enough for that loaded package\'s destination leg — liters = required fuel for the great-circle distance (weight-proportional burn model) minus what is already in the tank.', 'Refuel a grounded plane at the fuel price per added liter, deducted from cash (v0.8.0). The body is optional (v0.16.0): no body or mode "full" fills to maxFuel; mode "forFlight" with packageId buys 150% of the weather-adjusted fuel required for that loaded package\'s destination leg, capped at tank capacity — liters = min(maxFuel, max(current fuel, 1.5 × required)) − current fuel, where required is the weight-proportional burn model over the great-circle distance with a ±20% wind adjustment (v0.36.0).',
operationId: 'refuelPlane', operationId: 'refuelPlane',
parameters: [ parameters: [
{ name: 'planeId', in: 'path', required: true, schema: { type: 'string' } }, { name: 'planeId', in: 'path', required: true, schema: { type: 'string' } },
@@ -483,7 +488,7 @@ export const openApiSpec = {
label: { type: 'string', description: 'Display name (e.g. "ATR 72-600").' }, label: { type: 'string', description: 'Display name (e.g. "ATR 72-600").' },
price: { type: 'number', description: 'Market price in € (buy cost; sell pays out sellFraction × this).' }, price: { type: 'number', description: 'Market price in € (buy cost; sell pays out sellFraction × this).' },
maxFuel: { type: 'number', description: 'Fuel capacity, liters.' }, maxFuel: { type: 'number', description: 'Fuel capacity, liters.' },
burnPerKm: { type: 'number', description: 'Cruise fuel burn at a full tank, liters/km (v0.16.0 weighted model calibrates to this).' }, burnPerKm: { type: 'number', description: 'Cruise fuel burn at a full tank, liters/km (v0.16.0 weighted model calibrates to this). Actual per-leg burn varies ±20% with wind — departure weather vs route heading (v0.36.0).' },
emptyWeightKg: { type: 'number', description: 'Operating empty weight, kg — with burnPerKm and maxFuel it drives the v0.16.0 weight-proportional burn model.' }, emptyWeightKg: { type: 'number', description: 'Operating empty weight, kg — with burnPerKm and maxFuel it drives the v0.16.0 weight-proportional burn model.' },
speedKmh: { type: 'number', description: 'Cruise speed, km/h.' }, speedKmh: { type: 'number', description: 'Cruise speed, km/h.' },
maxPayloadKg: { type: 'number', description: 'Max payload in kg — total package weight that can be loaded on one plane (v0.27.0, replaces the v0.12.0 slot count).' }, maxPayloadKg: { type: 'number', description: 'Max payload in kg — total package weight that can be loaded on one plane (v0.27.0, replaces the v0.12.0 slot count).' },
@@ -570,7 +575,7 @@ export const openApiSpec = {
mode: { mode: {
enum: ['full', 'forFlight'], enum: ['full', 'forFlight'],
description: description:
'"full" (default) fills the tank to maxFuel; "forFlight" adds just enough fuel for one loaded package\'s destination leg.', '"full" (default) fills the tank to maxFuel; "forFlight" buys 150% of the weather-adjusted fuel required for one loaded package\'s destination leg, capped at tank capacity (v0.36.0).',
}, },
packageId: { packageId: {
type: 'string', type: 'string',
+23 -14
View File
@@ -6,10 +6,11 @@ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { openGameDb } from '@/lib/db/client'; import { openGameDb } from '@/lib/db/client';
import * as schema from '@/lib/db/schema'; import * as schema from '@/lib/db/schema';
import { loadAirportsGeoJson } from '@/lib/airports'; import { loadAirportsGeoJson } from '@/lib/airports';
import { haversineKm } from '@/lib/geo'; import { haversineKm, initialBearingDeg } from '@/lib/geo';
import { weatherFor } from '@/lib/weather';
import { PLANE_TYPES } from '@/lib/game/catalog'; import { PLANE_TYPES } from '@/lib/game/catalog';
import { MARKET_TARGET_OPEN_PACKAGES, mulberry32 } from '@/lib/game/seed'; import { MARKET_TARGET_OPEN_PACKAGES, mulberry32 } from '@/lib/game/seed';
import { arrivalTimeFor, fuelAtWeighted, requiredFuelFor } from './movement'; import { arrivalTimeFor, fuelAtWeighted, requiredFuelFor, windBurnFactor } from './movement';
import { import {
clampTimeScale, clampTimeScale,
deliverPackages, deliverPackages,
@@ -165,7 +166,7 @@ describe('deliverPackages (v0.12.0 arrival delivery)', () => {
}); });
it('delivers in-deadline cargo, unbinds expired cargo for free, keeps other-dest cargo bound', async () => { it('delivers in-deadline cargo, unbinds expired cargo for free, keeps other-dest cargo bound', async () => {
const { inst, to, km, type } = await setupFlight('deliver-mixed.db'); const { inst, from, to, km, type } = await setupFlight('deliver-mixed.db');
const accountBefore = (await inst.db.select().from(schema.account))[0]!; const accountBefore = (await inst.db.select().from(schema.account))[0]!;
// Three packages on the plane: one in-deadline for TO, one expired for TO, // Three packages on the plane: one in-deadline for TO, one expired for TO,
@@ -184,8 +185,11 @@ describe('deliverPackages (v0.12.0 arrival delivery)', () => {
expect(plane.locationIcao).toBe(TO); expect(plane.locationIcao).toBe(TO);
expect(plane.lat).toBe(to.lat); expect(plane.lat).toBe(to.lat);
expect(plane.lon).toBe(to.lon); expect(plane.lon).toBe(to.lon);
// Remaining fuel per the v0.16.0 weight-proportional burn model (closed form). // Remaining fuel per the v0.16.0 weight-proportional burn model (closed form), wind-adjusted
expect(plane.fuel).toBeCloseTo(fuelAtWeighted(type.maxFuel, type.burnPerKm, type.emptyWeightKg, type.maxFuel, km, 1), 6); // (v0.36.0) by the departure airport's weather vs the route heading.
const w = weatherFor(FROM);
const f = windBurnFactor(w.windKt, w.windDirDeg, initialBearingDeg(from.lat, from.lon, to.lat, to.lon));
expect(plane.fuel).toBeCloseTo(fuelAtWeighted(type.maxFuel, type.burnPerKm * f, type.emptyWeightKg, type.maxFuel, km, 1), 6);
expect(plane.departureSimTime).toBeNull(); expect(plane.departureSimTime).toBeNull();
expect(plane.destIcao).toBeNull(); // flight context cleared on landing expect(plane.destIcao).toBeNull(); // flight context cleared on landing
@@ -241,14 +245,19 @@ describe('deliverPackages (v0.12.0 arrival delivery)', () => {
// --- Auto-chain on landing (v0.25.0, ISS-043) --- // --- Auto-chain on landing (v0.25.0, ISS-043) ---
it('auto-chains to the next queued leg when fuel covers it', async () => { it('auto-chains to the next queued leg when fuel covers it', async () => {
const { inst, to, km, type } = await setupFlight('chain-ok.db'); // full tank EDDF→LEMD const { inst, from, to, km, type } = await setupFlight('chain-ok.db'); // full tank EDDF→LEMD
const fc = await loadAirportsGeoJson(); const fc = await loadAirportsGeoJson();
const nextP = fc.features.find((f) => f.properties.icao === 'EHAM')!.properties; const nextP = fc.features.find((f) => f.properties.icao === 'EHAM')!.properties;
const nextKm = haversineKm(to.lat, to.lon, nextP.lat, nextP.lon); const nextKm = haversineKm(to.lat, to.lon, nextP.lat, nextP.lon);
// Precondition sanity: a full-tank departure leaves enough for the LEMD→EHAM hop. // Precondition sanity: a full-tank departure leaves enough for the LEMD→EHAM hop — both legs
const landedFuel = fuelAtWeighted(type.maxFuel, type.burnPerKm, type.emptyWeightKg, type.maxFuel, km, 1); // wind-adjusted (v0.36.0) exactly as deliverPackages computes them.
expect(requiredFuelFor(type.burnPerKm, type.emptyWeightKg, type.maxFuel, nextKm)).toBeLessThanOrEqual(landedFuel); const wFrom = weatherFor(FROM);
const fOut = windBurnFactor(wFrom.windKt, wFrom.windDirDeg, initialBearingDeg(from.lat, from.lon, to.lat, to.lon));
const landedFuel = fuelAtWeighted(type.maxFuel, type.burnPerKm * fOut, type.emptyWeightKg, type.maxFuel, km, 1);
const wTo = weatherFor(TO); // the chained leg departs from the arrival airport
const fNext = windBurnFactor(wTo.windKt, wTo.windDirDeg, initialBearingDeg(to.lat, to.lon, nextP.lat, nextP.lon));
expect(requiredFuelFor(type.burnPerKm * fNext, type.emptyWeightKg, type.maxFuel, nextKm)).toBeLessThanOrEqual(landedFuel);
await inst.db.update(schema.planes) await inst.db.update(schema.planes)
.set({ queue: JSON.stringify(['EHAM', 'EDDV']) }) .set({ queue: JSON.stringify(['EHAM', 'EDDV']) })
@@ -268,14 +277,14 @@ describe('deliverPackages (v0.12.0 arrival delivery)', () => {
}); });
it('grounds when fuel cannot cover the next queued leg, keeping the queue intact', async () => { it('grounds when fuel cannot cover the next queued leg, keeping the queue intact', async () => {
const { inst, km, type } = await setupFlight('chain-nofuel.db'); const { inst, from, to, km, type } = await setupFlight('chain-nofuel.db');
const fc = await loadAirportsGeoJson();
const nextP = fc.features.find((f) => f.properties.icao === 'EHAM')!.properties;
// Depart with exactly the fuel for EDDF→LEMD → lands empty, cannot start LEMD→EHAM. // Depart with exactly the wind-adjusted fuel for EDDF→LEMD (v0.36.0) → lands empty, cannot start LEMD→EHAM.
const w = weatherFor(FROM);
const f = windBurnFactor(w.windKt, w.windDirDeg, initialBearingDeg(from.lat, from.lon, to.lat, to.lon));
await inst.db.update(schema.planes) await inst.db.update(schema.planes)
.set({ .set({
fuel: requiredFuelFor(type.burnPerKm, type.emptyWeightKg, type.maxFuel, km), fuel: requiredFuelFor(type.burnPerKm * f, type.emptyWeightKg, type.maxFuel, km),
queue: JSON.stringify(['EHAM']), queue: JSON.stringify(['EHAM']),
}) })
.where(eq(schema.planes.id, 'plane-starter')); .where(eq(schema.planes.id, 'plane-starter'));
+27 -7
View File
@@ -3,7 +3,8 @@ import { eq, inArray } from 'drizzle-orm';
import { getGameDb, persist, type GameDb } from '@/lib/db/client'; import { getGameDb, persist, type GameDb } from '@/lib/db/client';
import * as schema from '@/lib/db/schema'; import * as schema from '@/lib/db/schema';
import { loadAirportsGeoJson } from '@/lib/airports'; import { loadAirportsGeoJson } from '@/lib/airports';
import { haversineKm } from '@/lib/geo'; import { haversineKm, initialBearingDeg } from '@/lib/geo';
import { weatherFor } from '@/lib/weather';
import { PLANE_TYPES } from '@/lib/game/catalog'; import { PLANE_TYPES } from '@/lib/game/catalog';
import { parseIcaoQueue } from '@/lib/game/state'; import { parseIcaoQueue } from '@/lib/game/state';
import { import {
@@ -13,7 +14,7 @@ import {
type Rng, type Rng,
type SeedAirport, type SeedAirport,
} from '@/lib/game/seed'; } from '@/lib/game/seed';
import { arrivalTimeFor, fuelAtWeighted, requiredFuelFor } from './movement'; import { arrivalTimeFor, fuelAtWeighted, requiredFuelFor, windBurnFactor } from './movement';
// Server-authoritative sim engine (v0.7.0) — tick-on-read: there is NO background // Server-authoritative sim engine (v0.7.0) — tick-on-read: there is NO background
// timer. Every read/action endpoint calls tickNow() first; the clock advances by // timer. Every read/action endpoint calls tickNow() first; the clock advances by
@@ -156,8 +157,17 @@ export async function deliverPackages(inst: GameDb, clockMs: number): Promise<nu
const distanceKm = haversineKm(from.lat, from.lon, to.lat, to.lon); const distanceKm = haversineKm(from.lat, from.lon, to.lat, to.lon);
if (clockMs < arrivalTimeFor(p.departureSimTime, distanceKm, type.speedKmh)) continue; if (clockMs < arrivalTimeFor(p.departureSimTime, distanceKm, type.speedKmh)) continue;
// Arrived: progress is exactly 1 here, so remaining fuel is the full-leg weighted burn. // Arrived: progress is exactly 1 here, so remaining fuel is the full-leg weighted burn —
const landedFuel = fuelAtWeighted(p.fuel, type.burnPerKm, type.emptyWeightKg, type.maxFuel, distanceKm, 1); // wind-adjusted (v0.36.0) by departure weather vs route heading, same factor flyPlane used.
const w = weatherFor(p.locationIcao);
const landedFuel = fuelAtWeighted(
p.fuel,
type.burnPerKm * windBurnFactor(w.windKt, w.windDirDeg, initialBearingDeg(from.lat, from.lon, to.lat, to.lon)),
type.emptyWeightKg,
type.maxFuel,
distanceKm,
1,
);
// Deliver every bound package whose destination is the airport just reached — // Deliver every bound package whose destination is the airport just reached —
// runs whether or not a queued leg chains next (ISS-043). // runs whether or not a queued leg chains next (ISS-043).
@@ -188,9 +198,19 @@ export async function deliverPackages(inst: GameDb, clockMs: number): Promise<nu
if (queue.length > 0) { if (queue.length > 0) {
const nextDest = queue[0]; const nextDest = queue[0];
const next = byIcao.get(nextDest); // unknown ICAO in the queue → cannot chain const next = byIcao.get(nextDest); // unknown ICAO in the queue → cannot chain
if (next && requiredFuelFor(type.burnPerKm, type.emptyWeightKg, type.maxFuel, haversineKm(to.lat, to.lon, next.lat, next.lon)) <= landedFuel) { if (next) {
queue.shift(); // Wind-adjusted requirement for the NEXT leg — it departs from `to` (v0.36.0).
chainedTo = nextDest; const wNext = weatherFor(to.icao);
const nextRequiredL = requiredFuelFor(
type.burnPerKm * windBurnFactor(wNext.windKt, wNext.windDirDeg, initialBearingDeg(to.lat, to.lon, next.lat, next.lon)),
type.emptyWeightKg,
type.maxFuel,
haversineKm(to.lat, to.lon, next.lat, next.lon),
);
if (nextRequiredL <= landedFuel) {
queue.shift();
chainedTo = nextDest;
}
} }
} }
await inst.db.update(schema.planes) await inst.db.update(schema.planes)
+48
View File
@@ -10,6 +10,7 @@ import {
progressAt, progressAt,
requiredFuelFor, requiredFuelFor,
travelDurationMs, travelDurationMs,
windBurnFactor,
} from './movement'; } from './movement';
// Synthetic in-flight fixtures — no DB needed: everything is a pure function of // Synthetic in-flight fixtures — no DB needed: everything is a pure function of
@@ -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)', () => { describe('maxRangeKm (v0.28.0)', () => {
it('ATR 72-600 full tank ≈ 4439 km (±5%)', () => { it('ATR 72-600 full tank ≈ 4439 km (±5%)', () => {
const r = maxRangeKm(BURN, WE, TANK); 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); 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: // 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). // (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 // 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", "name": "flight-ops",
"version": "0.32.0", "version": "0.36.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev -p 3000", "dev": "next dev -p 3000",