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
+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 }) => {