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:
+314
-198
@@ -1,239 +1,355 @@
|
||||
import { test, expect, type Page } from '@playwright/test';
|
||||
import type { Page, Request } from '@playwright/test';
|
||||
import { expect, test } 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.
|
||||
// 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
|
||||
// via the right pane → click its destination in the on-board list → fly at 60× →
|
||||
// arrival delivers EXACTLY the reward + rep; abandon deducts exactly the formula
|
||||
// penalty while grounded; expired packages deliver/abandon with no reward and no
|
||||
// penalty. Mirrors lib/game/penalty.ts for exact-value assertions.
|
||||
|
||||
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 SCALE = 60; // fastest sim speed — 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 — one-way burn 3174 L > half tank, so the return leg is unfuelable
|
||||
|
||||
// Mirrors lib/sim/movement.ts + lib/game/catalog.ts (atr72).
|
||||
const BURN_PER_KM = 1.75;
|
||||
const FUEL_PRICE_PER_L = 0.8;
|
||||
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;
|
||||
// Mirrors lib/game/penalty.ts — the exact abandon penalty at a given game clock.
|
||||
function penaltyFor(reward: number, createdSimTime: number, deadlineSimTime: number, clockMs: number): number {
|
||||
const span = deadlineSimTime - createdSimTime;
|
||||
const frac = span > 0 ? Math.min(1, Math.max(0, (clockMs - createdSimTime) / span)) : 1;
|
||||
return Math.round(reward * (0.15 + 0.45 * frac) * 100) / 100;
|
||||
}
|
||||
|
||||
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)));
|
||||
planes: Array<{ id: string; status: string; fuel: number; maxFuel: number; locationIcao: string; destIcao: string | null }>;
|
||||
packages: Array<{
|
||||
id: string; originIcao: string; destIcao: string; reward: number; cargoType: string; cargoCount: number;
|
||||
deadlineSimTime: number; status: 'open' | 'loaded' | 'delivered' | 'expired' | 'abandoned'; planeId: string | null; createdSimTime: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
async function getState(page: Page): Promise<StateBody> {
|
||||
const res = await page.request.get('/api/state');
|
||||
expect(res.status()).toBe(200);
|
||||
expect(res.ok()).toBeTruthy();
|
||||
return (await res.json()) as StateBody;
|
||||
}
|
||||
|
||||
async function postSim(page: Page, body: unknown) {
|
||||
return page.request.post('/api/sim', { data: body });
|
||||
// The e2e instance runs with FLIGHT_TEST_HOOKS=1 (docker-compose.yml) — the hook is live.
|
||||
async function createPackage(page: Page, from?: string, to?: string, deadlineOffsetMs?: number): Promise<StateBody['packages'][number]> {
|
||||
const res = await page.request.post('/api/test/create-package', { data: { originIcao: from, destIcao: to, deadlineOffsetMs } });
|
||||
expect(res.status()).toBe(201);
|
||||
return (await res.json()) as StateBody['packages'][number];
|
||||
}
|
||||
|
||||
// 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 });
|
||||
async function postSim(page: Page, body: Record<string, unknown>): Promise<void> {
|
||||
const res = await page.request.post('/api/sim', { data: body });
|
||||
expect(res.ok()).toBeTruthy();
|
||||
}
|
||||
|
||||
// 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.
|
||||
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);
|
||||
});
|
||||
|
||||
// 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;
|
||||
const body = (await res.json()) as { timeScale: number };
|
||||
expect(body.timeScale, `timeScale should be ${scale} after POST /api/sim`).toBe(scale);
|
||||
}
|
||||
|
||||
// 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 } }>;
|
||||
};
|
||||
// Closest airport to the hub from /api/airports → shortest possible flight. Returns both
|
||||
// endpoints' coordinates so exact-km assertions (rep/fuel) can mirror lib/geo.ts.
|
||||
async function nearestToHub(page: Page): Promise<{ icao: string; lat: number; lon: number; hubLat: number; hubLon: number }> {
|
||||
const res = await page.request.get('/api/airports');
|
||||
expect(res.ok()).toBeTruthy();
|
||||
const fc = (await res.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 d = Math.hypot(f.properties.lat - hub.lat, f.properties.lon - hub.lon);
|
||||
const dn = Math.hypot(nearest.lat - hub.lat, nearest.lon - hub.lon);
|
||||
if (d < dn) nearest = f.properties;
|
||||
}
|
||||
return { icao: nearest.icao, lat: nearest.lat, lon: nearest.lon, hubLat: hub.lat, hubLon: hub.lon };
|
||||
}
|
||||
|
||||
// Every spec that touches the time controls restores scale=1 / unpaused on exit so
|
||||
// a failure mid-test can't leave the live instance fast-forwarded for later specs.
|
||||
test.afterEach(async ({ page }) => {
|
||||
await postSim(page, { timeScale: 1, paused: false });
|
||||
});
|
||||
|
||||
test.describe.configure({ mode: 'serial' }); // shared live DB — order matters within this file
|
||||
// Self-heal helper: let any in-flight leg finish, reposition plane-starter home via the API
|
||||
// (repositioning needs no package — v0.12.0 manual flight), and abandon any leftover cargo so
|
||||
// exact cash/rep assertions start from a clean baseline even after an interrupted previous run.
|
||||
async function flyHomeViaApi(page: Page): Promise<void> {
|
||||
const s0 = await getState(page);
|
||||
const plane = s0.planes.find((p) => p.id === 'plane-starter')!;
|
||||
if (plane.status === 'flying' || plane.locationIcao !== HUB) {
|
||||
// Fast-forward any leftover leg / repositioning flight (~3 min real time worst case at 60×).
|
||||
await setScale(page, SCALE);
|
||||
}
|
||||
if (plane.status === 'flying') {
|
||||
// Let the in-flight leg finish before repositioning.
|
||||
await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')!.status), { timeout: 240_000 }).toBe('available');
|
||||
}
|
||||
if (plane.locationIcao !== HUB) {
|
||||
const res = await page.request.post(`/api/planes/${plane.id}/fly`, { data: { destIcao: HUB } });
|
||||
expect(res.ok()).toBeTruthy(); // repositioning needs no package (v0.12.0 manual flight)
|
||||
await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')!.locationIcao), { timeout: 240_000 }).toBe(HUB);
|
||||
}
|
||||
// Abandon leftover loaded/expired packages (expired ones cost nothing; loaded ones take a
|
||||
// penalty — irrelevant, the baseline snapshot is captured AFTER this cleanup).
|
||||
const s1 = await getState(page);
|
||||
for (const pkg of s1.packages) {
|
||||
if (pkg.planeId === 'plane-starter' && (pkg.status === 'loaded' || pkg.status === 'expired')) {
|
||||
const res = await page.request.post(`/api/packages/${pkg.id}/abandon`);
|
||||
expect(res.ok()).toBeTruthy();
|
||||
}
|
||||
}
|
||||
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;
|
||||
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 ~3 min at 60×) + two short flights + refuel
|
||||
// Self-heal: the starter plane may be anywhere from a previous run/spec.
|
||||
await flyHomeViaApi(page);
|
||||
const before = await getState(page);
|
||||
expect(before.planes.find((p) => p.id === 'plane-starter')!.locationIcao).toBe(HUB);
|
||||
|
||||
// Nearest airport to the hub → shortest possible flight (fastest e2e loop).
|
||||
const nearest = await nearestToHub(page);
|
||||
|
||||
// A known short package at the hub with a generous deadline.
|
||||
const pkg = await createPackage(page, HUB, nearest.icao, 24 * 3_600_000);
|
||||
expect(pkg.originIcao).toBe(HUB);
|
||||
expect(pkg.destIcao).toBe(nearest.icao);
|
||||
|
||||
// LOAD via the UI: exactly one eligible plane (the starter at EDDF) → direct button.
|
||||
await page.goto('/');
|
||||
const loadBtn = page.getByTestId(`load-${pkg.id}`);
|
||||
await expect(loadBtn, 'Load button enabled for a package at the hub').toBeEnabled();
|
||||
await loadBtn.click();
|
||||
|
||||
// The action endpoint's full snapshot is applied immediately (applySnapshot) — the
|
||||
// package shows as loaded on the starter in the on-board section.
|
||||
const onboardRow = page.getByTestId(`onboard-package-${pkg.id}`);
|
||||
await expect(onboardRow, 'loaded package appears in the on-board list').toBeVisible();
|
||||
|
||||
// FLY via the UI: click the destination name button in the on-board list.
|
||||
const flyBtn = page.getByTestId(`fly-to-${pkg.id}`);
|
||||
await expect(flyBtn).toBeEnabled();
|
||||
await flyBtn.click();
|
||||
|
||||
let s = await getState(page);
|
||||
const planeId = 'plane-starter';
|
||||
expect(s.planes.find((p) => p.id === planeId)!.status).toBe('flying');
|
||||
expect(s.planes.find((p) => p.id === planeId)!.destIcao).toBe(nearest.icao);
|
||||
|
||||
// Fast-forward to arrival (≤ ~10 min game time at 60× → seconds of real time).
|
||||
await setScale(page, SCALE);
|
||||
await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === planeId)!.status), { timeout: 120_000 }).toBe('available');
|
||||
|
||||
s = await getState(page);
|
||||
const landed = s.planes.find((p) => p.id === planeId)!;
|
||||
expect(landed.locationIcao).toBe(nearest.icao); // arrived at the package's destination
|
||||
expect(landed.destIcao).toBeNull(); // flight context cleared on landing
|
||||
|
||||
// EXACT reward: cash increased by exactly the package's payout…
|
||||
const delivered = s.packages.find((p) => p.id === pkg.id)!;
|
||||
expect(delivered.status).toBe('delivered');
|
||||
expect(s.account.cash).toBeCloseTo(before.account.cash + pkg.reward, 6);
|
||||
|
||||
// …and rep gained per the engine formula (great-circle km of the leg just flown),
|
||||
// mirroring lib/sim/engine.ts's double rounding (gain rounded to cents, then total).
|
||||
const exactKm = haversineKm(nearest.hubLat, nearest.hubLon, nearest.lat, nearest.lon);
|
||||
const repGain = Math.round((REP_BASE + REP_PER_KM * exactKm) * 100) / 100;
|
||||
expect(s.account.reputation).toBeCloseTo(Math.round((before.account.reputation + repGain) * 100) / 100, 9);
|
||||
|
||||
// Fuel burned exactly burnPerKm × km (analytic movement — no rounding drift).
|
||||
expect(landed.fuel).toBeCloseTo(Math.max(0, before.planes.find((p) => p.id === planeId)!.fuel - BURN_PER_KM * exactKm), 6);
|
||||
|
||||
// Refuel via the API: cost = (maxFuel − fuel) × FUEL_PRICE_PER_L, 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 refuelCost = Math.round((afterRefuel.maxFuel - landed.fuel) * FUEL_PRICE_PER_L * 100) / 100;
|
||||
expect(s.account.cash).toBeCloseTo(before.account.cash + pkg.reward - refuelCost, 6);
|
||||
|
||||
// Restore: fly home (repositioning — no package needed in the v0.12.0 model).
|
||||
const homeRes = await page.request.post(`/api/planes/${planeId}/fly`, { data: { destIcao: HUB } });
|
||||
expect(homeRes.ok()).toBeTruthy();
|
||||
await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === planeId)!.locationIcao), { timeout: 120_000 }).toBe(HUB);
|
||||
});
|
||||
|
||||
test('abandon while grounded deducts exactly the formula penalty', async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
await flyHomeViaApi(page);
|
||||
const before = await getState(page);
|
||||
|
||||
// Two packages at the hub — load both, abandon one through the UI.
|
||||
const pkgA = await createPackage(page, HUB, 'LEMD', 24 * 3_600_000);
|
||||
const pkgB = await createPackage(page, HUB, 'LEMD', 24 * 3_600_000);
|
||||
for (const p of [pkgA, pkgB]) {
|
||||
const res = await page.request.post(`/api/packages/${p.id}/load`, { data: { planeId: 'plane-starter' } });
|
||||
expect(res.ok()).toBeTruthy();
|
||||
}
|
||||
|
||||
// 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);
|
||||
// Abandon A via the UI — capture the endpoint's OWN response so the penalty is checked
|
||||
// against the exact game clock the server used (a later GET would tick the clock further).
|
||||
await page.goto('/');
|
||||
const abandonBtn = page.getByTestId(`abandon-${pkgA.id}`);
|
||||
await expect(abandonBtn).toBeEnabled();
|
||||
const [res] = await Promise.all([
|
||||
page.waitForResponse((r) => r.url().includes(`/api/packages/${pkgA.id}/abandon`), { timeout: 15_000 }),
|
||||
abandonBtn.click(),
|
||||
]);
|
||||
expect(res.status()).toBe(200);
|
||||
const s = (await res.json()) as StateBody; // full post-action snapshot at the penalty's clock
|
||||
|
||||
// 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 expectedPenalty = penaltyFor(pkgA.reward, pkgA.createdSimTime, pkgA.deadlineSimTime, s.simState.clockMs);
|
||||
expect(expectedPenalty).toBeGreaterThanOrEqual(Math.round(pkgA.reward * 0.15 * 100) / 100); // at/above the floor
|
||||
const abandoned = s.packages.find((p) => p.id === pkgA.id)!;
|
||||
expect(abandoned.status).toBe('abandoned');
|
||||
expect(abandoned.planeId).toBeNull(); // slot freed
|
||||
expect(s.account.cash).toBeCloseTo(before.account.cash - expectedPenalty, 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);
|
||||
// B is still on board — abandon it via the API to leave a clean state.
|
||||
const resB = await page.request.post(`/api/packages/${pkgB.id}/abandon`);
|
||||
expect(resB.ok()).toBeTruthy();
|
||||
});
|
||||
|
||||
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')!;
|
||||
test('expired packages deliver and abandon with no reward and no penalty', async ({ page }) => {
|
||||
test.setTimeout(480_000); // two ~3 min real-time legs at 60× (HUB↔LEMD) plus expiry wait
|
||||
await flyHomeViaApi(page);
|
||||
const before = await getState(page);
|
||||
|
||||
// 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;
|
||||
// A: load, let it expire while loaded, then FLY to its destination — arrival must
|
||||
// unbind it WITHOUT paying out. B: load, let it expire, abandon for free. B's
|
||||
// destination differs from A's so it stays bound on the plane at LEMD arrival and can
|
||||
// still be abandoned (an expired package whose dest matches the arrival airport is
|
||||
// unbound by the tick instead).
|
||||
const pkgA = await createPackage(page, HUB, 'LEMD', 10_000); // short game-time deadline
|
||||
const pkgB = await createPackage(page, HUB, 'EHAM', 10_000);
|
||||
for (const p of [pkgA, pkgB]) {
|
||||
const res = await page.request.post(`/api/packages/${p.id}/load`, { data: { planeId: 'plane-starter' } });
|
||||
expect(res.ok()).toBeTruthy();
|
||||
}
|
||||
|
||||
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/);
|
||||
// Fast-forward past both deadlines — the tick flips loaded → expired (slot kept).
|
||||
await setScale(page, SCALE);
|
||||
let s = await getState(page);
|
||||
await expect.poll(async () => ((await getState(page)).packages.find((p) => p.id === pkgA.id)!.status), { timeout: 30_000 }).toBe('expired');
|
||||
s = await getState(page);
|
||||
expect(s.packages.find((p) => p.id === pkgB.id)!.status).toBe('expired');
|
||||
|
||||
const unknown = await page.request.post('/api/planes/plane-starter/dispatch', { data: { contractId: 'nope' } });
|
||||
expect(unknown.status()).toBe(404);
|
||||
// Fly to the destination — arrival unbinds A for free (no reward, no rep).
|
||||
const flyRes = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: 'LEMD' } });
|
||||
expect(flyRes.ok()).toBeTruthy();
|
||||
await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')!.status), { timeout: 240_000 }).toBe('available');
|
||||
|
||||
s = await getState(page);
|
||||
const landedA = s.packages.find((p) => p.id === pkgA.id)!;
|
||||
expect(landedA.status).toBe('expired'); // still expired — never delivered…
|
||||
expect(landedA.planeId).toBeNull(); // …but the slot was freed on arrival
|
||||
expect(s.account.cash).toBe(before.account.cash); // NO reward for expired cargo
|
||||
expect(s.account.reputation).toBe(before.account.reputation); // no rep either
|
||||
|
||||
// B: abandon an expired package while grounded — penalty is exactly €0.
|
||||
const cashBeforeAbandon = s.account.cash;
|
||||
const resB = await page.request.post(`/api/packages/${pkgB.id}/abandon`);
|
||||
expect(resB.ok()).toBeTruthy();
|
||||
s = await getState(page);
|
||||
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 (scale is still SCALE from the expiry wait above).
|
||||
await page.request.post('/api/planes/plane-starter/refuel');
|
||||
const homeRes = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
|
||||
expect(homeRes.ok()).toBeTruthy();
|
||||
await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')!.locationIcao), { timeout: 240_000 }).toBe(HUB);
|
||||
});
|
||||
|
||||
test('load and fly validation paths (404 / 409 / 400)', async ({ page }) => {
|
||||
test.setTimeout(600_000); // the FAR round trip alone is ~7.5 min real time at 60×
|
||||
await flyHomeViaApi(page);
|
||||
|
||||
// Load a package whose origin the plane is not at → 409.
|
||||
const pkg = await createPackage(page, 'LEMD', HUB, 24 * 3_600_000); // originates in Madrid, plane sits in Frankfurt
|
||||
let res = await page.request.post(`/api/packages/${pkg.id}/load`, { data: { planeId: 'plane-starter' } });
|
||||
expect(res.status()).toBe(409);
|
||||
expect(((await res.json()) as { error: string }).error).toMatch(/at EDDF/);
|
||||
|
||||
// Load an unknown package → 404.
|
||||
res = await page.request.post('/api/packages/no-such-package/load', { data: { planeId: 'plane-starter' } });
|
||||
expect(res.status()).toBe(404);
|
||||
|
||||
// Fly to an ICAO not in the dataset → 400.
|
||||
res = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: 'ZZZZ' } });
|
||||
expect(res.status()).toBe(400);
|
||||
expect(((await res.json()) as { error: string }).error).toMatch(/unknown airport/);
|
||||
|
||||
// Fly while already flying → 409 (short hop leg on the nearest route, then let it finish).
|
||||
const hopDest = await nearestToHub(page);
|
||||
res = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: hopDest.icao } });
|
||||
expect(res.ok()).toBeTruthy();
|
||||
const again = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
|
||||
expect(again.status()).toBe(409);
|
||||
await setScale(page, SCALE);
|
||||
await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')!.locationIcao), { timeout: 120_000 }).toBe(hopDest.icao);
|
||||
|
||||
// Abandon an open package → 409 (nothing loaded to abandon).
|
||||
res = await page.request.post(`/api/packages/${pkg.id}/abandon`);
|
||||
expect(res.status()).toBe(409);
|
||||
|
||||
// Fly home from the hop so the fuel section starts at the hub.
|
||||
res = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
|
||||
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: a full-tank one-way burn of > half the tank makes the return leg unfuelable.
|
||||
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 leg
|
||||
}
|
||||
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 needs the same burn as the outbound — more than what's left.
|
||||
const backRes = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
|
||||
expect(backRes.status()).toBe(409);
|
||||
expect(((await backRes.json()) as { error: string }).error).toMatch(/insufficient fuel/i);
|
||||
|
||||
// Restore: refuel + fly home.
|
||||
await page.request.post('/api/planes/plane-starter/refuel');
|
||||
const homeRes = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
|
||||
expect(homeRes.ok()).toBeTruthy();
|
||||
await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')!.locationIcao), { timeout: 240_000 }).toBe(HUB);
|
||||
});
|
||||
|
||||
// Great-circle distance in km — mirrors lib/geo.ts (same formula, same R).
|
||||
function haversineKm(lat1: number, lon1: number, lat2: number, lon2: number): number {
|
||||
const toRad = (d: number) => (d * Math.PI) / 180;
|
||||
const R = 6371;
|
||||
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 * R * Math.asin(Math.min(1, Math.sqrt(a)));
|
||||
}
|
||||
|
||||
+5
-4
@@ -13,20 +13,21 @@ test('GET /api/version returns the app version from package.json', async ({ page
|
||||
expect(body.version).toBe(pkg.version);
|
||||
});
|
||||
|
||||
test('GET /api/openapi serves the spec with all nine endpoints and the package.json version', async ({ page }) => {
|
||||
test('GET /api/openapi serves the spec with all ten endpoints and the package.json version', async ({ page }) => {
|
||||
const res = await page.request.get('/api/openapi');
|
||||
expect(res.status()).toBe(200);
|
||||
expect(res.headers()['content-type']).toContain('application/json');
|
||||
const spec = (await res.json()) as { info: { title: string; version: string }; paths: Record<string, unknown> };
|
||||
expect(spec.info.title).toBe('Flight Ops API');
|
||||
expect(spec.info.version).toBe(pkg.version);
|
||||
// v0.8.0 adds the five player-action endpoints to the four read-only ones.
|
||||
// v0.12.0 swaps the contract accept/dispatch pair for package load/abandon + plane fly.
|
||||
expect(Object.keys(spec.paths).sort()).toEqual([
|
||||
'/api/airports',
|
||||
'/api/contracts/{id}/accept',
|
||||
'/api/market/buy-plane',
|
||||
'/api/market/sell-plane',
|
||||
'/api/planes/{planeId}/dispatch',
|
||||
'/api/packages/{id}/abandon',
|
||||
'/api/packages/{id}/load',
|
||||
'/api/planes/{planeId}/fly',
|
||||
'/api/planes/{planeId}/refuel',
|
||||
'/api/sim',
|
||||
'/api/state',
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
+6
-1
@@ -14,7 +14,7 @@ interface StateBody {
|
||||
account: { cash: number; reputation: number };
|
||||
simState: SimState;
|
||||
planes: Array<Record<string, unknown>>;
|
||||
contracts: Array<Record<string, unknown>>;
|
||||
packages: Array<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
async function getState(page: Page): Promise<StateBody> {
|
||||
@@ -50,6 +50,11 @@ test('left pane renders wallet, reputation and the starter fleet at Frankfurt',
|
||||
await expect(row).toContainText('ATR 72-600');
|
||||
await expect(row).toContainText('Frankfurt');
|
||||
|
||||
// v0.12.0 loadout: "n/capacity pkgs" (capacity from the snapshot catalog — ATR 72-600 = 6).
|
||||
const loadout = page.getByTestId('plane-loadout-plane-starter');
|
||||
await expect(loadout).toBeVisible();
|
||||
expect((await loadout.textContent()) ?? '').toMatch(/^\d+\/6 pkgs$/);
|
||||
|
||||
// Sim clock renders (UTC wall-clock of the game time).
|
||||
await expect(page.getByTestId('sim-clock')).toContainText(/UTC/);
|
||||
});
|
||||
|
||||
+160
-196
@@ -1,12 +1,12 @@
|
||||
import { test, expect, type Page } from '@playwright/test';
|
||||
import type { Page } from '@playwright/test';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
// v0.10.0 — right Jobs & Market pane against the live origin: open contracts render with
|
||||
// names/reward/deadline and Accept (UI click) moves one into the accepted group; a hook-created
|
||||
// EDDF contract is accepted + dispatched through the UI picker, plane-starter shows "in flight"
|
||||
// in the UI AND /api/state; market refuel through the pane deducts exactly
|
||||
// (maxFuel − fuel) × fuelPricePerL and fills the tank; buy/sell previews match the catalog +
|
||||
// economy constants exposed in /api/state. Contracts are created via POST /api/test/create-contract
|
||||
// (FLIGHT_TEST_HOOKS=1), same pattern as e2e/actions.spec.ts.
|
||||
// v0.12.0 — right Jobs & Market pane against the live origin: open packages render with
|
||||
// names/reward/deadline and Load (UI click) binds one to the starter; the on-board list shows
|
||||
// loaded packages with fly-to-destination + abandon buttons (penalty preview from the same
|
||||
// formula as the server); flying planes show their current leg; market buy/sell/refuel previews
|
||||
// match the catalog + economy constants exposed in /api/state. Packages are created via
|
||||
// POST /api/test/create-package (FLIGHT_TEST_HOOKS=1), same pattern as e2e/actions.spec.ts.
|
||||
|
||||
const SCALE = 60;
|
||||
const HUB = 'EDDF'; // must match HUB_ICAO in lib/game/seed.ts
|
||||
@@ -18,15 +18,18 @@ interface PlaneState {
|
||||
fuel: number;
|
||||
maxFuel: number;
|
||||
locationIcao: string;
|
||||
destIcao: string | null;
|
||||
}
|
||||
|
||||
interface ContractState {
|
||||
interface PackageState {
|
||||
id: string;
|
||||
originName: string;
|
||||
destName: string;
|
||||
reward: number;
|
||||
status: 'open' | 'accepted' | 'completed' | 'expired';
|
||||
status: 'open' | 'loaded' | 'delivered' | 'expired' | 'abandoned';
|
||||
planeId: string | null;
|
||||
createdSimTime: number;
|
||||
deadlineSimTime: number;
|
||||
}
|
||||
|
||||
interface CatalogEntry {
|
||||
@@ -36,13 +39,14 @@ interface CatalogEntry {
|
||||
maxFuel: number;
|
||||
burnPerKm: number;
|
||||
speedKmh: number;
|
||||
capacity: number;
|
||||
}
|
||||
|
||||
interface StateBody {
|
||||
account: { cash: number; reputation: number };
|
||||
simState: { clockMs: number; timeScale: number; paused: boolean };
|
||||
planes: PlaneState[];
|
||||
contracts: ContractState[];
|
||||
packages: PackageState[];
|
||||
catalog: CatalogEntry[];
|
||||
fuelPricePerL: number;
|
||||
sellFraction: number;
|
||||
@@ -61,6 +65,15 @@ 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 });
|
||||
@@ -78,6 +91,13 @@ function haversineKm(lat1: number, lon1: number, lat2: number, lon2: number): nu
|
||||
return 2 * 6371 * Math.asin(Math.min(1, Math.sqrt(a)));
|
||||
}
|
||||
|
||||
// Mirrors lib/game/penalty.ts — the exact abandon penalty at a given game clock.
|
||||
function penaltyFor(reward: number, createdSimTime: number, deadlineSimTime: number, clockMs: number): number {
|
||||
const span = deadlineSimTime - createdSimTime;
|
||||
const frac = span > 0 ? Math.min(1, Math.max(0, (clockMs - createdSimTime) / span)) : 1;
|
||||
return Math.round(reward * (0.15 + 0.45 * frac) * 100) / 100;
|
||||
}
|
||||
|
||||
// Closest airport to EDDF from /api/airports → shortest possible arrival time at scale 60.
|
||||
async function nearestToHub(page: Page): Promise<{ icao: string; lat: number; lon: number }> {
|
||||
const fc = (await (await page.request.get('/api/airports')).json()) as {
|
||||
@@ -94,155 +114,160 @@ async function nearestToHub(page: Page): Promise<{ icao: string; lat: number; lo
|
||||
return nearest;
|
||||
}
|
||||
|
||||
// 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 },
|
||||
// Test hook (FLIGHT_TEST_HOOKS=1): create an open package on a known route.
|
||||
async function createPackage(page: Page, fromIcao: string, toIcao: string, deadlineOffsetMs?: number): Promise<PackageState> {
|
||||
const created = await page.request.post('/api/test/create-package', {
|
||||
data: { originIcao: fromIcao, destIcao: toIcao, deadlineOffsetMs },
|
||||
});
|
||||
expect(created.status()).toBe(201);
|
||||
return (await created.json()) as ContractState;
|
||||
return (await created.json()) as PackageState;
|
||||
}
|
||||
|
||||
// Create + accept + dispatch a contract on plane-starter via the API, then wait for arrival.
|
||||
async function flyViaApi(page: Page, fromIcao: string, toIcao: string): Promise<void> {
|
||||
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);
|
||||
// Self-heal: let any in-flight leg finish, then reposition plane-starter home via the API.
|
||||
async function flyHomeViaApi(page: Page): Promise<void> {
|
||||
const s0 = await getState(page);
|
||||
const starter = s0.planes.find((p) => p.id === 'plane-starter')!;
|
||||
if (starter.status === 'flying' || starter.locationIcao !== HUB) {
|
||||
// Fast-forward any leftover leg / repositioning flight (~3 min real time worst case at 60×).
|
||||
await setScale(page, SCALE);
|
||||
}
|
||||
if (starter.status === 'flying') {
|
||||
await expect
|
||||
.poll(async () => (await getState(page)).planes.find((p) => p.id === 'plane-starter')?.status === 'available', {
|
||||
timeout: 240_000,
|
||||
message: 'waiting for an in-flight plane-starter to land',
|
||||
})
|
||||
.toBe(true);
|
||||
}
|
||||
if (starter.locationIcao !== HUB) {
|
||||
const res = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
|
||||
expect(res.status()).toBe(200); // repositioning needs no package (v0.12.0 manual flight)
|
||||
await expect
|
||||
.poll(async () => (await getState(page)).planes.find((p) => p.id === 'plane-starter')?.locationIcao === HUB, {
|
||||
timeout: 240_000,
|
||||
message: 'waiting for plane-starter to land back at the hub',
|
||||
})
|
||||
.toBe(true);
|
||||
}
|
||||
}
|
||||
|
||||
test('open contracts render with names, reward and deadline; Accept moves the contract into the accepted group', async ({ page }) => {
|
||||
test.setTimeout(90_000);
|
||||
test('open packages render with names, reward and deadline; Load is enabled at a package origin', async ({ page }) => {
|
||||
test.setTimeout(300_000); // self-heal can take minutes after an interrupted run
|
||||
await flyHomeViaApi(page); // deterministic: the starter sits at EDDF
|
||||
|
||||
await page.goto('/');
|
||||
const right = page.getByTestId('right-pane');
|
||||
await expect(right).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
// ≥1 open contract row once the first snapshot lands — names, reward (€) and an Accept button.
|
||||
const rows = page.locator('li[data-testid^="open-contract-"]');
|
||||
await expect(rows.first(), 'an open contract row appears').toBeVisible({ timeout: 15_000 });
|
||||
// ≥1 open package row once the first snapshot lands — route, reward (€) and a Load button.
|
||||
const rows = page.locator('li[data-testid^="open-package-"]');
|
||||
await expect(rows.first(), 'an open package row appears').toBeVisible({ timeout: 15_000 });
|
||||
await expect(rows.first()).toContainText(/→/);
|
||||
await expect(page.locator('[data-testid^="open-contract-reward-"]').first()).toContainText('€');
|
||||
await expect(page.locator('[data-testid^="accept-"]').first()).toBeVisible();
|
||||
await expect(page.locator('[data-testid^="open-package-reward-"]').first()).toContainText('€');
|
||||
|
||||
// Deterministic target: a hook-created EDDF contract, accepted through the UI.
|
||||
// Deterministic target: a hook-created EDDF package — Load is enabled (starter at origin, free slot).
|
||||
const nearest = await nearestToHub(page);
|
||||
const contract = await createContract(page, HUB, nearest.icao);
|
||||
const pkg = await createPackage(page, HUB, nearest.icao);
|
||||
const row = page.locator(`li[data-testid="open-package-${pkg.id}"]`);
|
||||
await expect(row, 'the hook-created package appears in the list').toBeVisible({ timeout: 15_000 });
|
||||
await expect(page.getByTestId(`open-package-reward-${pkg.id}`)).toContainText(eur(pkg.reward));
|
||||
const loadBtn = page.getByTestId(`load-${pkg.id}`);
|
||||
await expect(loadBtn).toBeEnabled();
|
||||
|
||||
const row = page.locator(`li[data-testid="open-contract-${contract.id}"]`);
|
||||
await expect(row).toBeVisible({ timeout: 15_000 }); // the running poll picks it up
|
||||
await expect(page.getByTestId(`accept-${contract.id}`)).toBeVisible();
|
||||
await page.getByTestId(`accept-${contract.id}`).click();
|
||||
|
||||
// Server truth first…
|
||||
// Load through the UI — exactly one eligible plane, so the button acts directly.
|
||||
await loadBtn.click();
|
||||
const body = await getState(page);
|
||||
expect(body.contracts.find((c) => c.id === contract.id)?.status).toBe('accepted');
|
||||
// …and the UI: gone from open, present in the accepted group with dispatch controls.
|
||||
expect(body.packages.find((p) => p.id === pkg.id)?.status).toBe('loaded');
|
||||
expect(body.packages.find((p) => p.id === pkg.id)?.planeId).toBe('plane-starter');
|
||||
|
||||
// The loaded package leaves the open list and shows in the on-board section.
|
||||
await expect(row).not.toBeVisible();
|
||||
const accRow = page.getByTestId(`accepted-contract-${contract.id}`);
|
||||
await expect(accRow).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.getByTestId(`dispatch-plane-${contract.id}`)).toBeVisible();
|
||||
await expect(page.getByTestId(`onboard-package-${pkg.id}`)).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
|
||||
test('dispatch loop through the UI + market refuel with exact cost', async ({ page }) => {
|
||||
test.setTimeout(240_000); // two flights at scale 60 plus UI steps — typical run well under three minutes
|
||||
test('Load is disabled when no plane sits at the package origin', async ({ page }) => {
|
||||
test.setTimeout(300_000); // self-heal can take minutes after an interrupted run
|
||||
await flyHomeViaApi(page); // starter at EDDF — a Madrid-origin package cannot be loaded here
|
||||
|
||||
// Self-heal first: a previous interrupted run may have left plane-starter mid-flight or at
|
||||
// another airport. Let any flight finish, then fly it home (same pattern as actions.spec.ts).
|
||||
let body = await getState(page);
|
||||
const starter0 = body.planes.find((p) => p.id === 'plane-starter');
|
||||
expect(starter0).toBeDefined();
|
||||
if (starter0!.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);
|
||||
body = await getState(page);
|
||||
}
|
||||
if (body.planes.find((p) => p.id === 'plane-starter')!.locationIcao !== HUB) {
|
||||
const from = body.planes.find((p) => p.id === 'plane-starter')!.locationIcao;
|
||||
await flyViaApi(page, from, HUB);
|
||||
}
|
||||
const pkg = await createPackage(page, 'LEMD', HUB);
|
||||
await page.goto('/');
|
||||
const row = page.locator(`li[data-testid="open-package-${pkg.id}"]`);
|
||||
await expect(row).toBeVisible({ timeout: 15_000 });
|
||||
await expect(page.getByTestId(`load-${pkg.id}`)).toBeDisabled();
|
||||
});
|
||||
|
||||
test('on-board list: fly-to-destination + abandon with penalty preview; flying planes show their leg', async ({ page }) => {
|
||||
test.setTimeout(240_000); // one short flight at scale 60 plus UI steps — typical run well under three minutes
|
||||
await flyHomeViaApi(page);
|
||||
|
||||
// Hook contract on the shortest EDDF route, accepted through the UI.
|
||||
const nearest = await nearestToHub(page);
|
||||
const contract = await createContract(page, HUB, nearest.icao);
|
||||
const pkg = await createPackage(page, HUB, nearest.icao, 24 * 3_600_000);
|
||||
const loadRes = await page.request.post(`/api/packages/${pkg.id}/load`, { data: { planeId: 'plane-starter' } });
|
||||
expect(loadRes.status()).toBe(200);
|
||||
|
||||
await page.goto('/');
|
||||
const row = page.locator(`li[data-testid="open-contract-${contract.id}"]`);
|
||||
await expect(row).toBeVisible({ timeout: 15_000 });
|
||||
await page.getByTestId(`accept-${contract.id}`).click();
|
||||
const onboardRow = page.getByTestId(`onboard-package-${pkg.id}`);
|
||||
await expect(onboardRow, 'loaded package appears in the on-board list').toBeVisible({ timeout: 15_000 });
|
||||
|
||||
// Dispatch controls appear in the accepted group; pick plane-starter and dispatch.
|
||||
const accRow = page.getByTestId(`accepted-contract-${contract.id}`);
|
||||
await expect(accRow).toBeVisible({ timeout: 10_000 });
|
||||
const picker = page.getByTestId(`dispatch-plane-${contract.id}`);
|
||||
await expect(picker).toBeVisible();
|
||||
await picker.selectOption('plane-starter'); // grounded at EDDF → enabled option
|
||||
await page.getByTestId(`dispatch-${contract.id}`).click();
|
||||
// The abandon button previews the exact penalty at the snapshot's clock (same formula as the server).
|
||||
// Freeze the clock first so every snapshot — mine and the UI's last poll — shares one clock value,
|
||||
// making the preview deterministic instead of racing the 1 s poll loop.
|
||||
await postSim(page, { paused: true });
|
||||
const state = await getState(page);
|
||||
const expectedPenalty = penaltyFor(pkg.reward, pkg.createdSimTime, pkg.deadlineSimTime, state.simState.clockMs);
|
||||
await expect(page.getByTestId(`abandon-penalty-${pkg.id}`)).toContainText(eur(expectedPenalty));
|
||||
|
||||
// Fly to the destination through the UI — the button is the destination's name.
|
||||
const flyBtn = page.getByTestId(`fly-to-${pkg.id}`);
|
||||
await expect(flyBtn).toBeEnabled();
|
||||
await flyBtn.click();
|
||||
|
||||
// The on-board block now shows the plane in flight with its current leg…
|
||||
await expect(page.getByTestId('onboard-plane-starter')).toContainText(/in flight/i, { timeout: 10_000 });
|
||||
const leg = page.getByTestId('plane-leg-plane-starter');
|
||||
await expect(leg).toBeVisible();
|
||||
await expect(leg).toContainText('→');
|
||||
|
||||
// UI shows the in-flight indicator…
|
||||
await expect(page.getByTestId(`contract-inflight-${contract.id}`)).toBeVisible({ timeout: 10_000 });
|
||||
// …and /api/state agrees.
|
||||
body = await getState(page);
|
||||
let starter = body.planes.find((p) => p.id === 'plane-starter')!;
|
||||
expect(starter.status).toBe('flying');
|
||||
let body = await getState(page);
|
||||
expect(body.planes.find((p) => p.id === 'plane-starter')!.status).toBe('flying');
|
||||
expect(body.planes.find((p) => p.id === 'plane-starter')!.destIcao).toBe(nearest.icao);
|
||||
|
||||
// Fly to arrival at scale 60 (poll the authoritative state).
|
||||
await postSim(page, { timeScale: SCALE });
|
||||
// Resume and fast-forward to arrival (poll the authoritative state).
|
||||
await postSim(page, { paused: false });
|
||||
await setScale(page, SCALE);
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const b = await getState(page);
|
||||
const p = b.planes.find((x) => x.id === 'plane-starter');
|
||||
return !!p && p.status === 'available' && p.locationIcao === nearest.icao;
|
||||
}, { timeout: 90_000, message: `waiting for arrival at ${nearest.icao} (scale 60)` })
|
||||
}, { timeout: 120_000, message: `waiting for arrival at ${nearest.icao} (scale 60)` })
|
||||
.toBe(true);
|
||||
|
||||
// Market refuel through the pane: the flight genuinely burned fuel…
|
||||
// The in-deadline package was delivered on arrival — it leaves the on-board list…
|
||||
body = await getState(page);
|
||||
starter = body.planes.find((p) => p.id === 'plane-starter')!;
|
||||
expect(starter.fuel).toBeLessThan(starter.maxFuel);
|
||||
const expectedCost = Math.round((starter.maxFuel - starter.fuel) * body.fuelPricePerL * 100) / 100;
|
||||
const cashBefore = body.account.cash;
|
||||
expect(body.packages.find((p) => p.id === pkg.id)?.status).toBe('delivered');
|
||||
await expect(onboardRow).not.toBeVisible({ timeout: 15_000 });
|
||||
|
||||
// …the refuel picker lists it, the preview matches the exact cost, and the click deducts it.
|
||||
await expect(page.getByTestId('market-refuel-plane')).toBeVisible({ timeout: 15_000 });
|
||||
await page.getByTestId('market-refuel-plane').selectOption('plane-starter');
|
||||
await expect(page.getByTestId('market-refuel-cost')).toContainText(eur(expectedCost));
|
||||
await page.getByTestId('market-refuel').click();
|
||||
|
||||
// Cash decreased by exactly the expected cost and the tank is full (server truth).
|
||||
// Restore: fly home so the live instance stays in its onboarding state for the next run.
|
||||
const homeRes = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
|
||||
expect(homeRes.status()).toBe(200);
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const b = await getState(page);
|
||||
return Math.abs(b.account.cash - (cashBefore - expectedCost)) < 0.001;
|
||||
}, { timeout: 15_000, message: 'waiting for the refuel cost to land in /api/state' })
|
||||
.poll(async () => (await getState(page)).planes.find((p) => p.id === 'plane-starter')?.locationIcao === HUB, {
|
||||
timeout: 120_000,
|
||||
message: 'waiting for plane-starter to land back at the hub',
|
||||
})
|
||||
.toBe(true);
|
||||
body = await getState(page);
|
||||
starter = body.planes.find((p) => p.id === 'plane-starter')!;
|
||||
expect(starter.fuel).toBe(starter.maxFuel);
|
||||
|
||||
// Fly home so the live instance stays in its onboarding state for the next run.
|
||||
await flyViaApi(page, nearest.icao, HUB);
|
||||
});
|
||||
|
||||
test('market buy/sell render with price and payout previews from the catalog in /api/state', async ({ page }) => {
|
||||
test.setTimeout(60_000);
|
||||
test('market buy/sell/refuel render with previews from the catalog in /api/state; refuel deducts exactly', async ({ page }) => {
|
||||
test.setTimeout(300_000); // self-heal can take minutes after an interrupted run
|
||||
await flyHomeViaApi(page); // grounded starter at EDDF (tank partial after the previous test's flight)
|
||||
|
||||
const body = await getState(page); // authoritative catalog + economy constants (v0.10.0)
|
||||
expect(body.catalog.length).toBeGreaterThan(0);
|
||||
const entry = body.catalog.find((e) => e.model === 'atr72')!;
|
||||
expect(entry).toBeDefined();
|
||||
expect(entry.capacity).toBe(6); // v0.12.0 slot capacity exposed in the snapshot
|
||||
|
||||
await page.goto('/');
|
||||
// Buy: the model picker lists the catalog entry and the price preview is its full price.
|
||||
@@ -255,91 +280,30 @@ test('market buy/sell render with price and payout previews from the catalog in
|
||||
// Sell: the grounded-plane picker lists plane-starter and the payout preview is
|
||||
// sellFraction × price, both from the snapshot's catalog/constants.
|
||||
const starter = body.planes.find((p) => p.id === 'plane-starter')!;
|
||||
expect(starter.status).toBe('available'); // test order: it flew home at the end of the previous test
|
||||
expect(starter.status).toBe('available');
|
||||
await page.getByTestId('market-sell-plane').selectOption('plane-starter', { timeout: 15_000 });
|
||||
const payout = Math.round(entry.price * body.sellFraction * 100) / 100;
|
||||
await expect(page.getByTestId('market-sell-payout')).toContainText(eur(payout));
|
||||
await expect(page.getByTestId('market-sell')).toBeVisible();
|
||||
});
|
||||
|
||||
test('dispatch pickers are independent per contract — selecting one does not leak into the others (v0.10.1)', async ({ page }) => {
|
||||
test.setTimeout(240_000); // two short flights at scale 60 plus UI steps
|
||||
// Refuel through the pane: the flight genuinely burned fuel…
|
||||
if (starter.fuel < starter.maxFuel) {
|
||||
const expectedCost = Math.round((starter.maxFuel - starter.fuel) * body.fuelPricePerL * 100) / 100;
|
||||
const cashBefore = body.account.cash;
|
||||
|
||||
// Self-heal first: a previous interrupted run may have left plane-starter mid-flight or at
|
||||
// another airport (same pattern as the dispatch-loop test above).
|
||||
let body = await getState(page);
|
||||
const starter0 = body.planes.find((p) => p.id === 'plane-starter');
|
||||
expect(starter0).toBeDefined();
|
||||
if (starter0!.status === 'flying') {
|
||||
await postSim(page, { timeScale: SCALE });
|
||||
// …the refuel picker lists it, the preview matches the exact cost, and the click deducts it.
|
||||
await expect(page.getByTestId('market-refuel-plane')).toBeVisible({ timeout: 15_000 });
|
||||
await page.getByTestId('market-refuel-plane').selectOption('plane-starter');
|
||||
await expect(page.getByTestId('market-refuel-cost')).toContainText(eur(expectedCost));
|
||||
await page.getByTestId('market-refuel').click();
|
||||
|
||||
// Cash decreased by exactly the expected cost and the tank is full (server truth).
|
||||
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',
|
||||
})
|
||||
.poll(async () => {
|
||||
const b = await getState(page);
|
||||
return Math.abs(b.account.cash - (cashBefore - expectedCost)) < 0.001;
|
||||
}, { timeout: 15_000, message: 'waiting for the refuel cost to land in /api/state' })
|
||||
.toBe(true);
|
||||
body = await getState(page);
|
||||
const after = await getState(page);
|
||||
expect(after.planes.find((p) => p.id === 'plane-starter')!.fuel).toBe(starter.maxFuel);
|
||||
}
|
||||
if (body.planes.find((p) => p.id === 'plane-starter')!.locationIcao !== HUB) {
|
||||
const from = body.planes.find((p) => p.id === 'plane-starter')!.locationIcao;
|
||||
await flyViaApi(page, from, HUB);
|
||||
}
|
||||
|
||||
// Two hook contracts, both originating at EDDF on the shortest route.
|
||||
const nearest = await nearestToHub(page);
|
||||
const a = await createContract(page, HUB, nearest.icao);
|
||||
const b = await createContract(page, HUB, nearest.icao);
|
||||
|
||||
await page.goto('/');
|
||||
// Accept BOTH through the UI buttons.
|
||||
for (const c of [a, b]) {
|
||||
const row = page.locator(`li[data-testid="open-contract-${c.id}"]`);
|
||||
await expect(row).toBeVisible({ timeout: 15_000 });
|
||||
await page.getByTestId(`accept-${c.id}`).click();
|
||||
}
|
||||
|
||||
const accA = page.getByTestId(`accepted-contract-${a.id}`);
|
||||
const accB = page.getByTestId(`accepted-contract-${b.id}`);
|
||||
await expect(accA).toBeVisible({ timeout: 10_000 });
|
||||
await expect(accB).toBeVisible();
|
||||
|
||||
// Both dispatch pickers render with the placeholder default — nothing preselected (v0.10.1)…
|
||||
const pickerA = page.getByTestId(`dispatch-plane-${a.id}`);
|
||||
const pickerB = page.getByTestId(`dispatch-plane-${b.id}`);
|
||||
await expect(pickerA).toBeVisible();
|
||||
await expect(pickerB).toBeVisible();
|
||||
await expect(pickerA).toHaveValue('');
|
||||
await expect(pickerB).toHaveValue('');
|
||||
await expect(pickerA.locator('option[value=""]')).toHaveText(/select a plane/i);
|
||||
|
||||
// …and the Dispatch buttons stay disabled until a valid plane is chosen.
|
||||
await expect(page.getByTestId(`dispatch-${a.id}`)).toBeDisabled();
|
||||
await expect(page.getByTestId(`dispatch-${b.id}`)).toBeDisabled();
|
||||
|
||||
// Select plane-starter in contract A's picker → contract B's picker must STILL show the
|
||||
// placeholder (the v0.10.1 regression: selections used to appear shared across rows).
|
||||
await pickerA.selectOption('plane-starter');
|
||||
await expect(pickerA).toHaveValue('plane-starter');
|
||||
await expect(pickerB).toHaveValue('');
|
||||
|
||||
// Dispatch A with the selected plane → it goes 'flying' and its row shows the in-flight…
|
||||
await page.getByTestId(`dispatch-${a.id}`).click();
|
||||
await expect(page.getByTestId(`contract-inflight-${a.id}`)).toBeVisible({ timeout: 10_000 });
|
||||
body = await getState(page);
|
||||
expect(body.planes.find((p) => p.id === 'plane-starter')!.status).toBe('flying');
|
||||
|
||||
// …indicator, while B remains accepted with its own independent picker on the placeholder.
|
||||
await expect(accB).toBeVisible();
|
||||
await expect(pickerB).toHaveValue('');
|
||||
await expect(page.getByTestId(`dispatch-${b.id}`)).toBeDisabled();
|
||||
|
||||
// Restore: let A's flight finish and fly plane-starter home so later specs start clean.
|
||||
await postSim(page, { timeScale: SCALE });
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const p = (await getState(page)).planes.find((x) => x.id === 'plane-starter');
|
||||
return !!p && p.status === 'available' && p.locationIcao === nearest.icao;
|
||||
}, { timeout: 90_000, message: `waiting for arrival at ${nearest.icao} (scale 60)` })
|
||||
.toBe(true);
|
||||
await flyViaApi(page, nearest.icao, HUB);
|
||||
});
|
||||
|
||||
+19
-19
@@ -1,14 +1,14 @@
|
||||
import { test, expect, type Page } from '@playwright/test';
|
||||
|
||||
// v0.7.0 sim engine against the live origin: tick-on-read clock advance at a
|
||||
// raised time scale, pause freezing, contract expiry (via the FLIGHT_TEST_HOOKS
|
||||
// raised time scale, pause freezing, package expiry (via the FLIGHT_TEST_HOOKS
|
||||
// hook enabled in docker-compose.yml), and market top-up.
|
||||
|
||||
// Must match lib/sim/engine.ts — kept in sync manually (same pattern as MIN_ZOOM/MAX_ZOOM in map.spec.ts).
|
||||
const MAX_TICK_ELAPSED_MS = 2_000;
|
||||
const SCALE = 60;
|
||||
const MAX_DELTA_PER_TICK = MAX_TICK_ELAPSED_MS * SCALE; // 120_000 game ms per tick at scale 60
|
||||
// Must match MARKET_TARGET_OPEN_CONTRACTS in lib/game/seed.ts.
|
||||
// Must match MARKET_TARGET_OPEN_PACKAGES in lib/game/seed.ts.
|
||||
const MARKET_TARGET = 10;
|
||||
|
||||
interface SimState {
|
||||
@@ -21,7 +21,7 @@ interface StateBody {
|
||||
account: { cash: number; reputation: number };
|
||||
simState: SimState;
|
||||
planes: Array<Record<string, unknown>>;
|
||||
contracts: Array<{ id: string; status: string; deadlineSimTime: number }>;
|
||||
packages: Array<{ id: string; status: string; deadlineSimTime: number }>;
|
||||
}
|
||||
|
||||
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||
@@ -74,16 +74,16 @@ test('paused freezes the clock exactly across polls', async ({ page }) => {
|
||||
}
|
||||
});
|
||||
|
||||
test('an open contract past its short deadline expires and the market stays topped up', async ({ page }) => {
|
||||
test('an open package past its short deadline expires and the market stays topped up', async ({ page }) => {
|
||||
test.setTimeout(60_000);
|
||||
|
||||
const before = await getState(page);
|
||||
const victim = before.contracts.find((c) => c.status === 'open');
|
||||
expect(victim).toBeDefined(); // steady state always has open contracts
|
||||
const victim = before.packages.find((p) => p.status === 'open');
|
||||
expect(victim).toBeDefined(); // steady state always has open packages
|
||||
|
||||
// Test-only hook (FLIGHT_TEST_HOOKS=1 in docker-compose): move an existing open
|
||||
// contract's deadline ~10 game-seconds out so expiry is observable at scale 60.
|
||||
const hook = await page.request.post('/api/test/expire-contract', { data: { contractId: victim!.id } });
|
||||
// package's deadline ~10 game-seconds out so expiry is observable at scale 60.
|
||||
const hook = await page.request.post('/api/test/expire-package', { data: { packageId: victim!.id } });
|
||||
expect(hook.status()).toBe(200);
|
||||
const moved = (await hook.json()) as { id: string; deadlineSimTime: number };
|
||||
expect(moved.id).toBe(victim!.id);
|
||||
@@ -94,24 +94,24 @@ test('an open contract past its short deadline expires and the market stays topp
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const body = await getState(page);
|
||||
if (body.contracts.find((c) => c.id === moved.id)?.status === 'expired') expiredBody = body;
|
||||
if (body.packages.find((p) => p.id === moved.id)?.status === 'expired') expiredBody = body;
|
||||
return !!expiredBody;
|
||||
}, { timeout: 30_000, message: 'waiting for the short-deadline contract to expire' })
|
||||
}, { timeout: 30_000, message: 'waiting for the short-deadline package to expire' })
|
||||
.toBe(true);
|
||||
|
||||
const after = expiredBody!;
|
||||
expect(after.contracts.find((c) => c.id === moved.id)?.status).toBe('expired');
|
||||
// Expired contracts leave the available set…
|
||||
expect(after.contracts.filter((c) => c.status === 'open').some((c) => c.id === moved.id)).toBe(false);
|
||||
expect(after.packages.find((p) => p.id === moved.id)?.status).toBe('expired');
|
||||
// Expired packages leave the available set…
|
||||
expect(after.packages.filter((p) => p.status === 'open').some((c) => c.id === moved.id)).toBe(false);
|
||||
// …and spawn tops the market back up to the target — never below it.
|
||||
const openCount = after.contracts.filter((c) => c.status === 'open').length;
|
||||
const openCount = after.packages.filter((p) => p.status === 'open').length;
|
||||
expect(openCount).toBeGreaterThanOrEqual(MARKET_TARGET);
|
||||
});
|
||||
|
||||
test('the hook can also create a fresh short-deadline contract, which then expires', async ({ page }) => {
|
||||
test('the hook can also create a fresh short-deadline package, which then expires', async ({ page }) => {
|
||||
test.setTimeout(60_000);
|
||||
|
||||
const created = await page.request.post('/api/test/expire-contract');
|
||||
const created = await page.request.post('/api/test/expire-package');
|
||||
expect(created.status()).toBe(201);
|
||||
const c = (await created.json()) as { id: string; status: string };
|
||||
expect(c.status).toBe('open');
|
||||
@@ -122,12 +122,12 @@ test('the hook can also create a fresh short-deadline contract, which then expir
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const body = await getState(page);
|
||||
if (body.contracts.find((x) => x.id === c.id)?.status === 'expired') expiredBody = body;
|
||||
if (body.packages.find((p) => p.id === c.id)?.status === 'expired') expiredBody = body;
|
||||
return !!expiredBody;
|
||||
}, { timeout: 30_000, message: 'waiting for the hook-created contract to expire' })
|
||||
}, { timeout: 30_000, message: 'waiting for the hook-created package to expire' })
|
||||
.toBe(true);
|
||||
|
||||
const openCount = expiredBody!.contracts.filter((x) => x.status === 'open').length;
|
||||
const openCount = expiredBody!.packages.filter((p) => p.status === 'open').length;
|
||||
expect(openCount).toBeGreaterThanOrEqual(MARKET_TARGET); // top-up held after this expiry too
|
||||
});
|
||||
|
||||
|
||||
+5
-5
@@ -10,7 +10,7 @@ interface StateBody {
|
||||
account: { cash: number; reputation: number };
|
||||
simState: { clockMs: number; timeScale: number; paused: boolean };
|
||||
planes: Array<Record<string, unknown>>;
|
||||
contracts: Array<Record<string, unknown>>;
|
||||
packages: Array<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
test('GET /api/state returns a seeded snapshot', async ({ page }) => {
|
||||
@@ -32,10 +32,10 @@ test('GET /api/state returns a seeded snapshot', async ({ page }) => {
|
||||
expect(body.planes[0], `plane.${key}`).toHaveProperty(key);
|
||||
}
|
||||
|
||||
expect(Array.isArray(body.contracts)).toBe(true);
|
||||
expect(body.contracts.length).toBeGreaterThan(0);
|
||||
expect(Array.isArray(body.packages)).toBe(true);
|
||||
expect(body.packages.length).toBeGreaterThan(0);
|
||||
for (const key of ['id', 'originIcao', 'destIcao', 'reward', 'cargoType', 'deadlineSimTime', 'status']) {
|
||||
expect(body.contracts[0], `contract.${key}`).toHaveProperty(key);
|
||||
expect(body.packages[0], `package.${key}`).toHaveProperty(key);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -78,5 +78,5 @@ test('state survives a container restart (sim clock + account persisted)', async
|
||||
expect(a.account.cash).toBe(before.account.cash);
|
||||
expect(a.account.reputation).toBe(before.account.reputation);
|
||||
expect(a.planes.map((p) => p.id).sort()).toEqual(before.planes.map((p) => p.id).sort());
|
||||
expect(a.contracts.map((c) => c.id).sort()).toEqual(before.contracts.map((c) => c.id).sort());
|
||||
expect(a.packages.map((p) => p.id).sort()).toEqual(before.packages.map((p) => p.id).sort());
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user