Files
Flight-Ops/e2e/actions.spec.ts
T

240 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { test, expect, type Page } 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.
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 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;
}
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)));
}
async function getState(page: Page): Promise<StateBody> {
const res = await page.request.get('/api/state');
expect(res.status()).toBe(200);
return (await res.json()) as StateBody;
}
async function postSim(page: Page, body: unknown) {
return page.request.post('/api/sim', { data: body });
}
// 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 });
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;
}
// 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 } }>;
};
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 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;
}
// 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);
// 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 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);
});
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')!;
// 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;
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/);
const unknown = await page.request.post('/api/planes/plane-starter/dispatch', { data: { contractId: 'nope' } });
expect(unknown.status()).toBe(404);
});