feat: v0.19.0 — ISS-011 auto-slow to 1x when all planes have landed
CI / build + unit + e2e (pull_request) Successful in 6m14s

This commit is contained in:
2026-09-19 18:07:42 +00:00
parent f0602453ea
commit 86379fc9c6
9 changed files with 261 additions and 7 deletions
+14 -3
View File
@@ -117,6 +117,9 @@ async function flyHomeViaApi(page: Page): Promise<void> {
plane = s.planes.find((p) => p.id === 'plane-starter')!;
}
if (plane.locationIcao !== HUB) {
// ISS-011: the landing above auto-slown the sim to 1× — re-set SCALE so the return leg
// fast-forwards instead of taking real-time hours.
await setScale(page, SCALE);
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);
@@ -221,7 +224,9 @@ test('full core loop via UI: load → fly to destination → exact reward + rep
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).
// Restore: fly home (repositioning — no package needed in the v0.12.0 model). ISS-011: the
// arrival above auto-slown the sim to 1×, so re-set SCALE for the return leg.
await setScale(page, SCALE);
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);
@@ -308,8 +313,10 @@ test('expired packages deliver and abandon with no reward and no penalty', async
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).
// Restore: refuel + fly home. ISS-011: the LEMD arrival above auto-slown the sim to 1×, so
// re-set SCALE for the return leg (the expiry wait's scale no longer persists past a landing).
await page.request.post('/api/planes/plane-starter/refuel');
await setScale(page, SCALE);
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);
@@ -347,7 +354,9 @@ test('load and fly validation paths (404 / 409 / 400)', async ({ page }) => {
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.
// Fly home from the hop so the fuel section starts at the hub. ISS-011: the hop arrival above
// auto-slown the sim to 1×, so re-set SCALE for this leg.
await setScale(page, SCALE);
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);
@@ -370,6 +379,8 @@ test('load and fly validation paths (404 / 409 / 400)', async ({ page }) => {
const atFar = sFar.planes.find((p) => p.id === 'plane-starter')!;
expect(atFar.locationIcao).toBe(FAR);
// The return leg fits: the weighted burn over it is less than what's left in the tank.
// ISS-011: the FAR arrival above auto-slown the sim to 1×, so re-set SCALE for this leg.
await setScale(page, SCALE);
const backRes = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
expect(backRes.ok()).toBeTruthy();
await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')!.locationIcao), { timeout: 240_000 }).toBe(HUB);
+130
View File
@@ -0,0 +1,130 @@
import type { Page } from '@playwright/test';
import { expect, test } from '@playwright/test';
// v0.19.0 — ISS-011: when the LAST plane lands (no plane remains in flight) the sim auto-slows
// to 1×, server-side and persisted on the sim_state row so the UI slider reflects it on the next
// snapshot. Arrival-triggered by design: a grounded fleet at high scale is left alone, there is no
// auto-restore — the player speeds up manually again (asserted below).
const SCALE = 1000; // fastest sim speed — top of the v0.14.0 ladder (same convention as e2e/sim.spec.ts)
const HUB = 'EDDF'; // Frankfurt Main — the seeded starter plane's home base (lib/game/seed.ts)
interface SimState {
clockMs: number;
timeScale: number;
paused: boolean;
}
interface StateBody {
simState: SimState;
planes: Array<{ id: string; status: string; locationIcao: string; destIcao: string | null }>;
}
async function getState(page: Page): Promise<StateBody> {
const res = await page.request.get('/api/state');
expect(res.ok()).toBeTruthy();
return (await res.json()) as StateBody;
}
async function postSim(page: Page, body: Record<string, unknown>): Promise<void> {
const res = await page.request.post('/api/sim', { data: body });
expect(res.status()).toBe(200);
}
// 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);
const body = (await res.json()) as SimState;
expect(body.timeScale, `timeScale should be ${scale} after POST /api/sim`).toBe(scale);
}
// Closest airport to the hub from /api/airports → shortest possible leg (fastest e2e flight).
async function nearestToHub(page: Page): Promise<{ icao: string }> {
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;
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 };
}
// Self-heal helper (same pattern as e2e/relocation.spec.ts): let any in-flight leg finish and
// reposition plane-starter home via the API so every test starts from the canonical state even
// after an interrupted previous run.
async function flyHomeViaApi(page: Page): Promise<void> {
let s = await getState(page);
let plane = s.planes.find((p) => p.id === 'plane-starter')!;
if (plane.status === 'flying' || plane.locationIcao !== HUB) {
// Fast-forward any leftover leg / repositioning flight (~20 s real time worst case at 1000×).
await setScale(page, SCALE);
}
if (plane.status === 'flying') {
// Let the in-flight leg finish before repositioning. While flying, locationIcao stays at the
// DEPARTURE airport — re-read state after landing before deciding whether a repositioning
// flight is needed.
await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')!.status), { timeout: 240_000 }).toBe('available');
s = await getState(page);
plane = s.planes.find((p) => p.id === 'plane-starter')!;
}
if (plane.locationIcao !== HUB) {
// ISS-011: the landing above auto-slown the sim to 1× — re-set SCALE so the return leg
// fast-forwards instead of taking real-time hours.
await setScale(page, SCALE);
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);
}
}
// Self-heal in CLEANUP (afterEach): the test deliberately leaves plane-starter at the far end, so
// fast-forward + reposition home here; then restore scale=1 / unpaused for later specs.
test.afterEach(async ({ page }) => {
await flyHomeViaApi(page);
await postSim(page, { timeScale: 1, paused: false });
});
test.describe.configure({ mode: 'serial' }); // shared live DB — order matters within this file
test('auto-slow to 1× when the last plane lands; manual speed-up still works (ISS-011)', async ({ page }) => {
test.setTimeout(300_000); // self-heal (worst case ~20 s at 1000×) + one short leg
await flyHomeViaApi(page); // deterministic: the starter sits grounded at EDDF
const nearest = await nearestToHub(page);
// Set 1000× via the API and verify it stuck.
await setScale(page, SCALE);
// Dispatch plane-starter on the shortest leg from its hub (repositioning — no package needed).
const flyRes = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: nearest.icao } });
expect(flyRes.status()).toBe(200);
// Wait for the landing…
let landedBody: StateBody | undefined;
await expect
.poll(async () => {
const b = await getState(page);
const p = b.planes.find((x) => x.id === 'plane-starter')!;
if (p.status === 'available' && p.locationIcao === nearest.icao) landedBody = b;
return !!landedBody;
}, { timeout: 120_000, message: `waiting for plane-starter to land at ${nearest.icao}` })
.toBe(true);
// …and the server has auto-slown to 1× — persisted on sim_state, so this very snapshot shows it.
expect(landedBody!.simState.timeScale).toBe(1);
// Manual override still works: POST /api/sim {timeScale: 1000} → confirmed by the response…
const post = await page.request.post('/api/sim', { data: { timeScale: SCALE } });
expect(post.status()).toBe(200);
expect(((await post.json()) as SimState).timeScale).toBe(SCALE);
// …and by a fresh snapshot (no auto-restore of the previous scale — and no re-slow either,
// since nothing is in flight: the trigger is arrival-based, not idle-enforcement).
expect((await getState(page)).simState.timeScale).toBe(SCALE);
});
+5
View File
@@ -89,6 +89,9 @@ async function flyHomeViaApi(page: Page): Promise<void> {
.toBe(true);
}
if (starter.locationIcao !== HUB) {
// ISS-011: the landing above auto-slown the sim to 1× — re-set SCALE so the return leg
// fast-forwards instead of taking real-time hours.
await setScale(page, SCALE);
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
@@ -291,6 +294,8 @@ test('hovering an on-board block of a flying plane highlights its route', async
}, { timeout: 120_000, message: `waiting for plane-starter to land at ${DEST}` })
.toBe(true);
// ISS-011: the arrival above auto-slown the sim to 1×, so re-set SCALE for this leg.
await setScale(page, SCALE);
const home = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
expect(home.status()).toBe(200);
await expect
+3
View File
@@ -72,6 +72,9 @@ async function flyHomeViaApi(page: Page): Promise<void> {
plane = s.planes.find((p) => p.id === 'plane-starter')!;
}
if (plane.locationIcao !== HUB) {
// ISS-011: the landing above auto-slown the sim to 1× — re-set SCALE so the return leg
// fast-forwards instead of taking real-time hours.
await setScale(page, SCALE);
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);
+8 -1
View File
@@ -174,6 +174,9 @@ async function flyHomeViaApi(page: Page): Promise<void> {
.toBe(true);
}
if (starter.locationIcao !== HUB) {
// ISS-011: the landing above auto-slown the sim to 1× — re-set SCALE so the return leg
// fast-forwards instead of taking real-time hours.
await setScale(page, SCALE);
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
@@ -284,6 +287,8 @@ test('on-board list: fly-to-destination + abandon with penalty preview; flying p
await expect(onboardRow).not.toBeVisible({ timeout: 15_000 });
// Restore: fly home so the live instance stays in its onboarding state for the next run.
// ISS-011: the arrival above auto-slown the sim to 1×, so re-set SCALE for this leg.
await setScale(page, SCALE);
const homeRes = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
expect(homeRes.status()).toBe(200);
await expect
@@ -401,7 +406,9 @@ test('on-board row Refuel tops up just enough for the package leg; market button
await expect(page.getByTestId('market-refuel')).toHaveText('Refuel to full');
// Restore: fly home — pkg2's destination IS the hub, so it delivers on arrival. The tank holds
// exactly the leg requirement, so this also proves "just enough" is enough.
// exactly the leg requirement, so this also proves "just enough" is enough. ISS-011: the far-end
// arrival above auto-slown the sim to 1×, so re-set SCALE for this leg.
await setScale(page, SCALE);
expect((await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } })).status()).toBe(200);
await expect
.poll(async () => (await getState(page)).planes.find((p) => p.id === 'plane-starter')?.locationIcao === HUB, {