v0.65.0: per-airport job pools with real-time refresh — open jobs spawn per pool airport (grounded/flying/queued locations) in a fixed POOL_BANDS composition, expire on the wall clock after JOB_LIFETIME_MS even while the world is frozen, and refill taken slots after a per-band cooldown; /api/state + SSE filter open jobs to pool origins and expose expiresRealMs for the right-pane countdown
CI / build + unit + e2e (pull_request) Failing after 17m26s

This commit is contained in:
2026-09-23 13:09:49 +00:00
parent 9c2c64fe89
commit 12d900b22a
24 changed files with 556 additions and 344 deletions
+20
View File
@@ -70,3 +70,23 @@ test('long-leg priority packages always expire after travel time at max speed +
}
}
});
// v0.65.0: open jobs live on the WALL clock — JOB_LIFETIME_MS after createdRealMs they expire
// regardless of time scale or fleet state. The age hook (FLIGHT_TEST_HOOKS=1) stamps a job past its
// lifetime and runs a tick, so expiry is observable in one round-trip; self-contained like the test
// above (the assertion lives in the hook response, no /api/state polling).
test('an open job aged past its wall-clock lifetime expires on the next tick', async ({ page }) => {
const pkg = await createPriorityPackage(page, FROM, TO);
const res = await page.request.post('/api/test/expire-package', { data: { packageId: pkg.id } });
expect(res.status()).toBe(200);
const aged = (await res.json()) as Pkg & { status: string };
expect(aged.id).toBe(pkg.id);
expect(aged.status, 'the hook runs a tick after aging the job, so it is expired by response time').toBe('expired');
// Unknown ids and non-open jobs still 404.
const missing = await page.request.post('/api/test/expire-package', { data: { packageId: 'no-such-id' } });
expect(missing.status()).toBe(404);
const alreadyExpired = await page.request.post('/api/test/expire-package', { data: { packageId: pkg.id } });
expect(alreadyExpired.status()).toBe(404); // expired is not open — the hook only ages live jobs
});
+9 -6
View File
@@ -361,14 +361,17 @@ test('hovering an open package highlights its endpoint airports', async ({ page
}).toBe(0);
});
// ISS-120 (v0.53.0): Auckland → Fua'amotu (Tonga) — both in the world dataset, and their short great
// circle crosses ±180 EASTWARD. Before the fix the arc's wrapped samples jumped +179.x → −179.x and
// MapLibre connected them with a chord across the whole map; now the trace is one continuous line.
// ISS-120 (v0.53.0): Frankfurt → Fua'amotu (Tonga) — their short great circle crosses ±180 EASTWARD
// (sampled: the raw longitude sequence jumps ~360° at the crossing, same as the original NZAA→NFTF pair).
// Before the fix the arc's wrapped samples jumped +179.x → −179.x and MapLibre connected them with a
// chord across the whole map; now the trace is one continuous line. v0.65.0: origin moved from NZAA to
// EDDF — open jobs only render where a plane can take them (the pool), so the hook must create it at
// the hub; same crossing, no relocation needed.
test('hovering an antimeridian-crossing package draws one continuous trace, no chord across the map', async ({ page }) => {
test.setTimeout(300_000); // self-heal can take minutes after an interrupted run
await flyHomeViaApi(page);
const pkg = await createPackage(page, 'NZAA', 'NFTF');
const pkg = await createPackage(page, HUB, 'NFTF');
await page.goto('/');
await waitForAirportCount(page);
@@ -377,10 +380,10 @@ test('hovering an antimeridian-crossing package draws one continuous trace, no c
await expect(row, 'the hook-created package appears in the list').toBeVisible({ timeout: 15_000 });
// fitToAirports unwraps crossing pairs (ISS-120) so both endpoints are in-viewport.
await fitToAirports(page, ['NZAA', 'NFTF']);
await fitToAirports(page, [HUB, 'NFTF']);
await row.hover();
await expectHoverRoute(page, 'NZAA', 'NFTF'); // endpoint check is mod-360 aware now (ISS-120)
await expectHoverRoute(page, HUB, 'NFTF'); // endpoint check is mod-360 aware now (ISS-120)
// The regression itself: no rendered segment may contain a ±180 longitude jump — that was the chord.
await expect.poll(async () => {
+11 -5
View File
@@ -35,6 +35,8 @@ interface PlaneState {
interface PackageState {
id: string;
originIcao: string; // v0.65.0 — pool visibility assertions need the origin
destIcao: string;
originName: string;
destName: string;
reward: number;
@@ -248,15 +250,19 @@ test('a priority package shows its special badge on the open-package row', async
await expect(badge).toHaveText('Priority');
});
test('Load is disabled when no plane sits at the package origin', async ({ page }) => {
test('open jobs only appear where a plane can take them — foreign-origin jobs are hidden', 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
await flyHomeViaApi(page); // starter at EDDF — the pool is just {EDDF}
const pkg = await createPackage(page, 'LEMD', HUB);
expect(pkg.originIcao).toBe('LEMD');
// v0.65.0: open jobs are only visible from airports where a plane can take them (the pool). LEMD is
// not in the pool while the starter sits at EDDF, so the job exists server-side but never reaches
// /api/state — no row and no disabled Load button to speak of.
expect((await getState(page)).packages.some((p) => p.id === pkg.id), 'a non-pool-origin open job must not appear in /api/state').toBe(false);
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();
await expect(page.locator(`li[data-testid="open-package-${pkg.id}"]`)).toHaveCount(0, { timeout: 15_000 });
});
test('on-board list: fly-to-destination + abandon with penalty preview; flying planes show their leg', async ({ page }) => {
+49 -63
View File
@@ -1,4 +1,8 @@
import { test, expect, type Page } from '@playwright/test';
// v0.65.0: the open-package count one pool airport keeps — imported (like expiry.spec.ts does) so it
// can never drift from lib/game/seed.ts. With a single grounded starter plane there is exactly one
// pool, at EDDF.
import { POOL_TARGET_PER_AIRPORT as POOL_TARGET } from '../lib/game/seed';
// v0.52.0 (ISS-117), finalized in #135: fixed world clock against the live origin — a fully
// grounded fleet freezes the clock, background-tick advance at a raised rate while something is
@@ -10,8 +14,6 @@ const MAX_TICK_ELAPSED_MS = 2_000;
const SCALE = 1000; // test rate for fast-forwarding legs
const WORLD = 24; // the fixed world rate (ISS-117) — must match lib/worldClock.ts
const MAX_DELTA_PER_TICK = MAX_TICK_ELAPSED_MS * SCALE; // 2_000_000 game ms per tick at scale 1000
// Must match MARKET_TARGET_OPEN_PACKAGES in lib/game/seed.ts.
const MARKET_TARGET = 10;
const STARTER = 'plane-starter';
interface StateBody {
@@ -101,7 +103,9 @@ test('a grounded fleet never starves: ignoring every open package cannot empty t
// Drain: ignore every open package, pass after pass. Pre-fix (v0.62.0) a frozen tick never
// spawned replacements, so this reached zero and stayed there — the soft-lock. Post-fix every
// request's tick re-tops-up BEFORE its removal, so the market can never empty; cap the passes to
// bound runtime either way (a zero-open snapshot would mean the bug is back).
// bound runtime either way (a zero-open snapshot would mean the bug is back). v0.65.0: ignoring
// starts each slot's refill cooldown (seconds in CI via FLIGHT_JOB_*_REFILL_MS), so the board
// comes back a few seconds after the drain, not instantly.
for (let pass = 0; pass < 3; pass++) {
const s = await getState(page);
const open = s.packages.filter((p) => p.status === 'open');
@@ -111,14 +115,15 @@ test('a grounded fleet never starves: ignoring every open package cannot empty t
}
}
// The ~1 s background tick must top the market back up within ~5 s of real time…
// The ~1 s background tick must refill the pool once the cooldowns lapse — within ~15 s of real
// time (CI's heavy-job refill delay is 5 s, so allow headroom for dind slowness)…
let topped: StateBody | undefined;
await expect
.poll(async () => {
const body = await getState(page);
if (body.packages.some((p) => p.status === 'open')) topped = body;
return !!topped;
}, { timeout: 5_000, message: 'waiting for the market to refill while grounded' })
}, { timeout: 15_000, message: 'waiting for the pool to refill while grounded' })
.toBe(true);
const after = topped!;
@@ -146,69 +151,50 @@ test('timeScale 1000 advances the clock by roughly real-time × 1000 between pol
}
});
test('an open package past its short deadline expires and the market stays topped up', async ({ page }) => {
test.setTimeout(60_000);
await activateWorld(page);
try {
const before = await getState(page);
const victim = before.packages.find((p) => p.status === 'open');
expect(victim).toBeDefined(); // steady state always has open packages
test('an open job past its wall-clock lifetime expires and its pool slot refills', async ({ page }) => {
const before = await getState(page);
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
// package's deadline ~10 game-seconds out so expiry is observable at scale 1000.
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);
// Test-only hook (FLIGHT_TEST_HOOKS=1 in docker-compose): ages the job's createdRealMs past its
// lifetime and runs a tick, so it comes back already expired — v0.65.0 expiry is wall-clock, no
// scale or clock activation needed.
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; status: string };
expect(moved.id).toBe(victim!.id);
expect(moved.status, 'the aged job should be expired by the tick the hook runs').toBe('expired');
await setScale(page, SCALE);
let expiredBody: StateBody | undefined;
await expect
.poll(async () => {
const body = await getState(page);
if (body.packages.find((p) => p.id === moved.id)?.status === 'expired') expiredBody = body;
return !!expiredBody;
}, { timeout: 30_000, message: 'waiting for the short-deadline package to expire' })
.toBe(true);
const after = expiredBody!;
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.packages.filter((p) => p.status === 'open').length;
expect(openCount).toBeGreaterThanOrEqual(MARKET_TARGET);
} finally {
await deactivateWorld(page);
}
// Expired jobs leave the market…
let after: StateBody | undefined;
await expect
.poll(async () => {
const body = await getState(page);
if (body.packages.filter((p) => p.status === 'open').length >= POOL_TARGET) after = body;
return !!after;
}, { timeout: 15_000, message: 'waiting for the pool to refill after expiry' })
.toBe(true);
// …and the spawner refills the pool back up — never below its target.
expect(after!.packages.filter((p) => p.status === 'open').length).toBeGreaterThanOrEqual(POOL_TARGET);
expect(after!.packages.some((p) => p.id === moved.id && p.status === 'open')).toBe(false); // expired stays out of the market
});
test('the hook can also create a fresh short-deadline package, which then expires', async ({ page }) => {
test.setTimeout(60_000);
await activateWorld(page);
try {
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');
test('the hook can also create an already-aged job, which is expired by response time', async ({ page }) => {
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 };
// The hook ages the job before its final tick, so it never even appears open in /api/state.
expect(c.status).toBe('expired');
await setScale(page, SCALE);
let expiredBody: StateBody | undefined;
await expect
.poll(async () => {
const body = await getState(page);
if (body.packages.find((p) => p.id === c.id)?.status === 'expired') expiredBody = body;
return !!expiredBody;
}, { timeout: 30_000, message: 'waiting for the hook-created package to expire' })
.toBe(true);
const openCount = expiredBody!.packages.filter((p) => p.status === 'open').length;
expect(openCount).toBeGreaterThanOrEqual(MARKET_TARGET); // top-up held after this expiry too
} finally {
await deactivateWorld(page);
}
let after: StateBody | undefined;
await expect
.poll(async () => {
const body = await getState(page);
if (body.packages.filter((p) => p.status === 'open').length >= POOL_TARGET) after = body;
return !!after;
}, { timeout: 15_000, message: 'waiting for the pool to refill after the hook-created job expired' })
.toBe(true);
expect(after!.packages.filter((p) => p.status === 'open').length).toBeGreaterThanOrEqual(POOL_TARGET); // top-up held after this expiry too
});
test('POST /api/test/set-timescale validates its input', async ({ page }) => {