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:
@@ -1,11 +1,12 @@
|
||||
import { test, expect, type Page } from '@playwright/test';
|
||||
|
||||
// v0.11.0 — map interactions against the live origin (PLAN.md "Middle — Map"):
|
||||
// v0.12.0 — map interactions against the live origin (PLAN.md "Middle — Map"):
|
||||
// expanded airport popup (name + ICAO kept, plus approximate local time and a
|
||||
// deterministic generated weather), plane markers whose positions come from the
|
||||
// /api/state polls, active contract route lines (pending dashed vs in-flight
|
||||
// solid), and the plane status popup. Contracts are created on a known EDDF→EDDV
|
||||
// route via POST /api/test/create-contract (FLIGHT_TEST_HOOKS=1).
|
||||
// /api/state polls, each FLYING plane's current leg as a route line (origin →
|
||||
// dest_icao; grounded planes draw nothing), and the plane status popup. Packages
|
||||
// are created on a known EDDF→EDDV route via POST /api/test/create-package
|
||||
// (FLIGHT_TEST_HOOKS=1).
|
||||
|
||||
const HUB = 'EDDF'; // must match HUB_ICAO in lib/game/seed.ts
|
||||
// Hannover — ~282 km from EDDF → ~33 min game time ≈ 33 s at scale 60, a comfortable
|
||||
@@ -20,19 +21,18 @@ interface PlaneState {
|
||||
fuel: number;
|
||||
maxFuel: number;
|
||||
locationIcao: string;
|
||||
contractId: string | null;
|
||||
locationName: string;
|
||||
destIcao: string | null;
|
||||
destName: string | null;
|
||||
lat: number;
|
||||
lon: number;
|
||||
}
|
||||
|
||||
interface ContractState {
|
||||
interface PackageState {
|
||||
id: string;
|
||||
originIcao: string;
|
||||
originName: string;
|
||||
destIcao: string;
|
||||
destName: string;
|
||||
reward: number;
|
||||
status: 'open' | 'accepted' | 'completed' | 'expired';
|
||||
status: 'open' | 'loaded' | 'delivered' | 'expired' | 'abandoned';
|
||||
planeId: string | null;
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ interface StateBody {
|
||||
account: { cash: number; reputation: number };
|
||||
simState: { clockMs: number; timeScale: number; paused: boolean };
|
||||
planes: PlaneState[];
|
||||
contracts: ContractState[];
|
||||
packages: PackageState[];
|
||||
}
|
||||
|
||||
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||
@@ -66,38 +66,47 @@ async function postSim(page: Page, body: unknown) {
|
||||
return page.request.post('/api/sim', { data: body });
|
||||
}
|
||||
|
||||
// Set the time scale and VERIFY from the response that it stuck — a silent reset would
|
||||
// otherwise surface as an unexplained landing-wait timeout.
|
||||
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);
|
||||
const body = (await res.json()) as { timeScale: number };
|
||||
expect(body.timeScale, `timeScale should be ${scale} after POST /api/sim`).toBe(scale);
|
||||
}
|
||||
|
||||
// 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', {
|
||||
// Test hook (FLIGHT_TEST_HOOKS=1): create an open package on a known route.
|
||||
async function createPackage(page: Page, fromIcao: string, toIcao: string): Promise<PackageState> {
|
||||
const created = await page.request.post('/api/test/create-package', {
|
||||
data: { originIcao: fromIcao, destIcao: toIcao, deadlineOffsetMs: 24 * 3_600_000 },
|
||||
});
|
||||
expect(created.status()).toBe(201);
|
||||
return (await created.json()) as ContractState;
|
||||
return (await created.json()) as PackageState;
|
||||
}
|
||||
|
||||
// Create + accept + dispatch a contract from → to on plane-starter, then poll at
|
||||
// Load a package onto plane-starter and fly it from → to via the API, then poll at
|
||||
// scale 60 until the plane lands at `to`. Same pattern as e2e/actions.spec.ts.
|
||||
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 });
|
||||
async function loadAndFly(page: Page, fromIcao: string, toIcao: string): Promise<PackageState> {
|
||||
const pkg = await createPackage(page, fromIcao, toIcao);
|
||||
const load = await page.request.post(`/api/packages/${pkg.id}/load`, { data: { planeId: 'plane-starter' } });
|
||||
expect(load.status()).toBe(200);
|
||||
const fly = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: toIcao } });
|
||||
expect(fly.status()).toBe(200);
|
||||
await setScale(page, 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: 120_000, message: `waiting for plane-starter to land at ${toIcao}` })
|
||||
}, { timeout: 240_000, message: `waiting for plane-starter to land at ${toIcao}` })
|
||||
.toBe(true);
|
||||
return contract;
|
||||
return pkg;
|
||||
}
|
||||
|
||||
// All features of a GeoJSON source (viewport-independent), [] until the map/source exists.
|
||||
@@ -161,8 +170,8 @@ test('airport popup shows name, ICAO, local time and generated weather', async (
|
||||
expect(wText).toMatch(/\d+ kt/); // wind speed in knots
|
||||
});
|
||||
|
||||
test('plane markers move while flying, the active route line is highlighted, and the plane popup shows status + route', async ({ page }) => {
|
||||
test.setTimeout(300_000); // generous cap — two short flights at scale 60 plus map waits
|
||||
test('plane markers move while flying, the current leg draws a route line, and the plane popup shows status + route', async ({ page }) => {
|
||||
test.setTimeout(600_000); // self-heal from a far airport (~4 min worst case at 60×) + main flight + cleanup
|
||||
|
||||
await page.goto('/');
|
||||
await waitForAirportCount(page);
|
||||
@@ -173,35 +182,45 @@ test('plane markers move while flying, the active route line is highlighted, and
|
||||
const foundStarter = (await getState(page)).planes.find((p) => p.id === 'plane-starter');
|
||||
expect(foundStarter).toBeDefined();
|
||||
if (foundStarter!.status === 'flying') {
|
||||
await postSim(page, { timeScale: SCALE });
|
||||
await setScale(page, SCALE);
|
||||
await expect
|
||||
.poll(async () => (await getState(page)).planes.find((p) => p.id === 'plane-starter')?.status === 'available', {
|
||||
timeout: 120_000,
|
||||
timeout: 240_000,
|
||||
message: 'waiting for an in-flight plane-starter to land',
|
||||
})
|
||||
.toBe(true);
|
||||
}
|
||||
const starterLoc = (await getState(page)).planes.find((p) => p.id === 'plane-starter')!.locationIcao;
|
||||
if (starterLoc !== HUB) {
|
||||
await flyContract(page, starterLoc, HUB);
|
||||
await loadAndFly(page, starterLoc, HUB);
|
||||
}
|
||||
|
||||
// 1) Accept the hook-created contract — before dispatch it must draw a PENDING route line.
|
||||
const contract = await createContract(page, HUB, DEST);
|
||||
const acc = await page.request.post(`/api/contracts/${contract.id}/accept`);
|
||||
expect(acc.status()).toBe(200);
|
||||
// 1) Load the hook-created package — a GROUNDED plane draws no route line yet.
|
||||
const pkg = await createPackage(page, HUB, DEST);
|
||||
const load = await page.request.post(`/api/packages/${pkg.id}/load`, { data: { planeId: 'plane-starter' } });
|
||||
expect(load.status()).toBe(200);
|
||||
|
||||
// The map updates from the shell's /api/state polls — poll until the pending line lands.
|
||||
// The map updates from the shell's /api/state polls — poll until the snapshot lands.
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const feats = (await sourceFeatures(page, 'planes')) as Array<{ properties: Record<string, unknown> }>;
|
||||
return feats.some((f) => f.properties.id === 'plane-starter');
|
||||
}, { timeout: 15_000, message: 'waiting for the plane feature in the planes source' })
|
||||
.toBe(true);
|
||||
|
||||
// A GROUNDED plane draws no route line. Poll (not one-shot): if self-heal just flew the
|
||||
// plane home, the map's last poll may still show that flight's stale line until its next
|
||||
// ~1 s tick — wait for it to clear rather than racing the poll loop.
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const feats = (await sourceFeatures(page, 'routes')) as Array<{ properties: Record<string, unknown> }>;
|
||||
return feats.some((f) => f.properties.contractId === contract.id && f.properties.kind === 'pending');
|
||||
}, { timeout: 15_000, message: 'waiting for the pending route line in the routes source' })
|
||||
return !feats.some((f) => f.properties.planeId === 'plane-starter');
|
||||
}, { timeout: 15_000, message: 'waiting for the grounded plane to draw no route line' })
|
||||
.toBe(true);
|
||||
|
||||
// 2) Dispatch plane-starter and speed up — the marker must appear as a flying feature.
|
||||
const disp = await page.request.post('/api/planes/plane-starter/dispatch', { data: { contractId: contract.id } });
|
||||
expect(disp.status()).toBe(200);
|
||||
// 2) Fly the plane and speed up — its current leg must appear as a route line.
|
||||
const fly = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: DEST } });
|
||||
expect(fly.status()).toBe(200);
|
||||
await postSim(page, { timeScale: SCALE });
|
||||
|
||||
await expect
|
||||
@@ -219,15 +238,16 @@ test('plane markers move while flying, the active route line is highlighted, and
|
||||
const p2 = (await getState(page)).planes.find((p) => p.id === 'plane-starter')!;
|
||||
expect(haversineKm(p1.lat, p1.lon, p2.lat, p2.lon)).toBeGreaterThan(0);
|
||||
|
||||
// 4) The route line for this contract is now IN-FLIGHT (solid layer), not pending.
|
||||
// 4) The route line for this flight exists in the routes source (v0.12.0: one line
|
||||
// per flying plane's current leg, keyed by planeId).
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const feats = (await sourceFeatures(page, 'routes')) as Array<{ properties: Record<string, unknown> }>;
|
||||
return feats.some((f) => f.properties.contractId === contract.id && f.properties.kind === 'inflight');
|
||||
}, { timeout: 15_000, message: 'waiting for the in-flight route line' })
|
||||
return feats.some((f) => f.properties.planeId === 'plane-starter');
|
||||
}, { timeout: 15_000, message: 'waiting for the route line in the routes source' })
|
||||
.toBe(true);
|
||||
|
||||
// 5) Click the flying plane → status popup with model, status, fuel and the route.
|
||||
// 5) Click the flying plane → status popup with model, status, fuel and the leg.
|
||||
// Fetch + project in one evaluate so the click lands on a fresh position (the
|
||||
// marker drifts only ~1 px/s of screen space at scale 60).
|
||||
const pt = await page.evaluate(async () => {
|
||||
@@ -250,17 +270,20 @@ test('plane markers move while flying, the active route line is highlighted, and
|
||||
await expect(page.getByTestId('plane-status')).toHaveText('flying');
|
||||
await expect(page.getByTestId('plane-fuel')).toHaveText(/\d+ \/ \d+ L/);
|
||||
|
||||
const live = (await getState(page)).contracts.find((c) => c.id === contract.id)!;
|
||||
await expect(page.getByTestId('plane-route')).toContainText(`${live.originName} → ${live.destName}`);
|
||||
const live = (await getState(page)).planes.find((p) => p.id === 'plane-starter')!;
|
||||
// The popup's route line is the plane's own leg: origin name → destination name.
|
||||
await expect(page.getByTestId('plane-route')).toContainText(`${live.locationName} → ${live.destName}`);
|
||||
|
||||
// 6) Let the flight finish, then fly home so the live instance stays in its
|
||||
// onboarding state (starter grounded at EDDF) for the other specs.
|
||||
// onboarding state (starter grounded at EDDF) for the other specs. The loaded
|
||||
// package is delivered on arrival (in-deadline).
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const p = (await getState(page)).planes.find((x) => x.id === 'plane-starter');
|
||||
return !!p && p.status === 'available' && p.locationIcao === DEST;
|
||||
}, { timeout: 120_000, message: `waiting for plane-starter to land at ${DEST}` })
|
||||
.toBe(true);
|
||||
expect((await getState(page)).packages.find((x) => x.id === pkg.id)?.status).toBe('delivered');
|
||||
|
||||
await flyContract(page, DEST, HUB);
|
||||
await loadAndFly(page, DEST, HUB);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user