Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
11fc6f9d5c | ||
|
|
8f32d5bf81 | ||
|
|
a8233829c3 | ||
|
|
bf6f1b3c95 | ||
|
|
9ce8660fd2 | ||
|
|
3dbac9c549 | ||
|
|
b76acbacac | ||
|
|
479b993c09 | ||
|
|
76f5b6f8ce |
@@ -1,5 +1,24 @@
|
||||
# Changelog
|
||||
|
||||
## v0.49.0 — 2026-09-22 07:25
|
||||
- **Airport popup Relocate now works for flying/repairing planes (ISS-096).** The Relocate row in the map's airport popup (`components/Map.tsx`) previously listed only grounded planes and showed "No grounded planes to relocate." when none was available. It now lists ALL planes from the latest snapshot, and the click handler picks the endpoint by the SELECTED plane's current status: `available` → `POST /api/planes/{id}/fly` (immediate relocation, unchanged), `flying`/`repairing` → `POST /api/planes/{id}/queue` (the destination is appended to the plane's flight queue and chains on landing). Success text is "Relocation started." for fly and "Added to queue." for queue; the no-planes fallback guard is reworded to "No planes to relocate." No API changes — both endpoints existed since v0.25.0/v0.46.0
|
||||
- **Tests.** E2E +1 (`e2e/relocation.spec.ts`): dispatches the starter plane on a leg via the API so it is flying, slows the sim to 1x so the leg cannot land mid-test, opens the airport popup for a different airport, selects the flying plane from the (now full) list and clicks "Relocate here", then asserts via state that the plane's `queue` contains the clicked airport's ICAO while it is still flying its original destination
|
||||
|
||||
## v0.48.0 — 2026-09-22 06:55
|
||||
- **Leg distance shown on contracts (ISS-095).** `PackageState` gains `distanceKm` (`lib/game/state.ts`) — the great-circle origin→dest distance, computed server-side in `getStateSnapshot()` from the airport GeoJSON already loaded for live-plane math (same `haversineKm` as flight movement; 0 if a coord is missing). Shown in two places via new `formatKm()` (`lib/format.ts`, integer km, no space before "km"): the on-board contract line (`EGCC → GMME 554km €23,826.00 …`) and the job-list row (`€37,408.00 215km Perishables 0.4t …`). OpenAPI spec updated in lockstep; nothing stored
|
||||
- **Tests.** Unit: `app/api/state/route.test.ts` asserts every package's `distanceKm` equals the haversine of its airports' coordinates (computed with the same lib/geo helper); `lib/openapi.test.ts` pinned key list gains `distanceKm`; new `formatKm` test in `lib/format.test.ts`. E2E: `right-pane.spec.ts` +2 assertions (job-row distance span matches /^\d+km$/, generic and hook-created package), `actions.spec.ts` +1 (on-board line)
|
||||
|
||||
## v0.47.0 — 2026-09-21 23:01
|
||||
- **Job payoff retuned so weight dominates (ISS-090).** `REWARD_PER_KG` in `lib/game/seed.ts` rises from €0.5/kg to €2.0/kg — the linear form is unchanged, only the constant moves. Before, the weight term was small vs base+distance, so heavier jobs barely paid more (a 1000 km leg: €3,250 @500 kg → €5,000 @4,000 kg — per-kg rate fell from €4.59 to €1.01) and there was no sense in taking heavy jobs; after the retune the same leg pays €4,000 / €11,000, so weight clearly drives payoff. Safe for viability: fuel burn (`lib/sim/movement.ts`) is weight-independent, so this only raises income
|
||||
- **Tests.** `seed.test.ts`: stale test name "… 0.5 * weightKg …" made generic; new literal pins in the `rewardFor` formula test — `rewardFor(1000, 4000) === 11_000` and `rewardFor(1000, 500) === 4_000` — so future retunes are deliberate. All other reward assertions recompute through the exported constants and pass unchanged
|
||||
|
||||
## v0.46.0 — 2026-09-21 22:32
|
||||
- **Queue consumption on dispatch (ISS-088).** `flyPlane` (`lib/game/actions.ts`) now consumes `queue[0]` when the requested destination IS the first queued leg, so a landing never chains a zero-distance self-hop (the "phantom hop" — a→a completing instantly before reaching the real next leg). Flying to a non-first queued leg stays an explicit override and leaves the queue untouched; the persisted queue is written back in the same update
|
||||
- **Engine auto-chain hardening.** `deliverPackages` (`lib/sim/engine.ts`) gains two guards: (1) defensive skip — if `queue[0]` equals the arrival airport (only reachable from legacy/corrupt rows now that dispatch consumes), it drops the leg without flying; (2) full-tank backstop — if the next queued leg does not fit even a full tank, the plane grounds as `'available'` with the queue kept intact instead of chaining an unflyable leg (mirrors `flyPlane`'s 409 without throwing mid-tick)
|
||||
- **Selling a queued plane is rejected.** `sellPlane` (`lib/game/actions.ts`) returns 409 `the plane has queued legs — cancel queued legs first` for a grounded plane with a non-empty queue, instead of silently discarding the queued legs
|
||||
- **Test hook: `/api/test/set-cash`.** New route under `app/api/test/` (sibling of `set-fuel`/`add-cash`) setting account cash to an ABSOLUTE value `{ amount }` — same `FLIGHT_TEST_HOOKS=1` gate, strict JSON validation, post-tick state snapshot. Deliberately NOT in the OpenAPI spec: test hooks are out-of-spec by convention and `lib/openapi.test.ts` pins exactly 13 public paths
|
||||
- **Tests.** Unit +5 (373 total): `flyPlane` consumes queue[0] on dispatch / skip-ahead keeps the queue intact; `sellPlane` 409s a grounded plane with queued legs leaving the row untouched; engine full-tank backstop grounds with the queue intact (FGSL ~4267 km from LEMD — beyond ATR range); engine defensive self-leg drop. E2E: `e2e/queue.spec.ts` +3 — three queued legs fly in FIFO order across landings (dispatch response snapshot proves queue[0] consumed), a mid-flight drained tank is topped up before the chained leg at exactly the rep-discounted cost (expectations computed with the same lib helpers as the engine, via `/api/test/set-fuel` drain + new `set-cash` hook), cancelling the only queued leg mid-flight leaves a plain landing with no auto-chain; `e2e/state.spec.ts` +1 — a staged two-leg queue survives `docker compose restart flight` byte-identical; `e2e/two-planes.spec.ts` reworked — the 240 s poll that absorbed the phantom hop is replaced by explicit assertions: the fly response snapshot shows only `[b]` queued, and every state observed while waiting for the chain must never show location a with dest a
|
||||
|
||||
## v0.45.0 — 2026-09-21 21:12
|
||||
- **Broken planes cannot fly (ISS-089).** A plane at `health ≤ 0` is now grounded until repaired instead of flying on fumes: `flyPlane` (`lib/game/actions.ts`) rejects it with 409 `the plane is broken (health 0) — repair it first` BEFORE any auto-refuel math, so fuel and cash stay untouched; the engine's auto-chain (`deliverPackages`, `lib/sim/engine.ts`) skips chaining when a landing's wear breaks the plane — it stays grounded at the arrival airport with its queue kept intact for a later dispatch (same shape as an unaffordable top-up)
|
||||
- **Repair is gradual.** `repairPlane` no longer jumps health to 100 — it grounds the plane in `'repairing'` with the gate at `clock + REPAIR_DURATION_MS` and leaves health where it was. The engine's `finishRepairs` pass (`lib/sim/engine.ts`) now heals along a linear curve each tick: `health = min(100, max(startHealth, round(f × 100)))` with `f` the fraction of the repair duration elapsed — monotonic (never drops below the start value), and the flip to `'available'` at exactly 100 happens when the gate passes. The left-pane health bar doubles as the repair progress indicator; no UI change
|
||||
|
||||
@@ -2,6 +2,8 @@ import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { loadAirportsGeoJson } from '@/lib/airports';
|
||||
import { haversineKm } from '@/lib/geo';
|
||||
import { FUEL_PRICE_PER_L, PLANE_TYPES, SELL_FRACTION } from '@/lib/game/catalog';
|
||||
import { GET } from './route';
|
||||
|
||||
@@ -58,9 +60,20 @@ describe('GET /api/state', () => {
|
||||
|
||||
const packages = body.packages as Array<Record<string, unknown>>;
|
||||
expect(packages.length).toBeGreaterThan(0);
|
||||
for (const key of ['id', 'originIcao', 'destIcao', 'reward', 'cargoType', 'weightKg', 'deadlineSimTime', 'status']) {
|
||||
for (const key of ['id', 'originIcao', 'destIcao', 'reward', 'cargoType', 'weightKg', 'distanceKm', 'deadlineSimTime', 'status']) {
|
||||
expect(packages[0], `package.${key}`).toHaveProperty(key);
|
||||
}
|
||||
|
||||
// v0.48.0 (ISS-095): distanceKm is computed server-side — it must equal the haversine of the
|
||||
// fixture airports' coordinates for every package in the snapshot.
|
||||
const fc = await loadAirportsGeoJson();
|
||||
const coords = new Map(fc.features.map((f) => [f.properties.icao, f.properties]));
|
||||
for (const pkg of packages) {
|
||||
const from = coords.get(String(pkg.originIcao));
|
||||
const to = coords.get(String(pkg.destIcao));
|
||||
if (!from || !to) continue; // missing coord → 0 fallback (cannot happen with the committed dataset)
|
||||
expect(pkg.distanceKm, `package ${String(pkg.id)} distance`).toBeCloseTo(haversineKm(from.lat, from.lon, to.lat, to.lon), 6);
|
||||
}
|
||||
});
|
||||
|
||||
it('is stable across repeated calls (same seeded DB)', async () => {
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { getGameDb, persist } from '@/lib/db/client';
|
||||
import * as schema from '@/lib/db/schema';
|
||||
import { getStateSnapshot } from '@/lib/game/state';
|
||||
import { tickNow } from '@/lib/sim/engine';
|
||||
|
||||
// TEST-ONLY hook (v0.46.0, ISS-088) — sets the account's cash to an ABSOLUTE value so the e2e suite
|
||||
// can stage exact balances (e.g. drain it below a refuel cost to test grounding, then restore it).
|
||||
// Disabled unless FLIGHT_TEST_HOOKS=1 (set in docker-compose.yml for the e2e instance only); every
|
||||
// other deployment 404s here. Sibling of /api/test/add-cash — same gate, same conventions; add-cash
|
||||
// stays for relative grants, this one is exact.
|
||||
//
|
||||
// POST /api/test/set-cash
|
||||
// body { amount: number } — required; a finite number in [0, ∞) (400 otherwise). Sets
|
||||
// account.cash to exactly that value and returns the full state snapshot after ticking.
|
||||
|
||||
export async function POST(req: Request) {
|
||||
if (process.env.FLIGHT_TEST_HOOKS !== '1') {
|
||||
return Response.json({ error: 'test hooks disabled' }, { status: 404 });
|
||||
}
|
||||
|
||||
let body: unknown = {};
|
||||
try {
|
||||
const text = await req.text();
|
||||
body = text ? JSON.parse(text) : {};
|
||||
} catch {
|
||||
return Response.json({ error: 'invalid JSON body' }, { status: 400 });
|
||||
}
|
||||
if (typeof body !== 'object' || body === null || Array.isArray(body)) {
|
||||
return Response.json({ error: 'body must be a JSON object' }, { status: 400 });
|
||||
}
|
||||
const input = body as Record<string, unknown>;
|
||||
for (const key of Object.keys(input)) {
|
||||
if (key !== 'amount') {
|
||||
return Response.json({ error: `unknown field "${key}"` }, { status: 400 });
|
||||
}
|
||||
}
|
||||
const amount = input.amount;
|
||||
if (typeof amount !== 'number' || !Number.isFinite(amount) || amount < 0) {
|
||||
return Response.json({ error: 'amount is required and must be a finite number >= 0' }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const inst = await getGameDb();
|
||||
await tickNow(); // clock is current before the snapshot we return (same as siblings)
|
||||
const account = (await inst.db.select().from(schema.account))[0];
|
||||
if (!account) throw new Error('account row missing');
|
||||
await inst.db.update(schema.account).set({ cash: Math.round(amount * 100) / 100 });
|
||||
await persist(inst);
|
||||
return Response.json(await getStateSnapshot());
|
||||
} catch (err) {
|
||||
console.error('test hook failed', err);
|
||||
return Response.json({ error: 'test hook failed' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import type { GeoJsonFeatureCollection } from '@/lib/airports';
|
||||
import type { PlaneState, StateSnapshot } from '@/lib/game/state';
|
||||
import { PLANE_TYPES } from '@/lib/game/catalog';
|
||||
import { abandonPenalty } from '@/lib/game/penalty';
|
||||
import { formatCash, formatDeadlineRelative, formatFuel, formatSimClock, formatSpecialLabel } from '@/lib/format';
|
||||
import { formatCash, formatDeadlineRelative, formatFuel, formatKm, formatSimClock, formatSpecialLabel } from '@/lib/format';
|
||||
import { TIME_SCALES, scaleToSlider, sliderToScale } from '@/lib/timeScale';
|
||||
import { useGameAction } from '@/lib/useGameAction';
|
||||
import { useSimClock } from '@/lib/useSimClock';
|
||||
@@ -441,6 +441,8 @@ export default function LeftPane({ state, loading, error, onRefresh, applySnapsh
|
||||
<span data-testid={`onboard-route-${pkg.id}`} className="contract-route">
|
||||
{pkg.originIcao} → {pkg.destIcao}
|
||||
</span>
|
||||
{/* v0.48.0 (ISS-095): leg distance after the route, e.g. "EGCC → GMME 554km". */}
|
||||
<span data-testid={`onboard-distance-${pkg.id}`}>{formatKm(pkg.distanceKm)}</span>
|
||||
{/* v0.37.0 (ISS-073): special-status badge — rendered only when the package has one. */}
|
||||
{pkg.special && (
|
||||
<span data-testid={`package-special-${pkg.id}`} className="special-badge">
|
||||
|
||||
+14
-9
@@ -231,21 +231,22 @@ export default function FlightMap({ state, hovered, applySnapshot }: MapProps) {
|
||||
// deterministic generated weather — no gameplay effects per PLAN.md.
|
||||
const w = weatherFor(p.icao);
|
||||
const localTime = approxLocalTime(p.lon, Date.now());
|
||||
// ISS-010 (v0.18.0): Relocate row — grounded planes from the latest snapshot can be
|
||||
// repositioned to this airport via POST /api/planes/{id}/fly (pure repositioning).
|
||||
// ISS-010 (v0.18.0): Relocate row — every plane from the latest snapshot can be sent to
|
||||
// this airport: grounded ones fly immediately (POST /api/planes/{id}/fly), flying/repairing
|
||||
// ones get the leg appended to their queue (POST /api/planes/{id}/queue, ISS-096 v0.49.0).
|
||||
const st = stateRef.current;
|
||||
const groundedPlanes = st ? st.planes.filter((pl) => pl.status === 'available') : [];
|
||||
const planes = st ? st.planes : [];
|
||||
const planeLabel = (model: string) => st?.catalog.find((c) => c.model === model)?.label ?? model;
|
||||
const relocateHtml =
|
||||
groundedPlanes.length > 0
|
||||
planes.length > 0
|
||||
? `<select data-testid="airport-popup-plane" aria-label="Plane to relocate here">` +
|
||||
`<option value="">Select a plane\u2026</option>` +
|
||||
groundedPlanes.map(
|
||||
planes.map(
|
||||
(pl) =>
|
||||
`<option value="${escapeHtml(pl.id)}">${escapeHtml(planeLabel(pl.model))} \u2014 ${escapeHtml(pl.locationIcao)}</option>`,
|
||||
).join('') +
|
||||
`</select><button type="button" data-testid="airport-popup-relocate" disabled>Relocate here</button>`
|
||||
: `<span data-testid="airport-popup-no-planes">No grounded planes to relocate.</span>`;
|
||||
: `<span data-testid="airport-popup-no-planes">No planes to relocate.</span>`;
|
||||
const popup = new maplibregl.Popup({ offset: 10 })
|
||||
.setLngLat(f.geometry.coordinates as [number, number])
|
||||
.setHTML(
|
||||
@@ -263,7 +264,7 @@ export default function FlightMap({ state, hovered, applySnapshot }: MapProps) {
|
||||
const el = popup.getElement();
|
||||
const select = el?.querySelector<HTMLSelectElement>('[data-testid="airport-popup-plane"]');
|
||||
const button = el?.querySelector<HTMLButtonElement>('[data-testid="airport-popup-relocate"]');
|
||||
// The status span is always in the popup HTML (even with no grounded planes), so it
|
||||
// The status span is always in the popup HTML (even with no planes), so it
|
||||
// exists whenever the select/button do.
|
||||
const statusEl = el?.querySelector<HTMLElement>('[data-testid="airport-popup-relocate-status"]');
|
||||
if (select && button && statusEl) {
|
||||
@@ -276,7 +277,11 @@ export default function FlightMap({ state, hovered, applySnapshot }: MapProps) {
|
||||
button.disabled = true;
|
||||
statusEl.textContent = 'Relocating\u2026';
|
||||
try {
|
||||
const res = await fetch(`/api/planes/${planeId}/fly`, {
|
||||
// ISS-096 (v0.49.0): endpoint follows the SELECTED plane's current status — grounded
|
||||
// planes fly now, flying/repairing ones get the leg queued for after landing.
|
||||
const planeStatus = stateRef.current?.planes.find((pl) => pl.id === planeId)?.status ?? 'available';
|
||||
const action = planeStatus === 'available' ? 'fly' : 'queue';
|
||||
const res = await fetch(`/api/planes/${planeId}/${action}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ destIcao: p.icao }),
|
||||
@@ -285,7 +290,7 @@ export default function FlightMap({ state, hovered, applySnapshot }: MapProps) {
|
||||
const data = (await res.json()) as StateSnapshot & { error?: string };
|
||||
if (!res.ok) throw new Error(data.error ?? `HTTP ${res.status}`);
|
||||
applySnapshotRef.current(data); // shared shell state updates immediately, like the panes' runAction
|
||||
statusEl.textContent = 'Relocation started.';
|
||||
statusEl.textContent = action === 'fly' ? 'Relocation started.' : 'Added to queue.';
|
||||
} catch (err) {
|
||||
statusEl.textContent = err instanceof Error ? err.message : String(err);
|
||||
button.disabled = false; // allow retry / picking another plane
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useState } from 'react';
|
||||
import type { PackageState, PlaneState, StateSnapshot } from '@/lib/game/state';
|
||||
import { MAX_PLANE_DISCOUNT, PLANE_DISCOUNT_PER_REP, PLANE_TYPES, repRate, SELL_FRACTION } from '@/lib/game/catalog';
|
||||
import { maxRangeKm } from '@/lib/sim/movement';
|
||||
import { formatCargo, formatCash, formatDeadlineRelative, formatSpecialLabel } from '@/lib/format';
|
||||
import { formatCargo, formatCash, formatDeadlineRelative, formatKm, formatSpecialLabel } from '@/lib/format';
|
||||
import { useGameAction } from '@/lib/useGameAction';
|
||||
import type { HoverTarget } from './hoverTypes';
|
||||
|
||||
@@ -234,6 +234,8 @@ export default function RightPane({ state, loading, applySnapshot, onHover, onEr
|
||||
</div>
|
||||
<div className="contract-sub">
|
||||
<span data-testid={`open-package-reward-${pkg.id}`}>{formatCash(pkg.reward)}</span>
|
||||
{/* v0.48.0 (ISS-095): leg distance after the reward, e.g. "€37,408.00 215km". */}
|
||||
<span data-testid={`open-package-distance-${pkg.id}`}>{formatKm(pkg.distanceKm)}</span>
|
||||
{/* ISS-066 (v0.35.0): cargo shows as type + tonnage ("Mail 10.0t"), not "type × count". */}
|
||||
<span data-testid={`open-package-cargo-${pkg.id}`}>{formatCargo(pkg.cargoType, pkg.weightKg)}</span>
|
||||
<span data-testid={`open-package-deadline-${pkg.id}`}>
|
||||
|
||||
@@ -42,6 +42,7 @@ function pkg(over: Partial<PackageState> & Pick<PackageState, 'id'>): PackageSta
|
||||
cargoCount: 4,
|
||||
weightKg: 1_000,
|
||||
special: null, // v0.37.0 (ISS-073) — no special status by default in fixtures
|
||||
distanceKm: 1839, // v0.48.0 (ISS-095) — EDDF→LEMD great-circle distance
|
||||
deadlineSimTime: 1_000_000,
|
||||
status: 'open',
|
||||
planeId: null,
|
||||
|
||||
@@ -197,6 +197,8 @@ test('full core loop via UI: load → fly to destination → exact reward + rep
|
||||
await expect(onboardRow, 'loaded package appears in the on-board list').toBeVisible();
|
||||
// ISS-081 (v0.40.0): the on-board route shows ICAO tags, not full airport names.
|
||||
await expect(page.getByTestId(`onboard-route-${pkg.id}`)).toContainText(`${HUB} → ${nearest.icao}`);
|
||||
// v0.48.0 (ISS-095): leg distance on the on-board line, right after the route — integer km.
|
||||
await expect(page.getByTestId(`onboard-distance-${pkg.id}`)).toHaveText(/^\d+km$/);
|
||||
|
||||
// FLY via the UI: click the fly-to button in the on-board list (ISS-081: ICAO label).
|
||||
const flyBtn = page.getByTestId(`fly-to-${pkg.id}`);
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { test, expect, type Page } from '@playwright/test';
|
||||
// Real helpers (v0.46.0) — the refuel expectations must match lib/ exactly; Playwright resolves
|
||||
// the repo's tsconfig paths, so importing beats mirroring a fourth copy of the formulas.
|
||||
import { loadAirportsGeoJson } from '../lib/airports';
|
||||
import { FUEL_DISCOUNT_PER_REP, FUEL_PRICE_PER_L, MAX_FUEL_DISCOUNT, PLANE_TYPES, repRate } from '../lib/game/catalog';
|
||||
import { haversineKm, initialBearingDeg } from '../lib/geo';
|
||||
import { weatherFor } from '../lib/weather';
|
||||
import { fuelAtWeighted, requiredFuelFor, windBurnFactor } from '../lib/sim/movement';
|
||||
|
||||
// v0.26.0 — ISS-043 part 2 (UI): the flight queue. A plane's not-yet-started legs show up in a
|
||||
// Queue sub-section of its on-board block (cancel per leg; adding happens via fly-to / Relocate
|
||||
@@ -14,7 +21,9 @@ const QUEUE = 'EHAM'; // Amsterdam — a third airport, distinct from hub and de
|
||||
|
||||
interface PlaneState {
|
||||
id: string;
|
||||
model: string;
|
||||
status: 'available' | 'flying';
|
||||
fuel: number;
|
||||
maxFuel: number;
|
||||
locationIcao: string;
|
||||
destIcao: string | null;
|
||||
@@ -78,6 +87,17 @@ async function setFuelFull(page: Page, planeId: string): Promise<void> {
|
||||
expect((await page.request.post('/api/test/set-fuel', { data: { planeId, fuel: p.maxFuel } })).status()).toBe(200);
|
||||
}
|
||||
|
||||
// Test hook (FLIGHT_TEST_HOOKS=1): set a plane's tank to an exact level — the engine computes the
|
||||
// landed fuel from the row value at arrival, so draining mid-flight stages a known landing state.
|
||||
async function setFuel(page: Page, planeId: string, fuel: number): Promise<void> {
|
||||
expect((await page.request.post('/api/test/set-fuel', { data: { planeId, fuel } })).status()).toBe(200);
|
||||
}
|
||||
|
||||
// Test hook (FLIGHT_TEST_HOOKS=1): set the account's cash to an exact value (v0.46.0).
|
||||
async function setCash(page: Page, amount: number): Promise<void> {
|
||||
expect((await page.request.post('/api/test/set-cash', { data: { amount } })).status()).toBe(200);
|
||||
}
|
||||
|
||||
// Self-heal: let any in-flight leg finish, reposition plane-starter home via the API, and abandon
|
||||
// leftover cargo so every test starts from the canonical state even after an interrupted run.
|
||||
async function flyHomeViaApi(page: Page): Promise<void> {
|
||||
@@ -450,3 +470,139 @@ test('landing auto-starts the next queued leg (ISS-043 chain end-to-end)', async
|
||||
return !!p && p.status === 'available' && p.locationIcao === HUB;
|
||||
}, { timeout: 120_000, message: `waiting for the chained leg to land back at ${HUB}` }).toBe(true);
|
||||
});
|
||||
|
||||
// --- v0.46.0 (ISS-088): queue consumption on dispatch + refuel between legs ---
|
||||
|
||||
test('three queued legs fly in FIFO order across landings (v0.46.0)', async ({ page }) => {
|
||||
test.setTimeout(300_000); // self-heal + three chained legs at scale 1000
|
||||
await flyHomeViaApi(page);
|
||||
|
||||
await setFuelFull(page, 'plane-starter');
|
||||
|
||||
// Pre-build a 3-leg route while grounded: LEMD → EHAM → back to the hub.
|
||||
for (const icao of [DEST, QUEUE, HUB]) {
|
||||
expect((await page.request.post('/api/planes/plane-starter/queue', { data: { destIcao: icao } })).status()).toBe(200);
|
||||
}
|
||||
|
||||
// Start leg 1 — the response snapshot must show queue[0] CONSUMED (v0.46.0): no phantom self-hop on landing.
|
||||
const flyRes = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: DEST } });
|
||||
expect(flyRes.status()).toBe(200);
|
||||
const p0 = ((await flyRes.json()) as StateBody).planes.find((x) => x.id === 'plane-starter')!;
|
||||
expect(p0.status).toBe('flying');
|
||||
expect(p0.destIcao).toBe(DEST);
|
||||
expect(p0.queue, 'queue[0] consumed on dispatch — the rest stays in order').toEqual([QUEUE, HUB]);
|
||||
|
||||
// Landing at DEST auto-chains EHAM (leg 2)…
|
||||
await setScale(page, SCALE);
|
||||
await expect.poll(async () => {
|
||||
const x = (await getState(page)).planes.find((y) => y.id === 'plane-starter');
|
||||
return !!x && x.status === 'flying' && x.destIcao === QUEUE;
|
||||
}, { timeout: 120_000, message: `waiting for the auto-chained leg ${DEST} → ${QUEUE}` }).toBe(true);
|
||||
|
||||
// …and landing at EHAM chains the return to HUB (leg 3).
|
||||
await expect.poll(async () => {
|
||||
const x = (await getState(page)).planes.find((y) => y.id === 'plane-starter');
|
||||
return !!x && x.status === 'flying' && x.destIcao === HUB;
|
||||
}, { timeout: 120_000, message: `waiting for the auto-chained leg ${QUEUE} → ${HUB}` }).toBe(true);
|
||||
|
||||
await expect.poll(async () => {
|
||||
const x = (await getState(page)).planes.find((y) => y.id === 'plane-starter');
|
||||
return !!x && x.status === 'available' && x.locationIcao === HUB;
|
||||
}, { timeout: 120_000, message: `waiting for the chained leg to land back at ${HUB}` }).toBe(true);
|
||||
|
||||
expect((await getState(page)).planes.find((x) => x.id === 'plane-starter')!.queue).toEqual([]); // all three consumed in order
|
||||
});
|
||||
|
||||
test('a drained tank is topped up before a chained leg at exactly the rep-discounted cost (v0.46.0)', async ({ page }) => {
|
||||
test.setTimeout(300_000); // self-heal + two legs at scale 1000
|
||||
await flyHomeViaApi(page);
|
||||
|
||||
const s0 = await getState(page);
|
||||
const type = PLANE_TYPES.atr72; // the starter model (STARTER_MODEL)
|
||||
expect(s0.planes.find((x) => x.id === 'plane-starter')!.model).toBe('atr72'); // sanity: the catalog mirror is valid
|
||||
|
||||
// Full tank → leg 1 dispatch is a free no-op top-up, so only the CHAIN refuel moves cash. Stage an
|
||||
// exact balance (v0.46.0 hook) so the expectation holds regardless of what earlier tests earned.
|
||||
await setFuelFull(page, 'plane-starter');
|
||||
const STAGED_CASH = 20_000;
|
||||
await setCash(page, STAGED_CASH);
|
||||
const q = await page.request.post('/api/planes/plane-starter/queue', { data: { destIcao: QUEUE } });
|
||||
expect(q.status()).toBe(200);
|
||||
|
||||
const flyRes = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: DEST } });
|
||||
expect(flyRes.status()).toBe(200);
|
||||
const cashBeforeChain = ((await flyRes.json()) as StateBody).account.cash;
|
||||
expect(cashBeforeChain).toBe(STAGED_CASH); // dispatch was a free no-op — the staged balance is intact
|
||||
|
||||
// Drain the tank mid-flight — the engine computes the landed fuel from this row value at arrival.
|
||||
await setFuel(page, 'plane-starter', 0);
|
||||
|
||||
// Expected chain refuel from the SAME helpers the engine uses (wind-adjusted, v0.36.0): leg 1 lands
|
||||
// empty; leg DEST→QUEUE needs a top-up to min(maxFuel, max(landed, 1.5 × required)).
|
||||
const fc = await loadAirportsGeoJson();
|
||||
const hubP = fc.features.find((f) => f.properties.icao === HUB)!.properties;
|
||||
const destP = fc.features.find((f) => f.properties.icao === DEST)!.properties;
|
||||
const queueP = fc.features.find((f) => f.properties.icao === QUEUE)!.properties;
|
||||
|
||||
const km1 = haversineKm(hubP.lat, hubP.lon, destP.lat, destP.lon);
|
||||
const wHub = weatherFor(HUB);
|
||||
const burn1 = type.burnPerKm * windBurnFactor(wHub.windKt, wHub.windDirDeg, initialBearingDeg(hubP.lat, hubP.lon, destP.lat, destP.lon));
|
||||
const landedFuel = fuelAtWeighted(0, burn1, type.emptyWeightKg, type.maxFuel, km1, 1); // drained → lands empty
|
||||
expect(landedFuel).toBeCloseTo(0, 6);
|
||||
|
||||
const km2 = haversineKm(destP.lat, destP.lon, queueP.lat, queueP.lon);
|
||||
const wDest = weatherFor(DEST); // the chained leg departs from the arrival airport
|
||||
const burn2 = type.burnPerKm * windBurnFactor(wDest.windKt, wDest.windDirDeg, initialBearingDeg(destP.lat, destP.lon, queueP.lat, queueP.lon));
|
||||
const required2 = requiredFuelFor(burn2, type.emptyWeightKg, type.maxFuel, km2);
|
||||
expect(required2).toBeGreaterThan(landedFuel); // the top-up path genuinely runs
|
||||
|
||||
const target = Math.min(type.maxFuel, Math.max(landedFuel, required2 * 1.5));
|
||||
const liters = Math.max(0, target - landedFuel);
|
||||
const rate = repRate(s0.account.reputation, FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT);
|
||||
const expectedCost = Math.round(liters * FUEL_PRICE_PER_L * (1 - rate) * 100) / 100;
|
||||
|
||||
// Landing at DEST auto-chains QUEUE with the top-up…
|
||||
await setScale(page, SCALE);
|
||||
await expect.poll(async () => {
|
||||
const x = (await getState(page)).planes.find((y) => y.id === 'plane-starter');
|
||||
return !!x && x.status === 'flying' && x.destIcao === QUEUE;
|
||||
}, { timeout: 120_000, message: `waiting for the auto-chained leg ${DEST} → ${QUEUE}` }).toBe(true);
|
||||
|
||||
const s = await getState(page);
|
||||
// Exact fuel equality is impossible — this snapshot lands after leg 2's departure burn; the exact top-up level × rep-discounted price is pinned by the cash assertion below.
|
||||
const fuel = s.planes.find((x) => x.id === 'plane-starter')!.fuel;
|
||||
expect(fuel).toBeLessThanOrEqual(target); // can only have burned since departure…
|
||||
expect(fuel).toBeGreaterThan(1.4 * required2); // …and a wrong top-up level (≤ required2, or none at all) lands below this bound
|
||||
expect(s.account.cash).toBeCloseTo(cashBeforeChain - expectedCost, 6); // at exactly the rep-discounted cost
|
||||
|
||||
// Let leg 2 finish so the next test starts from a known airport.
|
||||
await expect.poll(async () => {
|
||||
const x = (await getState(page)).planes.find((y) => y.id === 'plane-starter');
|
||||
return !!x && x.status === 'available' && x.locationIcao === QUEUE;
|
||||
}, { timeout: 120_000, message: `waiting for the chained leg to land at ${QUEUE}` }).toBe(true);
|
||||
});
|
||||
|
||||
test('cancelling the only queued leg mid-flight leaves a plain landing (v0.46.0)', async ({ page }) => {
|
||||
test.setTimeout(300_000); // self-heal + one leg at scale 1000
|
||||
await flyHomeViaApi(page);
|
||||
|
||||
await setFuelFull(page, 'plane-starter');
|
||||
const q = await page.request.post('/api/planes/plane-starter/queue', { data: { destIcao: QUEUE } });
|
||||
expect(q.status()).toBe(200);
|
||||
const flyRes = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: DEST } });
|
||||
expect(flyRes.status()).toBe(200);
|
||||
|
||||
// Cancel the queued leg while airborne — landing at DEST must be a plain grounding, no auto-chain.
|
||||
const cancel = await page.request.post('/api/planes/plane-starter/queue/cancel', { data: { index: 0 } });
|
||||
expect(cancel.status()).toBe(200);
|
||||
|
||||
await setScale(page, SCALE);
|
||||
await expect.poll(async () => {
|
||||
const x = (await getState(page)).planes.find((y) => y.id === 'plane-starter');
|
||||
return !!x && x.status === 'available' && x.locationIcao === DEST;
|
||||
}, { timeout: 120_000, message: `waiting for plane-starter to land at ${DEST}` }).toBe(true);
|
||||
|
||||
const p = (await getState(page)).planes.find((x) => x.id === 'plane-starter')!;
|
||||
expect(p.destIcao).toBeNull(); // no chained leg started…
|
||||
expect(p.queue).toEqual([]); // …and the queue is empty
|
||||
});
|
||||
|
||||
+83
-3
@@ -5,8 +5,9 @@ import { expect, test } from '@playwright/test';
|
||||
// Two UI surfaces drive the EXISTING POST /api/planes/{id}/fly endpoint (pure repositioning has
|
||||
// been allowed since v0.12.0 — no API change): the left-pane Relocate section (ISS-023, v0.21.0)
|
||||
// (relocate-plane / relocate-dest / relocate-go) and the Relocate row inside the airport popup on
|
||||
// the map (airport-popup-plane / airport-popup-relocate). Marker-size regression is covered by
|
||||
// the existing e2e/map.spec.ts click tests, not here.
|
||||
// the map (airport-popup-plane / airport-popup-relocate). Since v0.49.0 (ISS-096) the popup row also
|
||||
// works for FLYING planes — it then appends to POST /api/planes/{id}/queue instead of flying now.
|
||||
// Marker-size regression is covered by the existing e2e/map.spec.ts click tests, not here.
|
||||
|
||||
const SCALE = 1000; // fastest sim speed (v0.14.0 ladder top) — keeps cleanup flights to seconds of real time
|
||||
const HUB = 'EDDF'; // Frankfurt Main — the seeded starter plane's home base (lib/game/seed.ts)
|
||||
@@ -15,7 +16,7 @@ const HUB = 'EDDF'; // Frankfurt Main — the seeded starter plane's home base (
|
||||
const POPUP_AIRPORT = { icao: 'LFRS', lon: -1.61073, lat: 47.153198 };
|
||||
|
||||
interface StateBody {
|
||||
planes: Array<{ id: string; status: string; maxFuel: number; locationIcao: string; destIcao: string | null }>;
|
||||
planes: Array<{ id: string; status: string; maxFuel: number; locationIcao: string; destIcao: string | null; queue: string[] }>;
|
||||
}
|
||||
|
||||
async function getState(page: Page): Promise<StateBody> {
|
||||
@@ -195,3 +196,82 @@ test('airport popup offers Relocate options for grounded planes', async ({ page
|
||||
return p.status === 'flying' && p.destIcao === POPUP_AIRPORT.icao;
|
||||
}, { timeout: 15_000, message: 'waiting for the popup relocate click to take effect (status "flying" + dest set)' }).toBe(true);
|
||||
});
|
||||
|
||||
test('airport popup Relocate queues a leg for a flying plane', async ({ page }) => {
|
||||
test.setTimeout(300_000); // self-heal (worst case ~20 s at 1000×) + one short leg, fast-forwarded in cleanup
|
||||
await flyHomeViaApi(page);
|
||||
|
||||
// Full tank before departure — deterministic baseline (ISS-082 hook; dispatch would auto-refuel anyway).
|
||||
await setFuelFull(page, 'plane-starter');
|
||||
|
||||
// ISS-096: the popup must offer Relocate for a FLYING plane too. Dispatch the starter on a leg via
|
||||
// the API, then slow the sim to 1x so the leg cannot land during the UI steps (at 1x even the
|
||||
// shortest leg takes minutes of real time) — "still flying its original destination" below is
|
||||
// deterministic regardless of how fast the UI moves.
|
||||
const nearest = await nearestToHub(page);
|
||||
expect(nearest.icao).not.toBe(POPUP_AIRPORT.icao); // popup click target must differ from the active leg
|
||||
await setScale(page, 1);
|
||||
|
||||
const flyRes = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: nearest.icao } });
|
||||
expect(flyRes.ok()).toBeTruthy();
|
||||
await expect.poll(async () => {
|
||||
const b = await getState(page);
|
||||
const p = b.planes.find((x) => x.id === 'plane-starter')!;
|
||||
return p.status === 'flying' && p.destIcao === nearest.icao;
|
||||
}, { timeout: 15_000, message: 'waiting for the API dispatch to take effect (status "flying" + dest set)' }).toBe(true);
|
||||
|
||||
await page.goto('/');
|
||||
|
||||
// Same map-ready waits as the grounded popup test above: airport count rendered + a snapshot with
|
||||
// the plane in the planes source — the popup's Relocate row is built from that same latest snapshot.
|
||||
const countEl = page.getByTestId('airport-count');
|
||||
await expect.poll(async () => {
|
||||
const text = (await countEl.textContent()) ?? '';
|
||||
const m = text.match(/(\d+)/);
|
||||
return m ? parseInt(m[1], 10) : -1;
|
||||
}, { timeout: 15_000 }).toBeGreaterThan(0);
|
||||
await expect.poll(async () =>
|
||||
page.evaluate(() => {
|
||||
const m = (window as any).__flightMap;
|
||||
if (!m || !m.getSource('planes')) return false;
|
||||
try {
|
||||
return m.querySourceFeatures('planes').some((f: { properties?: Record<string, unknown> }) => f.properties?.id === 'plane-starter');
|
||||
} catch {
|
||||
return false; // source not ready yet — keep polling
|
||||
}
|
||||
}),
|
||||
{ timeout: 15_000 },
|
||||
).toBe(true);
|
||||
|
||||
const pt = await page.evaluate(({ lon, lat }) => {
|
||||
const map = (window as any).__flightMap;
|
||||
const p = map.project([lon, lat]);
|
||||
const rect = map.getContainer().getBoundingClientRect();
|
||||
return { x: rect.left + p.x, y: rect.top + p.y };
|
||||
}, POPUP_AIRPORT);
|
||||
await page.mouse.click(pt.x, pt.y);
|
||||
|
||||
const popup = page.getByTestId('airport-popup');
|
||||
await expect(popup).toBeVisible();
|
||||
await expect(popup).toContainText(POPUP_AIRPORT.icao);
|
||||
|
||||
// The Relocate row now lists ALL planes (ISS-096) — the flying starter is selectable even though it
|
||||
// is not grounded; while flying its option shows the DEPARTURE airport.
|
||||
const planeSel = page.getByTestId('airport-popup-plane');
|
||||
await expect(planeSel).toBeVisible();
|
||||
await expect(planeSel.locator('option[value="plane-starter"]')).toHaveText(/ATR 72-600 — EDDF/);
|
||||
const goBtn = page.getByTestId('airport-popup-relocate');
|
||||
await expect(goBtn).toBeDisabled(); // placeholder selected
|
||||
|
||||
await planeSel.selectOption('plane-starter');
|
||||
await expect(goBtn).toBeEnabled();
|
||||
await goBtn.click();
|
||||
|
||||
// The click fires the queue POST asynchronously — poll until it took effect: the leg is APPENDED to
|
||||
// the flight queue while the plane keeps flying its original destination.
|
||||
await expect.poll(async () => {
|
||||
const b = await getState(page);
|
||||
const p = b.planes.find((x) => x.id === 'plane-starter')!;
|
||||
return p.status === 'flying' && p.destIcao === nearest.icao && p.queue.includes(POPUP_AIRPORT.icao);
|
||||
}, { timeout: 15_000, message: 'waiting for the popup relocate click to take effect (queue contains LFRS, still flying original dest)' }).toBe(true);
|
||||
});
|
||||
|
||||
@@ -210,6 +210,8 @@ test('open packages render with names, reward and deadline; Load is enabled at a
|
||||
await expect(rows.first(), 'an open package row appears').toBeVisible({ timeout: 15_000 });
|
||||
await expect(rows.first()).toContainText(/→/);
|
||||
await expect(page.locator('[data-testid^="open-package-reward-"]').first()).toContainText('€');
|
||||
// v0.48.0 (ISS-095): leg distance on the job row — integer km, no space before "km".
|
||||
await expect(page.locator('[data-testid^="open-package-distance-"]').first()).toHaveText(/^\d+km$/);
|
||||
|
||||
// Deterministic target: a hook-created EDDF package — Load is enabled (starter at origin, free slot).
|
||||
const nearest = await nearestToHub(page);
|
||||
@@ -220,6 +222,8 @@ test('open packages render with names, reward and deadline; Load is enabled at a
|
||||
// 4 t is within the starter ATR's 4.8 t maxPayloadKg, so Load stays enabled below.
|
||||
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));
|
||||
// v0.48.0 (ISS-095): the hook-created package's row shows its leg distance too.
|
||||
await expect(page.getByTestId(`open-package-distance-${pkg.id}`)).toHaveText(/^\d+km$/);
|
||||
const loadBtn = page.getByTestId(`load-${pkg.id}`);
|
||||
await expect(loadBtn).toBeEnabled();
|
||||
|
||||
|
||||
@@ -82,3 +82,49 @@ test('state survives a container restart (sim clock + account persisted)', async
|
||||
expect(a.planes.map((p) => p.id).sort()).toEqual(before.planes.map((p) => p.id).sort());
|
||||
expect(a.packages.map((p) => p.id).sort()).toEqual(before.packages.map((p) => p.id).sort());
|
||||
});
|
||||
|
||||
test('queued legs survive a container restart (v0.46.0)', async ({ page }) => {
|
||||
test.setTimeout(180_000);
|
||||
|
||||
// Self-heal: the suite runs serially and every spec leaves plane-starter grounded, but an
|
||||
// interrupted run may not — wait it out before staging a queue (queueLeg works while flying too).
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const res = await page.request.get('/api/state');
|
||||
if (res.status() !== 200) return 'down';
|
||||
const body = (await res.json()) as StateBody;
|
||||
return String(body.planes.find((p) => p.id === 'plane-starter')?.status ?? 'missing');
|
||||
}, { timeout: 120_000, message: 'waiting for plane-starter to be grounded' })
|
||||
.toBe('available');
|
||||
|
||||
// Stage a two-leg queue on the starter…
|
||||
const q1 = await page.request.post('/api/planes/plane-starter/queue', { data: { destIcao: 'LEMD' } });
|
||||
expect(q1.status()).toBe(200);
|
||||
const q2 = await page.request.post('/api/planes/plane-starter/queue', { data: { destIcao: 'EHAM' } });
|
||||
expect(q2.status()).toBe(200);
|
||||
|
||||
execSync(`docker compose ${composeArgs} restart flight`, { cwd: repoRoot, stdio: 'pipe' });
|
||||
|
||||
// Wait for the app to come back, capturing the post-restart snapshot.
|
||||
let after: StateBody | undefined;
|
||||
await expect
|
||||
.poll(async () => {
|
||||
try {
|
||||
const res = await page.request.get('/api/state');
|
||||
if (res.status() === 200) after = (await res.json()) as StateBody;
|
||||
return !!after;
|
||||
} catch {
|
||||
return false; // connection refused / 502 while the container starts
|
||||
}
|
||||
}, { timeout: 120_000, message: 'waiting for /api/state after docker compose restart' })
|
||||
.toBe(true);
|
||||
|
||||
const p = after!.planes.find((x) => x.id === 'plane-starter')!;
|
||||
expect(p.queue).toEqual(['LEMD', 'EHAM']); // the queue persisted byte-identical, in order…
|
||||
|
||||
// Cleanup: cancel both legs so the starter is left with an empty queue.
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const c = await page.request.post('/api/planes/plane-starter/queue/cancel', { data: { index: 0 } });
|
||||
expect(c.status()).toBe(200);
|
||||
}
|
||||
});
|
||||
|
||||
+12
-2
@@ -495,20 +495,30 @@ test('per-plane queues are independent; landing auto-chains the next queued leg'
|
||||
await expect(page.getByTestId(`queue-leg-${secondId}-0`), 'the cancelled leg is gone').toHaveCount(0, { timeout: 15_000 });
|
||||
await expect(starterLeg0, 'the starter queue is untouched by the other plane cancel').toContainText(a.icao);
|
||||
|
||||
// Fly the STARTER to its first queued destination…
|
||||
// Fly the STARTER to its first queued destination — v0.46.0 consumes queue[0] on dispatch, so the
|
||||
// response snapshot must already show only [b] left (previously the leg stayed queued and the
|
||||
// landing chained a zero-distance self-hop a→a before reaching b).
|
||||
const flyRes = await page.request.post(`/api/planes/${STARTER}/fly`, { data: { destIcao: a.icao } });
|
||||
expect(flyRes.status()).toBe(200);
|
||||
const flyBody = (await flyRes.json()) as StateBody;
|
||||
expect(flyBody.planes.find((x) => x.id === STARTER)!.queue, 'queue[0] consumed on dispatch').toEqual([b.icao]);
|
||||
|
||||
// …fast-forward; on landing at a the server AUTO-CHAINS queue[0] (b) in the same tick — but with
|
||||
// the second plane still grounded, ISS-057 slows the sim to 1× on that landing. Wait for the chain…
|
||||
await setScale(page, SCALE);
|
||||
const seen: Array<{ locationIcao: string; destIcao: string | null }> = [];
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const p = (await getState(page)).planes.find((x) => x.id === STARTER);
|
||||
if (p && p.status === 'flying') seen.push({ locationIcao: p.locationIcao, destIcao: p.destIcao });
|
||||
return !!p && p.status === 'flying' && p.destIcao === b.icao;
|
||||
}, { timeout: 240_000, message: `waiting for the auto-chained leg ${a} → ${b} to start` })
|
||||
}, { timeout: 120_000, message: `waiting for the auto-chained leg ${a} → ${b} to start` })
|
||||
.toBe(true);
|
||||
|
||||
// …and NO snapshot may show the phantom self-hop (location a with dest a) — every observed state
|
||||
// is either the outbound leg (HUB→a) or the chained leg (a→b).
|
||||
expect(seen.filter((s) => s.locationIcao === a.icao && s.destIcao === a.icao), 'phantom self-hop a → a').toEqual([]);
|
||||
|
||||
// …then restore speed so the chained leg fast-forwards too.
|
||||
await setScale(page, SCALE);
|
||||
await expect
|
||||
|
||||
@@ -38,6 +38,7 @@ const basePackage = (id: string): PackageState => ({
|
||||
cargoCount: 2,
|
||||
weightKg: 600,
|
||||
special: null, // v0.37.0 (ISS-073) — no special status by default in fixtures
|
||||
distanceKm: 1839, // v0.48.0 (ISS-095) — EDDF→LEMD great-circle distance
|
||||
deadlineSimTime: 0,
|
||||
status: 'open',
|
||||
planeId: null,
|
||||
|
||||
+9
-1
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { formatCargo, formatCash, formatDeadlineRelative, formatDuration, formatFuel, formatSimClock } from './format';
|
||||
import { formatCargo, formatCash, formatDeadlineRelative, formatDuration, formatFuel, formatKm, formatSimClock } from './format';
|
||||
|
||||
describe('formatCash', () => {
|
||||
it('formats EUR with thousands separators and two decimals', () => {
|
||||
@@ -30,6 +30,14 @@ describe('formatCargo', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatKm', () => {
|
||||
it('rounds to integer km with no space before "km" (v0.48.0, ISS-095)', () => {
|
||||
expect(formatKm(554)).toBe('554km');
|
||||
expect(formatKm(214.6)).toBe('215km');
|
||||
expect(formatKm(0)).toBe('0km');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatSimClock', () => {
|
||||
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');
|
||||
|
||||
@@ -16,6 +16,11 @@ export function formatCargo(cargoType: string, weightKg: number): string {
|
||||
return `${cargoType} ${(weightKg / 1000).toFixed(1)}t`;
|
||||
}
|
||||
|
||||
// Leg distance for contract rows (v0.48.0, ISS-095): "554km" — integer km, no space before "km".
|
||||
export function formatKm(km: number): string {
|
||||
return `${Math.round(km)}km`;
|
||||
}
|
||||
|
||||
// Special-status badge label (v0.37.0, ISS-073): "priority" → "Priority".
|
||||
export function formatSpecialLabel(special: string): string {
|
||||
return special.charAt(0).toUpperCase() + special.slice(1);
|
||||
|
||||
@@ -331,6 +331,34 @@ describe('flyPlane', () => {
|
||||
|
||||
await expectActionError(flyPlane(inst, 'plane-starter', 'LPPD'), 409, /insufficient fuel/i);
|
||||
});
|
||||
|
||||
// --- Queue consumption on dispatch (v0.46.0, ISS-088) ---
|
||||
|
||||
it('consumes queue[0] when flying to the first queued leg — no phantom self-hop on landing', async () => {
|
||||
const inst = await freshDb('fly-consume.db');
|
||||
await queueLeg(inst, 'plane-starter', 'LEMD');
|
||||
await queueLeg(inst, 'plane-starter', 'EHAM');
|
||||
|
||||
await flyPlane(inst, 'plane-starter', 'LEMD'); // the first queued leg
|
||||
|
||||
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||
expect(plane.status).toBe('flying');
|
||||
expect(plane.destIcao).toBe('LEMD');
|
||||
expect(JSON.parse(plane.queue)).toEqual(['EHAM']); // queue[0] consumed, the rest kept in order
|
||||
});
|
||||
|
||||
it('keeps the queue intact when skipping ahead to a non-first queued leg (explicit override)', async () => {
|
||||
const inst = await freshDb('fly-skipahead.db');
|
||||
await queueLeg(inst, 'plane-starter', 'LEMD');
|
||||
await queueLeg(inst, 'plane-starter', 'EHAM');
|
||||
|
||||
await flyPlane(inst, 'plane-starter', 'EHAM'); // not the first queued leg — an override
|
||||
|
||||
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||
expect(plane.status).toBe('flying');
|
||||
expect(plane.destIcao).toBe('EHAM');
|
||||
expect(JSON.parse(plane.queue)).toEqual(['LEMD', 'EHAM']); // untouched — FIFO order preserved
|
||||
});
|
||||
});
|
||||
|
||||
describe('queueLeg (v0.25.0, ISS-043)', () => {
|
||||
@@ -671,6 +699,19 @@ describe('sellPlane', () => {
|
||||
expect(ids).toContain('plane-extra'); // row untouched — the cargo stays bound to a real plane
|
||||
});
|
||||
|
||||
it('rejects selling a grounded plane with queued legs with 409 (v0.46.0, ISS-088)', async () => {
|
||||
const inst = await freshDb('sell-queue.db');
|
||||
await inst.db.insert(schema.planes).values({
|
||||
id: 'plane-extra', model: 'atr72', status: 'available', fuel: 100, maxFuel: PLANE_TYPES.atr72.maxFuel,
|
||||
locationIcao: HUB_ICAO, destIcao: null, lat: 50, lon: 8, departureSimTime: null,
|
||||
});
|
||||
await queueLeg(inst, 'plane-extra', 'LEMD');
|
||||
|
||||
await expectActionError(sellPlane(inst, 'plane-extra'), 409, /cancel queued legs first/);
|
||||
const plane = (await inst.db.select().from(schema.planes).where(eq(schema.planes.id, 'plane-extra')))[0];
|
||||
expect(JSON.parse(plane.queue)).toEqual(['LEMD']); // row untouched — the queue stays with a real plane
|
||||
});
|
||||
|
||||
it('rejects selling the last remaining plane with 409', async () => {
|
||||
const inst = await freshDb('sell-last.db'); // only the starter exists
|
||||
await expectActionError(sellPlane(inst, 'plane-starter'), 409, /last remaining/);
|
||||
|
||||
@@ -153,6 +153,12 @@ export async function flyPlane(inst: GameDb, planeId: string, destIcao: string):
|
||||
const sim = (await inst.db.select().from(schema.simState))[0];
|
||||
if (!sim) throw new Error('sim_state row missing');
|
||||
|
||||
// v0.46.0 (ISS-088): flying to the FIRST queued leg consumes it — otherwise the engine would
|
||||
// chain a zero-distance self-hop on landing (queue[0] === the airport just reached). Skip-ahead
|
||||
// to a non-first queued dest is an explicit override: the queue stays intact, FIFO preserved.
|
||||
const queue = parseIcaoQueue(plane.queue);
|
||||
if (queue.length > 0 && queue[0] === destIcao) queue.shift();
|
||||
|
||||
await inst.db.update(schema.planes)
|
||||
.set({
|
||||
status: 'flying',
|
||||
@@ -161,6 +167,7 @@ export async function flyPlane(inst: GameDb, planeId: string, destIcao: string):
|
||||
lat: origin.lat,
|
||||
lon: origin.lon,
|
||||
fuel: target, // ISS-082: the post-top-up level (unchanged when no top-up was needed)
|
||||
queue: JSON.stringify(queue),
|
||||
})
|
||||
.where(eq(schema.planes.id, planeId));
|
||||
if (cost > 0) {
|
||||
@@ -340,6 +347,11 @@ export async function sellPlane(inst: GameDb, planeId: string): Promise<{ payout
|
||||
throw new GameActionError(409, `cannot sell a plane carrying cargo`);
|
||||
}
|
||||
|
||||
// v0.46.0 (ISS-088): selling a plane with queued legs would silently drop them — force an explicit cancel first.
|
||||
if (parseIcaoQueue(plane.queue).length > 0) {
|
||||
throw new GameActionError(409, 'the plane has queued legs — cancel queued legs first');
|
||||
}
|
||||
|
||||
const all = await inst.db.select().from(schema.planes);
|
||||
if (all.length <= 1) throw new GameActionError(409, 'cannot sell the last remaining plane');
|
||||
|
||||
|
||||
@@ -78,7 +78,7 @@ describe('buildSeed', () => {
|
||||
expect(new Set(seed.packages.map((c) => c.id)).size).toBe(seed.packages.length);
|
||||
});
|
||||
|
||||
it('applies the reward formula: round(500 + 2.5 * km + 0.5 * weightKg), × special multiplier', () => {
|
||||
it('applies the reward formula (BASE_REWARD + REWARD_PER_KM * km + REWARD_PER_KG * weightKg) × special multiplier', () => {
|
||||
const seed = seedWith(42);
|
||||
for (const c of seed.packages) {
|
||||
const o = AIRPORTS.find((a) => a.icao === c.originIcao)!;
|
||||
@@ -134,6 +134,9 @@ describe('buildSeed', () => {
|
||||
it('rewardFor applies the documented formula', () => {
|
||||
expect(rewardFor(0, 0)).toBe(BASE_REWARD);
|
||||
expect(rewardFor(1000, 2_000)).toBe(Math.round(BASE_REWARD + REWARD_PER_KM * 1000 + REWARD_PER_KG * 2_000));
|
||||
// v0.47.0 (ISS-090): literal pins so future retunes are deliberate — weight must dominate payoff.
|
||||
expect(rewardFor(1000, 500)).toBe(4_000);
|
||||
expect(rewardFor(1000, 4_000)).toBe(11_000);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+4
-1
@@ -90,9 +90,12 @@ export const MARKET_TARGET_OPEN_PACKAGES = 10;
|
||||
// Reward formula (v0.6.0 distance part, deliberately simple; real revenue/cost
|
||||
// modeling is a later increment; v0.27.0 weight part): reward = BASE_REWARD +
|
||||
// REWARD_PER_KM * great-circle km + REWARD_PER_KG * package weight kg.
|
||||
// v0.47.0 (ISS-090) retune: REWARD_PER_KG 0.5 → 2.0 so the weight term dominates
|
||||
// base+distance — heavier jobs must clearly pay more per kg to be worth taking
|
||||
// (a 1000 km leg went from €3,250 @500 kg / €5,000 @4,000 kg to €4,000 / €11,000).
|
||||
export const BASE_REWARD = 500;
|
||||
export const REWARD_PER_KM = 2.5;
|
||||
export const REWARD_PER_KG = 0.5;
|
||||
export const REWARD_PER_KG = 2.0;
|
||||
|
||||
// Job reward bonus (v0.38.0, ISS-072): reward ×= 1 + repRate(reputation, …) — better jobs at
|
||||
// higher reputation, exactly ×1 at the starting value.
|
||||
|
||||
@@ -62,6 +62,8 @@ export interface PackageState {
|
||||
weightKg: number;
|
||||
// Special status (v0.37.0, ISS-073) — key into lib/game/specials.ts SPECIALS; null = none.
|
||||
special: string | null;
|
||||
// Great-circle leg distance in km (v0.48.0, ISS-095) — computed from airport coords, not stored.
|
||||
distanceKm: number;
|
||||
deadlineSimTime: number;
|
||||
// 'ignored' (v0.29.0, ISS-047 part B) = a dismissed open job — terminal, leaves the market.
|
||||
status: 'open' | 'loaded' | 'delivered' | 'expired' | 'abandoned' | 'ignored';
|
||||
@@ -100,6 +102,12 @@ export async function getStateSnapshot(): Promise<StateSnapshot> {
|
||||
const nameByIcao = new Map(fc.features.map((f) => [f.properties.icao, f.properties.name]));
|
||||
const nameOf = (icao: string) => nameByIcao.get(icao) ?? icao;
|
||||
const coordsByIcao = new Map(fc.features.map((f) => [f.properties.icao, f.properties]));
|
||||
// v0.48.0 (ISS-095): leg distance for the UI — same haversine as live-plane math; 0 if a coord is missing.
|
||||
const legDistanceKm = (originIcao: string, destIcao: string) => {
|
||||
const from = coordsByIcao.get(originIcao);
|
||||
const to = coordsByIcao.get(destIcao);
|
||||
return from && to ? haversineKm(from.lat, from.lon, to.lat, to.lon) : 0;
|
||||
};
|
||||
|
||||
// Analytic movement (v0.7.0): a flying plane's stored lat/lon/fuel are its
|
||||
// departure values — live position and fuel are pure functions of the clock.
|
||||
@@ -162,6 +170,7 @@ export async function getStateSnapshot(): Promise<StateSnapshot> {
|
||||
cargoCount: p.cargoCount,
|
||||
weightKg: p.weightKg,
|
||||
special: p.special,
|
||||
distanceKm: legDistanceKm(p.originIcao, p.destIcao), // v0.48.0 (ISS-095)
|
||||
deadlineSimTime: p.deadlineSimTime,
|
||||
status: p.status,
|
||||
planeId: p.planeId,
|
||||
|
||||
@@ -136,6 +136,7 @@ describe('OpenAPI spec', () => {
|
||||
'deadlineSimTime',
|
||||
'destIcao',
|
||||
'destName',
|
||||
'distanceKm', // v0.48.0 (ISS-095) — great-circle leg distance in km
|
||||
'id',
|
||||
'originIcao',
|
||||
'originName',
|
||||
|
||||
@@ -633,6 +633,7 @@ export const openApiSpec = {
|
||||
cargoCount: { type: 'integer' },
|
||||
weightKg: { type: 'number', description: 'Package weight in kg (v0.27.0) — drives load validation and the per-kg payout part.' },
|
||||
special: { type: ['string', 'null'], description: 'Special status from the specials registry (v0.37.0); null = none.' },
|
||||
distanceKm: { type: 'number', description: 'Great-circle leg distance in km (v0.48.0) — computed server-side from airport coordinates, not stored.' },
|
||||
deadlineSimTime: { type: 'integer', description: 'Sim-clock epoch ms (game time)' },
|
||||
// 'expired' = an open OR loaded package whose deadline passed on a tick;
|
||||
// loaded expired packages keep their slot, deliver no reward, abandon free.
|
||||
@@ -651,6 +652,7 @@ export const openApiSpec = {
|
||||
'cargoType',
|
||||
'cargoCount',
|
||||
'weightKg', // v0.27.0 (ISS-045) — package weight in kg
|
||||
'distanceKm', // v0.48.0 (ISS-095) — great-circle leg distance in km
|
||||
'deadlineSimTime',
|
||||
'status',
|
||||
'planeId',
|
||||
|
||||
@@ -378,6 +378,52 @@ describe('deliverPackages (v0.12.0 arrival delivery)', () => {
|
||||
expect(plane.status).toBe('available'); // unchanged landing behavior…
|
||||
expect(JSON.parse(plane.queue)).toEqual([]); // …and the queue stays empty
|
||||
});
|
||||
|
||||
it('grounds with the queue intact when the next leg does not fit even a full tank (v0.46.0 backstop)', async () => {
|
||||
const { inst, to, km, type } = await setupFlight('chain-nofit.db'); // full tank EDDF→LEMD
|
||||
const fc = await loadAirportsGeoJson();
|
||||
const farP = fc.features.find((f) => f.properties.icao === 'FGSL')!.properties; // ~4267 km from LEMD — beyond ATR range
|
||||
|
||||
// Precondition sanity: the LEMD→FGSL hop genuinely does not fit a full tank (wind-adjusted,
|
||||
// exactly as deliverPackages computes it) — so no top-up could ever make it flyable.
|
||||
const wTo = weatherFor(TO);
|
||||
const fNext = windBurnFactor(wTo.windKt, wTo.windDirDeg, initialBearingDeg(to.lat, to.lon, farP.lat, farP.lon));
|
||||
const nextRequiredL = requiredFuelFor(
|
||||
type.burnPerKm * fNext,
|
||||
type.emptyWeightKg,
|
||||
type.maxFuel,
|
||||
haversineKm(to.lat, to.lon, farP.lat, farP.lon),
|
||||
);
|
||||
expect(nextRequiredL).toBeGreaterThan(type.maxFuel);
|
||||
|
||||
await inst.db.update(schema.planes)
|
||||
.set({ queue: JSON.stringify(['FGSL']) })
|
||||
.where(eq(schema.planes.id, 'plane-starter'));
|
||||
|
||||
const clock = T0 + travelMs(km, type.speedKmh); // exactly at arrival
|
||||
expect(await deliverPackages(inst, clock)).toBe(1);
|
||||
|
||||
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||
expect(plane.status).toBe('available'); // grounded — the unflyable leg was NOT chained…
|
||||
expect(plane.destIcao).toBeNull();
|
||||
expect(JSON.parse(plane.queue)).toEqual(['FGSL']); // …queue kept intact for a later dispatch
|
||||
});
|
||||
|
||||
it('drops a queued leg to the arrival airport without flying — defensive guard for legacy rows (v0.46.0)', async () => {
|
||||
const { inst, km, type } = await setupFlight('chain-selfleg.db'); // full tank EDDF→LEMD
|
||||
// Legacy state: queue[0] is the airport we are about to land at — pre-v0.46.0 this chained a
|
||||
// zero-distance self-hop (the phantom hop). The guard drops it and grounds instead.
|
||||
await inst.db.update(schema.planes)
|
||||
.set({ queue: JSON.stringify(['LEMD', 'EHAM']) })
|
||||
.where(eq(schema.planes.id, 'plane-starter'));
|
||||
|
||||
expect(await deliverPackages(inst, T0 + travelMs(km, type.speedKmh))).toBe(1);
|
||||
|
||||
const plane = (await inst.db.select().from(schema.planes))[0];
|
||||
expect(plane.status).toBe('available'); // grounded — the self-leg was dropped, not flown…
|
||||
expect(plane.destIcao).toBeNull();
|
||||
expect(JSON.parse(plane.queue)).toEqual(['EHAM']); // …the rest of the queue kept for a later dispatch
|
||||
});
|
||||
});
|
||||
|
||||
describe('wear on landed legs (v0.39.0, ISS-069)', () => {
|
||||
|
||||
+12
-1
@@ -246,6 +246,12 @@ export async function deliverPackages(inst: GameDb, clockMs: number): Promise<nu
|
||||
let topUpFuel: number | null = null; // post-top-up fuel for the chained departure, when a top-up happened
|
||||
if (queue.length > 0 && newHealth > 0) {
|
||||
const nextDest = queue[0];
|
||||
// ponytail: defensive guard for legacy rows — a queued leg to the airport we just landed at is a
|
||||
// zero-distance no-op hop; drop it and ground instead of flying. Normal play can't produce this
|
||||
// since v0.46.0 (flyPlane consumes queue[0] on dispatch, every chain shifts its own leg).
|
||||
if (nextDest === to.icao) {
|
||||
queue.shift();
|
||||
} else {
|
||||
const next = byIcao.get(nextDest); // unknown ICAO in the queue → cannot chain
|
||||
if (next) {
|
||||
// Wind-adjusted requirement for the NEXT leg — it departs from `to` (v0.36.0).
|
||||
@@ -256,7 +262,11 @@ export async function deliverPackages(inst: GameDb, clockMs: number): Promise<nu
|
||||
type.maxFuel,
|
||||
haversineKm(to.lat, to.lon, next.lat, next.lon),
|
||||
);
|
||||
if (nextRequiredL <= landedFuel) {
|
||||
// v0.46.0 backstop: a leg that doesn't fit even a full tank is unflyable — ground with the queue
|
||||
// intact, mirroring flyPlane's 409 without throwing in the engine.
|
||||
if (nextRequiredL > type.maxFuel) {
|
||||
// no chain — the grounded update below keeps the queue for a later dispatch
|
||||
} else if (nextRequiredL <= landedFuel) {
|
||||
queue.shift();
|
||||
chainedTo = nextDest;
|
||||
} else {
|
||||
@@ -276,6 +286,7 @@ export async function deliverPackages(inst: GameDb, clockMs: number): Promise<nu
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
await inst.db.update(schema.planes)
|
||||
.set(
|
||||
chainedTo
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "flight-ops",
|
||||
"version": "0.45.0",
|
||||
"version": "0.49.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev -p 3000",
|
||||
|
||||
Reference in New Issue
Block a user