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
CI / build + unit + e2e (pull_request) Failing after 17m26s
This commit is contained in:
@@ -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
@@ -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
@@ -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
@@ -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 }) => {
|
||||
|
||||
Reference in New Issue
Block a user