Auto-refuel on dispatch; remove manual refueling UI/API/action (ISS-082, v0.42.0)
CI / build + unit + e2e (pull_request) Successful in 17m25s

This commit is contained in:
2026-09-21 15:19:39 +00:00
parent b2d03846f5
commit 64da8e67bf
28 changed files with 479 additions and 1008 deletions
+21 -52
View File
@@ -5,8 +5,6 @@ import { expect, test } from '@playwright/test';
import { initialBearingDeg } from '../lib/geo';
import { weatherFor } from '../lib/weather';
import { windBurnFactor } from '../lib/sim/movement';
// v0.38.0 (ISS-072) — reputation discounts: expectations computed from the LIVE snapshot's rep with the real helpers.
import { FUEL_DISCOUNT_PER_REP, FUEL_PRICE_PER_L, MAX_FUEL_DISCOUNT, repRate } from '../lib/game/catalog';
// v0.12.0 package model against the live origin (https://flight.dev.kkhost.pl):
// the full core loop through the UI — hook creates a short package at EDDF → Load
@@ -18,12 +16,9 @@ import { FUEL_DISCOUNT_PER_REP, FUEL_PRICE_PER_L, MAX_FUEL_DISCOUNT, repRate } f
const SCALE = 1000; // fastest sim speed (v0.14.0 ladder top) — 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 — a long leg: under the v0.16.0 weighted model a full tank covers the
// round trip, but the return leaves too little for another outbound
// Mirrors lib/sim/movement.ts (v0.16.0 weight-proportional burn) + lib/game/catalog.ts (atr72).
// Mirrors lib/sim/movement.ts (v0.16.0 weight-proportional burn).
const FUEL_DENSITY_KG_PER_L = 0.8; // Jet-A1 density — must match FUEL_DENSITY_KG_PER_L in movement.ts
// FUEL_PRICE_PER_L is imported from ../lib/game/catalog (v0.38.0) instead of mirrored here.
const REP_BASE = 1; // lib/sim/engine.ts
const REP_PER_KM = 0.0005;
@@ -79,6 +74,14 @@ async function postSim(page: Page, body: Record<string, unknown>): Promise<void>
expect(res.ok()).toBeTruthy();
}
// Test hook (FLIGHT_TEST_HOOKS=1): top a plane's tank back to full — replaces the removed /refuel
// endpoint (ISS-082). Reads maxFuel from the snapshot so no catalog mirror is needed.
async function setFuelFull(page: Page, planeId: string): Promise<void> {
const s = await getState(page);
const p = s.planes.find((x) => x.id === planeId)!;
expect((await page.request.post('/api/test/set-fuel', { data: { planeId, fuel: p.maxFuel } })).status()).toBe(200);
}
// Set the time scale and VERIFY from the response that it stuck — if something external ever
// resets the live instance's controls mid-test, fail loudly here instead of timing out later.
// ISS-041 also clears pause: a landing pauses the sim, so any fast-forward after one must
@@ -119,8 +122,8 @@ test.describe.configure({ mode: 'serial' }); // shared live DB — order matters
// exact cash/rep assertions start from a clean baseline even after an interrupted previous run.
async function flyHomeViaApi(page: Page): Promise<void> {
// v0.39.0 (ISS-069): wear accumulates across the shared CI DB — restore full health and clear any
// active repair gate from an interrupted run before the steps below act on the starter, so the FAR
// round-trip feasibility pin never sees a worn or broken plane.
// active repair gate from an interrupted run before the steps below act on the starter, so the
// exact-fuel pins never see a worn or broken plane (broken planes burn ×1.5 more).
expect((await page.request.post('/api/test/set-health', { data: { planeId: 'plane-starter', health: 100, repairUntilMs: 0 } })).status()).toBe(200);
let s = await getState(page); // ticks — a shortened gate flips the plane back to available here
let plane = s.planes.find((p) => p.id === 'plane-starter')!;
@@ -160,7 +163,7 @@ async function flyHomeViaApi(page: Page): Promise<void> {
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 ~20 s at 1000×) + two short flights + refuel
test.setTimeout(300_000); // self-heal (worst case ~20 s at 1000×) + two short flights
// Self-heal: the starter plane may be anywhere from a previous run/spec.
await flyHomeViaApi(page);
const before = await getState(page);
@@ -247,15 +250,8 @@ test('full core loop via UI: load → fly to destination → exact reward + rep
6,
);
// Refuel via the API: cost = (maxFuel − fuel) × FUEL_PRICE_PER_L, rep-scaled (v0.38.0, ISS-072), deducted exactly.
const refuelRes = await page.request.post(`/api/planes/${planeId}/refuel`);
expect(refuelRes.ok()).toBeTruthy();
s = await getState(page);
const afterRefuel = s.planes.find((p) => p.id === planeId)!;
expect(afterRefuel.fuel).toBe(afterRefuel.maxFuel); // back to full
const fuelRate = repRate(s.account.reputation, FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT);
const refuelCost = Math.round((afterRefuel.maxFuel - landed.fuel) * FUEL_PRICE_PER_L * (1 - fuelRate) * 100) / 100;
expect(s.account.cash).toBeCloseTo(before.account.cash + pkg.reward - refuelCost, 6);
// ISS-082 (v0.42.0): manual refuel is gone — the return leg below auto-refuels on dispatch; the
// exact top-up cost delta is pinned in e2e/right-pane.spec.ts instead of here.
// Restore: fly home (repositioning — no package needed in the v0.12.0 model). ISS-041: the
// arrival above paused the sim, so re-set SCALE for the return leg (setScale clears pause).
@@ -347,9 +343,9 @@ test('expired packages deliver and abandon with no reward and no penalty', async
expect(s.packages.find((p) => p.id === pkgB.id)!.status).toBe('abandoned');
expect(s.account.cash).toBe(cashBeforeAbandon); // free — expired abandons cost nothing
// Restore: refuel + fly home. ISS-041: the LEMD arrival above paused the sim, so
// Restore: top up + fly home. ISS-041: the LEMD arrival above paused the sim, so
// re-set SCALE for the return leg (setScale clears pause; the expiry wait's scale is kept).
await page.request.post('/api/planes/plane-starter/refuel');
await setFuelFull(page, 'plane-starter');
await setScale(page, SCALE);
const homeRes = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
expect(homeRes.ok()).toBeTruthy();
@@ -357,7 +353,7 @@ test('expired packages deliver and abandon with no reward and no penalty', async
});
test('load and fly validation paths (404 / 409 / 400)', async ({ page }) => {
test.setTimeout(600_000); // the FAR round trip alone is ~30 s real time at 1000×
test.setTimeout(300_000); // self-heal + one short hop each way at scale 1000
await flyHomeViaApi(page);
// Load a package whose origin the plane is not at → 409.
@@ -395,37 +391,10 @@ test('load and fly validation paths (404 / 409 / 400)', async ({ page }) => {
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 (v0.16.0 weighted burn): a full tank covers the FAR round trip —
// the plane gets lighter as fuel burns off, so both legs fit — but landing back at the
// hub leaves too little for ANOTHER long leg: the second outbound must 409.
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 legs
}
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 fits: the weighted burn over it is less than what's left in the tank.
// ISS-041: the FAR arrival above paused the sim, so re-set SCALE for this leg (setScale clears pause).
await setScale(page, SCALE);
const backRes = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
expect(backRes.ok()).toBeTruthy();
await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')!.locationIcao), { timeout: 240_000 }).toBe(HUB);
// …but the tank is now too light for another FAR leg.
const againFar = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: FAR } });
expect(againFar.status()).toBe(409);
expect(((await againFar.json()) as { error: string }).error).toMatch(/insufficient fuel/i);
// Restore: refuel (the plane is already home).
await page.request.post('/api/planes/plane-starter/refuel');
// ISS-082 (v0.42.0): the old "second outbound must 409 on insufficient fuel" pin is gone — dispatch
// auto-refuels any full-tank-flyable leg, so a partial tank no longer blocks it. The remaining
// insufficient-fuel case (a leg that doesn't fit even a FULL tank) is pinned in the unit tests and
// app/api/planes/[planeId]/fly/route.test.ts instead of spending ~60 s of CI time on a FAR round trip here.
});
// Great-circle distance in km — mirrors lib/geo.ts (same formula, same R).