feat: v0.12.0 — contracts → packages: load/fly/abandon gameplay (ATR 72-600 capacity 6, manual flight to any airport, time-scaled abandon penalty 15–60%), one-time DB migration preserving wallet/clock; new endpoints /api/packages/:id/load|abandon + /api/planes/:planeId/fly

This commit is contained in:
2026-09-18 11:02:56 +00:00
parent 0208673da7
commit ab2e9fa54a
49 changed files with 2424 additions and 1409 deletions
+314 -198
View File
@@ -1,239 +1,355 @@
import { test, expect, type Page } from '@playwright/test';
import type { Page, Request } from '@playwright/test';
import { expect, test } from '@playwright/test';
// v0.8.0 player actions against the live origin: THE full core loop — accept a
// contract → dispatch plane-starter → watch it fly at scale 60 → arrival pays the
// exact reward + reputation and grounds the plane with reduced fuel — plus the
// refuel cost check (folded into the loop, where the tank is genuinely partial)
// and the "dispatch without accept" 4xx path. Contracts are created on a known
// short route via POST /api/test/create-contract (FLIGHT_TEST_HOOKS=1 in
// docker-compose.yml), with the destination picked as the closest airport to EDDF
// from /api/airports so arrival is fast at scale 60.
// 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
// via the right pane → click its destination in the on-board list → fly at 60× →
// arrival delivers EXACTLY the reward + rep; abandon deducts exactly the formula
// penalty while grounded; expired packages deliver/abandon with no reward and no
// penalty. Mirrors lib/game/penalty.ts for exact-value assertions.
const SCALE = 60;
const HUB = 'EDDF'; // must match HUB_ICAO in lib/game/seed.ts
// Mirrors of server constants — kept in sync manually (same pattern as sim.spec.ts).
const BURN_PER_KM = 1.75; // atr72, lib/game/catalog.ts
const FUEL_PRICE_PER_L = 0.8; // lib/game/catalog.ts
const SCALE = 60; // fastest sim speed — keeps flights to seconds of real time
const HUB = 'EDDF'; // Frankfurt Main — the seeded starter plane's home base (lib/game/seed.ts)
const FAR = 'LEZL'; // Seville — one-way burn 3174 L > half tank, so the return leg is unfuelable
// Mirrors lib/sim/movement.ts + lib/game/catalog.ts (atr72).
const BURN_PER_KM = 1.75;
const FUEL_PRICE_PER_L = 0.8;
const REP_BASE = 1; // lib/sim/engine.ts
const REP_PER_KM = 0.0005;
interface PlaneState {
id: string;
status: 'available' | 'flying';
fuel: number;
maxFuel: number;
locationIcao: string;
departureSimTime: number | null;
lat: number;
lon: number;
}
interface ContractState {
id: string;
originIcao: string;
destIcao: string;
reward: number;
status: 'open' | 'accepted' | 'completed' | 'expired';
planeId: string | null;
// Mirrors lib/game/penalty.ts — the exact abandon penalty at a given game clock.
function penaltyFor(reward: number, createdSimTime: number, deadlineSimTime: number, clockMs: number): number {
const span = deadlineSimTime - createdSimTime;
const frac = span > 0 ? Math.min(1, Math.max(0, (clockMs - createdSimTime) / span)) : 1;
return Math.round(reward * (0.15 + 0.45 * frac) * 100) / 100;
}
interface StateBody {
account: { cash: number; reputation: number };
simState: { clockMs: number; timeScale: number; paused: boolean };
planes: PlaneState[];
contracts: ContractState[];
}
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
// Mirrors haversineKm in lib/geo.ts (same formula → identical floats).
function haversineKm(lat1: number, lon1: number, lat2: number, lon2: number): number {
const toRad = (d: number) => (d * Math.PI) / 180;
const dLat = toRad(lat2 - lat1);
const dLon = toRad(lon2 - lon1);
const a =
Math.sin(dLat / 2) ** 2 +
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2;
return 2 * 6371 * Math.asin(Math.min(1, Math.sqrt(a)));
planes: Array<{ id: string; status: string; fuel: number; maxFuel: number; locationIcao: string; destIcao: string | null }>;
packages: Array<{
id: string; originIcao: string; destIcao: string; reward: number; cargoType: string; cargoCount: number;
deadlineSimTime: number; status: 'open' | 'loaded' | 'delivered' | 'expired' | 'abandoned'; planeId: string | null; createdSimTime: number;
}>;
}
async function getState(page: Page): Promise<StateBody> {
const res = await page.request.get('/api/state');
expect(res.status()).toBe(200);
expect(res.ok()).toBeTruthy();
return (await res.json()) as StateBody;
}
async function postSim(page: Page, body: unknown) {
return page.request.post('/api/sim', { data: body });
// The e2e instance runs with FLIGHT_TEST_HOOKS=1 (docker-compose.yml) — the hook is live.
async function createPackage(page: Page, from?: string, to?: string, deadlineOffsetMs?: number): Promise<StateBody['packages'][number]> {
const res = await page.request.post('/api/test/create-package', { data: { originIcao: from, destIcao: to, deadlineOffsetMs } });
expect(res.status()).toBe(201);
return (await res.json()) as StateBody['packages'][number];
}
// Leave the live instance in a sane state for the other specs (scale 1, running).
test.afterEach(async ({ page }) => {
const res = await postSim(page, { timeScale: 1, paused: false });
async function postSim(page: Page, body: Record<string, unknown>): Promise<void> {
const res = await page.request.post('/api/sim', { data: body });
expect(res.ok()).toBeTruthy();
}
// Set the time scale and VERIFY from the response that it stuck — if something external ever
// resets the live instance's controls mid-test, fail loudly here instead of timing out later.
async function setScale(page: Page, scale: number): Promise<void> {
const res = await page.request.post('/api/sim', { data: { timeScale: scale } });
expect(res.status()).toBe(200);
});
// Test hook (FLIGHT_TEST_HOOKS=1): create an open contract on a known route.
async function createContract(page: Page, fromIcao: string, toIcao: string): Promise<ContractState> {
const created = await page.request.post('/api/test/create-contract', {
data: { originIcao: fromIcao, destIcao: toIcao, deadlineOffsetMs: 24 * 3_600_000 },
});
expect(created.status()).toBe(201);
return (await created.json()) as ContractState;
const body = (await res.json()) as { timeScale: number };
expect(body.timeScale, `timeScale should be ${scale} after POST /api/sim`).toBe(scale);
}
// Create + accept + dispatch a contract from → to on plane-starter, then poll at
// scale 60 until the plane lands at `to`. Used for the main flight and for
// restoring the starter plane to EDDF between runs.
async function flyContract(page: Page, fromIcao: string, toIcao: string): Promise<ContractState> {
const contract = await createContract(page, fromIcao, toIcao);
const acc = await page.request.post(`/api/contracts/${contract.id}/accept`);
expect(acc.status()).toBe(200);
const disp = await page.request.post('/api/planes/plane-starter/dispatch', { data: { contractId: contract.id } });
expect(disp.status()).toBe(200);
await postSim(page, { timeScale: SCALE });
await expect
.poll(async () => {
const body = await getState(page);
const p = body.planes.find((x) => x.id === 'plane-starter');
return !!p && p.status === 'available' && p.locationIcao === toIcao;
}, { timeout: 90_000, message: `waiting for plane-starter to land at ${toIcao}` })
.toBe(true);
return contract;
}
test('full core loop: accept → dispatch → fly at 60x → exact reward + rep on arrival', async ({ page }) => {
test.setTimeout(240_000); // generous cap — typical run is well under two minutes
// Self-heal first: a previous interrupted run may have left the starter plane
// mid-flight or at another airport. Let any flight finish, then fly it home so
// the loop below starts from the canonical onboarding state (plane at EDDF).
const foundStarter = (await getState(page)).planes.find((p) => p.id === 'plane-starter');
expect(foundStarter).toBeDefined();
let starter: PlaneState = foundStarter!;
if (starter.status === 'flying') {
await postSim(page, { timeScale: SCALE });
await expect
.poll(async () => (await getState(page)).planes.find((p) => p.id === 'plane-starter')?.status === 'available', {
timeout: 90_000,
message: 'waiting for an in-flight plane-starter to land',
})
.toBe(true);
starter = (await getState(page)).planes.find((p) => p.id === 'plane-starter')!;
}
if (starter.locationIcao !== HUB) {
await flyContract(page, starter.locationIcao, HUB);
}
// Baseline AFTER any healing flight — the exact-cash assertion below must not
// include heal rewards.
const before = await getState(page);
// Closest airport to EDDF from /api/airports → shortest possible arrival time.
const fc = (await (await page.request.get('/api/airports')).json()) as {
features: Array<{ properties: { icao: string; lat: number; lon: number } }>;
};
// Closest airport to the hub from /api/airports → shortest possible flight. Returns both
// endpoints' coordinates so exact-km assertions (rep/fuel) can mirror lib/geo.ts.
async function nearestToHub(page: Page): Promise<{ icao: string; lat: number; lon: number; hubLat: number; hubLon: number }> {
const res = await page.request.get('/api/airports');
expect(res.ok()).toBeTruthy();
const fc = (await res.json()) as { features: Array<{ properties: { icao: string; lat: number; lon: number } }> };
const hub = fc.features.find((f) => f.properties.icao === HUB)!.properties;
let nearest = fc.features.filter((f) => f.properties.icao !== HUB)[0].properties;
for (const f of fc.features) {
if (f.properties.icao === HUB) continue;
if (haversineKm(hub.lat, hub.lon, f.properties.lat, f.properties.lon) < haversineKm(hub.lat, hub.lon, nearest.lat, nearest.lon)) {
nearest = f.properties;
const d = Math.hypot(f.properties.lat - hub.lat, f.properties.lon - hub.lon);
const dn = Math.hypot(nearest.lat - hub.lat, nearest.lon - hub.lon);
if (d < dn) nearest = f.properties;
}
return { icao: nearest.icao, lat: nearest.lat, lon: nearest.lon, hubLat: hub.lat, hubLon: hub.lon };
}
// Every spec that touches the time controls restores scale=1 / unpaused on exit so
// a failure mid-test can't leave the live instance fast-forwarded for later specs.
test.afterEach(async ({ page }) => {
await postSim(page, { timeScale: 1, paused: false });
});
test.describe.configure({ mode: 'serial' }); // shared live DB — order matters within this file
// Self-heal helper: let any in-flight leg finish, reposition plane-starter home via the API
// (repositioning needs no package — v0.12.0 manual flight), and abandon any leftover cargo so
// exact cash/rep assertions start from a clean baseline even after an interrupted previous run.
async function flyHomeViaApi(page: Page): Promise<void> {
const s0 = await getState(page);
const plane = s0.planes.find((p) => p.id === 'plane-starter')!;
if (plane.status === 'flying' || plane.locationIcao !== HUB) {
// Fast-forward any leftover leg / repositioning flight (~3 min real time worst case at 60×).
await setScale(page, SCALE);
}
if (plane.status === 'flying') {
// Let the in-flight leg finish before repositioning.
await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')!.status), { timeout: 240_000 }).toBe('available');
}
if (plane.locationIcao !== HUB) {
const res = await page.request.post(`/api/planes/${plane.id}/fly`, { data: { destIcao: HUB } });
expect(res.ok()).toBeTruthy(); // repositioning needs no package (v0.12.0 manual flight)
await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')!.locationIcao), { timeout: 240_000 }).toBe(HUB);
}
// Abandon leftover loaded/expired packages (expired ones cost nothing; loaded ones take a
// penalty — irrelevant, the baseline snapshot is captured AFTER this cleanup).
const s1 = await getState(page);
for (const pkg of s1.packages) {
if (pkg.planeId === 'plane-starter' && (pkg.status === 'loaded' || pkg.status === 'expired')) {
const res = await page.request.post(`/api/packages/${pkg.id}/abandon`);
expect(res.ok()).toBeTruthy();
}
}
const km = haversineKm(hub.lat, hub.lon, nearest.lat, nearest.lon);
}
// 1) Accept the hook-created contract — response is the full snapshot.
const contract = await createContract(page, HUB, nearest.icao);
const acc = await page.request.post(`/api/contracts/${contract.id}/accept`);
expect(acc.status()).toBe(200);
let snap = (await acc.json()) as StateBody;
expect(snap.contracts.find((c) => c.id === contract.id)?.status).toBe('accepted');
// 2) Dispatch plane-starter — snapshot shows it flying with departure set.
const disp = await page.request.post('/api/planes/plane-starter/dispatch', { data: { contractId: contract.id } });
expect(disp.status()).toBe(200);
snap = (await disp.json()) as StateBody;
let plane = snap.planes.find((p) => p.id === 'plane-starter')!;
expect(plane.status).toBe('flying');
expect(typeof plane.departureSimTime).toBe('number'); // departure set at dispatch time
const departureFuel = plane.fuel; // frozen while flying (live fuel is analytic)
// Position moves away from the origin across polls at scale 60.
await postSim(page, { timeScale: SCALE });
let prevDist = -1;
for (let i = 0; i < 3; i++) {
await sleep(700);
snap = await getState(page);
plane = snap.planes.find((p) => p.id === 'plane-starter')!;
const distFromOrigin = haversineKm(hub.lat, hub.lon, plane.lat, plane.lon);
expect(distFromOrigin).toBeGreaterThan(prevDist); // strictly along the arc
prevDist = distFromOrigin;
test('full core loop via UI: load → fly to destination → exact reward + rep on arrival', async ({ page }) => {
test.setTimeout(300_000); // self-heal (worst case ~3 min at 60×) + two short flights + refuel
// Self-heal: the starter plane may be anywhere from a previous run/spec.
await flyHomeViaApi(page);
const before = await getState(page);
expect(before.planes.find((p) => p.id === 'plane-starter')!.locationIcao).toBe(HUB);
// Nearest airport to the hub → shortest possible flight (fastest e2e loop).
const nearest = await nearestToHub(page);
// A known short package at the hub with a generous deadline.
const pkg = await createPackage(page, HUB, nearest.icao, 24 * 3_600_000);
expect(pkg.originIcao).toBe(HUB);
expect(pkg.destIcao).toBe(nearest.icao);
// LOAD via the UI: exactly one eligible plane (the starter at EDDF) → direct button.
await page.goto('/');
const loadBtn = page.getByTestId(`load-${pkg.id}`);
await expect(loadBtn, 'Load button enabled for a package at the hub').toBeEnabled();
await loadBtn.click();
// The action endpoint's full snapshot is applied immediately (applySnapshot) — the
// package shows as loaded on the starter in the on-board section.
const onboardRow = page.getByTestId(`onboard-package-${pkg.id}`);
await expect(onboardRow, 'loaded package appears in the on-board list').toBeVisible();
// FLY via the UI: click the destination name button in the on-board list.
const flyBtn = page.getByTestId(`fly-to-${pkg.id}`);
await expect(flyBtn).toBeEnabled();
await flyBtn.click();
let s = await getState(page);
const planeId = 'plane-starter';
expect(s.planes.find((p) => p.id === planeId)!.status).toBe('flying');
expect(s.planes.find((p) => p.id === planeId)!.destIcao).toBe(nearest.icao);
// Fast-forward to arrival (≤ ~10 min game time at 60× → seconds of real time).
await setScale(page, SCALE);
await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === planeId)!.status), { timeout: 120_000 }).toBe('available');
s = await getState(page);
const landed = s.planes.find((p) => p.id === planeId)!;
expect(landed.locationIcao).toBe(nearest.icao); // arrived at the package's destination
expect(landed.destIcao).toBeNull(); // flight context cleared on landing
// EXACT reward: cash increased by exactly the package's payout…
const delivered = s.packages.find((p) => p.id === pkg.id)!;
expect(delivered.status).toBe('delivered');
expect(s.account.cash).toBeCloseTo(before.account.cash + pkg.reward, 6);
// …and rep gained per the engine formula (great-circle km of the leg just flown),
// mirroring lib/sim/engine.ts's double rounding (gain rounded to cents, then total).
const exactKm = haversineKm(nearest.hubLat, nearest.hubLon, nearest.lat, nearest.lon);
const repGain = Math.round((REP_BASE + REP_PER_KM * exactKm) * 100) / 100;
expect(s.account.reputation).toBeCloseTo(Math.round((before.account.reputation + repGain) * 100) / 100, 9);
// Fuel burned exactly burnPerKm × km (analytic movement — no rounding drift).
expect(landed.fuel).toBeCloseTo(Math.max(0, before.planes.find((p) => p.id === planeId)!.fuel - BURN_PER_KM * exactKm), 6);
// Refuel via the API: cost = (maxFuel − fuel) × FUEL_PRICE_PER_L, deducted exactly.
const refuelRes = await page.request.post(`/api/planes/${planeId}/refuel`);
expect(refuelRes.ok()).toBeTruthy();
s = await getState(page);
const afterRefuel = s.planes.find((p) => p.id === planeId)!;
expect(afterRefuel.fuel).toBe(afterRefuel.maxFuel); // back to full
const refuelCost = Math.round((afterRefuel.maxFuel - landed.fuel) * FUEL_PRICE_PER_L * 100) / 100;
expect(s.account.cash).toBeCloseTo(before.account.cash + pkg.reward - refuelCost, 6);
// Restore: fly home (repositioning — no package needed in the v0.12.0 model).
const homeRes = await page.request.post(`/api/planes/${planeId}/fly`, { data: { destIcao: HUB } });
expect(homeRes.ok()).toBeTruthy();
await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === planeId)!.locationIcao), { timeout: 120_000 }).toBe(HUB);
});
test('abandon while grounded deducts exactly the formula penalty', async ({ page }) => {
test.setTimeout(120_000);
await flyHomeViaApi(page);
const before = await getState(page);
// Two packages at the hub — load both, abandon one through the UI.
const pkgA = await createPackage(page, HUB, 'LEMD', 24 * 3_600_000);
const pkgB = await createPackage(page, HUB, 'LEMD', 24 * 3_600_000);
for (const p of [pkgA, pkgB]) {
const res = await page.request.post(`/api/packages/${p.id}/load`, { data: { planeId: 'plane-starter' } });
expect(res.ok()).toBeTruthy();
}
// 3) Poll until arrival (plane grounded at the destination).
let arrived: StateBody | undefined;
await expect
.poll(async () => {
snap = await getState(page);
const p = snap.planes.find((x) => x.id === 'plane-starter');
if (p && p.status === 'available' && p.locationIcao === nearest.icao) arrived = snap;
return !!arrived;
}, { timeout: 90_000, message: `waiting for arrival at ${nearest.icao} (scale 60)` })
.toBe(true);
// Abandon A via the UI — capture the endpoint's OWN response so the penalty is checked
// against the exact game clock the server used (a later GET would tick the clock further).
await page.goto('/');
const abandonBtn = page.getByTestId(`abandon-${pkgA.id}`);
await expect(abandonBtn).toBeEnabled();
const [res] = await Promise.all([
page.waitForResponse((r) => r.url().includes(`/api/packages/${pkgA.id}/abandon`), { timeout: 15_000 }),
abandonBtn.click(),
]);
expect(res.status()).toBe(200);
const s = (await res.json()) as StateBody; // full post-action snapshot at the penalty's clock
// 4) Exact reward + reputation gain; plane grounded with the analytic remaining fuel.
const after = arrived!;
expect(after.account.cash).toBeCloseTo(before.account.cash + contract.reward, 6);
const expectedPenalty = penaltyFor(pkgA.reward, pkgA.createdSimTime, pkgA.deadlineSimTime, s.simState.clockMs);
expect(expectedPenalty).toBeGreaterThanOrEqual(Math.round(pkgA.reward * 0.15 * 100) / 100); // at/above the floor
const abandoned = s.packages.find((p) => p.id === pkgA.id)!;
expect(abandoned.status).toBe('abandoned');
expect(abandoned.planeId).toBeNull(); // slot freed
expect(s.account.cash).toBeCloseTo(before.account.cash - expectedPenalty, 6);
const repGain = Math.round((REP_BASE + REP_PER_KM * km) * 100) / 100; // mirrors reputationGainFor
expect(after.account.reputation).toBeCloseTo(Math.round((before.account.reputation + repGain) * 100) / 100, 6);
plane = after.planes.find((p) => p.id === 'plane-starter')!;
expect(plane.status).toBe('available');
expect(plane.locationIcao).toBe(nearest.icao);
expect(plane.departureSimTime).toBeNull(); // flight context cleared on landing
expect(plane.fuel).toBeCloseTo(Math.max(0, departureFuel - BURN_PER_KM * km), 3);
const done = after.contracts.find((c) => c.id === contract.id)!;
expect(done.status).toBe('completed');
expect(done.planeId).toBe('plane-starter');
// 5) Refuel at the destination: cost is exactly (maxFuel − fuel) × FUEL_PRICE_PER_L.
const cashBeforeRefuel = after.account.cash;
const expectedCost = Math.round((plane.maxFuel - plane.fuel) * FUEL_PRICE_PER_L * 100) / 100;
expect(expectedCost).toBeGreaterThan(0); // the flight genuinely burned fuel
const refuel = await page.request.post('/api/planes/plane-starter/refuel');
expect(refuel.status()).toBe(200);
const afterRefuel = (await refuel.json()) as StateBody;
expect(afterRefuel.account.cash).toBeCloseTo(cashBeforeRefuel - expectedCost, 6);
expect(afterRefuel.planes.find((p) => p.id === 'plane-starter')!.fuel).toBe(plane.maxFuel);
// 6) Fly home so the live instance stays in its onboarding state for the next run.
await flyContract(page, nearest.icao, HUB);
// B is still on board — abandon it via the API to leave a clean state.
const resB = await page.request.post(`/api/packages/${pkgB.id}/abandon`);
expect(resB.ok()).toBeTruthy();
});
test('dispatch without accept is rejected with 409; unknown contract id with 404', async ({ page }) => {
const body = await getState(page);
const starter = body.planes.find((p) => p.id === 'plane-starter')!;
test('expired packages deliver and abandon with no reward and no penalty', async ({ page }) => {
test.setTimeout(480_000); // two ~3 min real-time legs at 60× (HUB↔LEMD) plus expiry wait
await flyHomeViaApi(page);
const before = await getState(page);
// A contract originating where the plane actually sits — so ONLY the missing
// accept is what blocks dispatch.
const created = await page.request.post('/api/test/create-contract', {
data: { originIcao: starter.locationIcao, destIcao: HUB === starter.locationIcao ? 'LEMD' : HUB },
});
expect(created.status()).toBe(201);
const contract = (await created.json()) as ContractState;
// A: load, let it expire while loaded, then FLY to its destination — arrival must
// unbind it WITHOUT paying out. B: load, let it expire, abandon for free. B's
// destination differs from A's so it stays bound on the plane at LEMD arrival and can
// still be abandoned (an expired package whose dest matches the arrival airport is
// unbound by the tick instead).
const pkgA = await createPackage(page, HUB, 'LEMD', 10_000); // short game-time deadline
const pkgB = await createPackage(page, HUB, 'EHAM', 10_000);
for (const p of [pkgA, pkgB]) {
const res = await page.request.post(`/api/packages/${p.id}/load`, { data: { planeId: 'plane-starter' } });
expect(res.ok()).toBeTruthy();
}
const noAccept = await page.request.post('/api/planes/plane-starter/dispatch', { data: { contractId: contract.id } });
expect(noAccept.status()).toBe(409);
expect(((await noAccept.json()) as { error: string }).error).toMatch(/open/);
// Fast-forward past both deadlines — the tick flips loaded → expired (slot kept).
await setScale(page, SCALE);
let s = await getState(page);
await expect.poll(async () => ((await getState(page)).packages.find((p) => p.id === pkgA.id)!.status), { timeout: 30_000 }).toBe('expired');
s = await getState(page);
expect(s.packages.find((p) => p.id === pkgB.id)!.status).toBe('expired');
const unknown = await page.request.post('/api/planes/plane-starter/dispatch', { data: { contractId: 'nope' } });
expect(unknown.status()).toBe(404);
// Fly to the destination — arrival unbinds A for free (no reward, no rep).
const flyRes = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: 'LEMD' } });
expect(flyRes.ok()).toBeTruthy();
await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')!.status), { timeout: 240_000 }).toBe('available');
s = await getState(page);
const landedA = s.packages.find((p) => p.id === pkgA.id)!;
expect(landedA.status).toBe('expired'); // still expired — never delivered…
expect(landedA.planeId).toBeNull(); // …but the slot was freed on arrival
expect(s.account.cash).toBe(before.account.cash); // NO reward for expired cargo
expect(s.account.reputation).toBe(before.account.reputation); // no rep either
// B: abandon an expired package while grounded — penalty is exactly €0.
const cashBeforeAbandon = s.account.cash;
const resB = await page.request.post(`/api/packages/${pkgB.id}/abandon`);
expect(resB.ok()).toBeTruthy();
s = await getState(page);
expect(s.packages.find((p) => p.id === pkgB.id)!.status).toBe('abandoned');
expect(s.account.cash).toBe(cashBeforeAbandon); // free — expired abandons cost nothing
// Restore: refuel + fly home (scale is still SCALE from the expiry wait above).
await page.request.post('/api/planes/plane-starter/refuel');
const homeRes = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
expect(homeRes.ok()).toBeTruthy();
await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')!.locationIcao), { timeout: 240_000 }).toBe(HUB);
});
test('load and fly validation paths (404 / 409 / 400)', async ({ page }) => {
test.setTimeout(600_000); // the FAR round trip alone is ~7.5 min real time at 60×
await flyHomeViaApi(page);
// Load a package whose origin the plane is not at → 409.
const pkg = await createPackage(page, 'LEMD', HUB, 24 * 3_600_000); // originates in Madrid, plane sits in Frankfurt
let res = await page.request.post(`/api/packages/${pkg.id}/load`, { data: { planeId: 'plane-starter' } });
expect(res.status()).toBe(409);
expect(((await res.json()) as { error: string }).error).toMatch(/at EDDF/);
// Load an unknown package → 404.
res = await page.request.post('/api/packages/no-such-package/load', { data: { planeId: 'plane-starter' } });
expect(res.status()).toBe(404);
// Fly to an ICAO not in the dataset → 400.
res = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: 'ZZZZ' } });
expect(res.status()).toBe(400);
expect(((await res.json()) as { error: string }).error).toMatch(/unknown airport/);
// Fly while already flying → 409 (short hop leg on the nearest route, then let it finish).
const hopDest = await nearestToHub(page);
res = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: hopDest.icao } });
expect(res.ok()).toBeTruthy();
const again = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
expect(again.status()).toBe(409);
await setScale(page, SCALE);
await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')!.locationIcao), { timeout: 120_000 }).toBe(hopDest.icao);
// Abandon an open package → 409 (nothing loaded to abandon).
res = await page.request.post(`/api/packages/${pkg.id}/abandon`);
expect(res.status()).toBe(409);
// Fly home from the hop so the fuel section starts at the hub.
res = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
expect(res.ok()).toBeTruthy();
await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')!.locationIcao), { timeout: 120_000 }).toBe(HUB);
// Insufficient fuel: a full-tank one-way burn of > half the tank makes the return leg unfuelable.
const s0 = await getState(page);
const starter = s0.planes.find((p) => p.id === 'plane-starter')!;
expect(starter.locationIcao).toBe(HUB); // landed back at the hub from the hop above
if (starter.fuel < starter.maxFuel) {
await page.request.post('/api/planes/plane-starter/refuel'); // ensure a full tank for the long leg
}
const farRes = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: FAR } });
expect(farRes.ok()).toBeTruthy();
await setScale(page, SCALE);
await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')!.status), { timeout: 240_000 }).toBe('available');
const sFar = await getState(page);
const atFar = sFar.planes.find((p) => p.id === 'plane-starter')!;
expect(atFar.locationIcao).toBe(FAR);
// The return leg needs the same burn as the outbound — more than what's left.
const backRes = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
expect(backRes.status()).toBe(409);
expect(((await backRes.json()) as { error: string }).error).toMatch(/insufficient fuel/i);
// Restore: refuel + fly home.
await page.request.post('/api/planes/plane-starter/refuel');
const homeRes = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
expect(homeRes.ok()).toBeTruthy();
await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')!.locationIcao), { timeout: 240_000 }).toBe(HUB);
});
// Great-circle distance in km — mirrors lib/geo.ts (same formula, same R).
function haversineKm(lat1: number, lon1: number, lat2: number, lon2: number): number {
const toRad = (d: number) => (d * Math.PI) / 180;
const R = 6371;
const dLat = toRad(lat2 - lat1);
const dLon = toRad(lon2 - lon1);
const a = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2;
return 2 * R * Math.asin(Math.min(1, Math.sqrt(a)));
}