240 lines
10 KiB
TypeScript
240 lines
10 KiB
TypeScript
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);
|
||
});
|