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
+71 -48
View File
@@ -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);
});