idle fleet slows world clock to real time instead of freezing (ISS-128)
CI / build + unit + e2e (pull_request) Successful in 10m32s

This commit is contained in:
2026-09-22 18:37:06 +00:00
parent 5268a39fe9
commit 0a40143497
12 changed files with 142 additions and 72 deletions
+7 -6
View File
@@ -98,9 +98,10 @@ async function setHealth(page: Page, planeId: string, fields: Record<string, unk
expect(res.status()).toBe(200);
}
// Idle-pause (ISS-117) freezes a fully grounded fleet — put the starter in repair so the clock
// advances for tests that need it. The €1.5k repair cost is why the expiry test captures its
// cash/rep baseline AFTER deactivateWorld; deactivateWorld restores full health and no gate afterwards.
// A fully grounded fleet only advances at the idle real-time rate (ISS-128) — put the starter in
// repair so the clock runs at FULL scale for tests that need fast-forwarding. The €1.5k repair cost
// is why the expiry test captures its cash/rep baseline AFTER deactivateWorld; deactivateWorld
// restores full health and no gate afterwards.
async function activateWorld(page: Page): Promise<void> {
await setHealth(page, 'plane-starter', { health: 50 });
expect((await page.request.post('/api/planes/plane-starter/repair')).status()).toBe(200);
@@ -343,9 +344,9 @@ test('expired packages deliver and abandon with no reward and no penalty', async
expect(res.ok()).toBeTruthy();
}
// Idle-pause (ISS-117): a fully grounded fleet freezes the clock, so expirePackages never runs —
// put the starter in repair for the expiry window only. The €1.5k repair cost is why the cash/rep
// baseline below is captured AFTER deactivateWorld, not before it.
// A fully grounded fleet only advances at the idle real-time rate (ISS-128) — put the starter in
// repair for the expiry window so scale 1000 can fast-forward past both deadlines quickly. The €1.5k
// repair cost is why the cash/rep baseline below is captured AFTER deactivateWorld, not before it.
await activateWorld(page);
try {
// Fast-forward past both deadlines — the tick flips loaded → expired (slot kept).
+4 -4
View File
@@ -39,9 +39,9 @@ async function setHealth(page: Page, planeId: string, fields: Record<string, unk
expect(res.status()).toBe(200);
}
// Idle-pause (ISS-117) freezes a fully grounded fleet — put the starter in repair so the clock
// advances for tests that need it. The €1.5k repair cost is irrelevant to every spec's relative
// cash assertions; deactivateWorld restores full health and no gate afterwards.
// A fully grounded fleet only advances at the idle real-time rate (ISS-128) — put the starter in
// repair so the clock runs at FULL scale for tests that need fast-forwarding. The €1.5k repair cost
// is irrelevant to every spec's relative cash assertions; deactivateWorld restores full health and no gate afterwards.
async function activateWorld(page: Page): Promise<void> {
await setHealth(page, STARTER, { health: 50 });
expect((await page.request.post(`/api/planes/${STARTER}/repair`)).status()).toBe(200);
@@ -151,7 +151,7 @@ test('sim clock ticks smoothly between polls at the fixed world rate (ISS-007, I
// 409s on non-available planes — it must be grounded before activateWorld.
await flyHomeViaApi(page);
await setScale(page, WORLD); // self-heal may have left the fast test rate behind
// Idle-pause freezes a grounded fleet — put the starter in repair so the clock advances.
// A grounded fleet only advances at the idle real-time rate (ISS-128) — put the starter in repair for full scale.
await activateWorld(page);
try {
await page.goto('/');
+18 -9
View File
@@ -272,19 +272,28 @@ test('on-board list: fly-to-destination + abandon with penalty preview; flying p
const onboardRow = page.getByTestId(`onboard-package-${pkg.id}`);
await expect(onboardRow, 'loaded package appears in the on-board list').toBeVisible({ timeout: 15_000 });
// The abandon button previews the exact penalty at the snapshot's clock (same formula as the server).
// The abandon button previews the penalty at its snapshot's clock (same formula as the server).
// The fleet is grounded here (the starter just loaded; workers:1 and every spec's cleanup leave no
// other planes), so idle-pause (ISS-117) freezes the clock — verify it actually did, so every
// snapshot (mine and the UI's last poll) shares one clock value instead of racing the 1 s poll loop.
const frozenClock = (await getState(page)).simState.clockMs;
// other planes), so the world runs at the idle real-time rate (ISS-128) — verify it actually did,
// then bound the UI's staleness instead of racing a frozen clock.
const c0 = (await getState(page)).simState.clockMs;
const t0 = Date.now();
await new Promise((r) => setTimeout(r, 1_500)); // > one background-tick interval
expect(
(await getState(page)).simState.clockMs,
'world should be frozen while the fleet is idle (ISS-117)',
).toBe(frozenClock);
const state = await getState(page);
const idleDelta = state.simState.clockMs - c0;
expect(idleDelta, 'world should advance at the idle real-time rate while grounded (ISS-128)').toBeGreaterThan(0);
expect(idleDelta).toBeLessThan((Date.now() - t0) * WORLD); // clearly below the full world rate
// The UI renders the penalty from its last snapshot — up to one poll interval (~1 s, useGameState)
// older than `state`, i.e. ≤ ~2 game-seconds of clock drift at the idle real-time rate (ISS-128).
// Allow the formula's slope over that window instead of an exact string match.
const DRIFT_MS = 3_000; // a couple of poll intervals at the idle rate (= 1), margin for slow CI boxes
const span = pkg.deadlineSimTime - pkg.createdSimTime;
const penaltyText = ((await page.getByTestId(`abandon-penalty-${pkg.id}`).textContent()) ?? '').replace(/[^0-9.]/g, '');
expect(Number.isFinite(Number(penaltyText)), `penalty text: ${penaltyText}`).toBe(true);
const expectedPenalty = penaltyFor(pkg.reward, pkg.createdSimTime, pkg.deadlineSimTime, state.simState.clockMs);
await expect(page.getByTestId(`abandon-penalty-${pkg.id}`)).toContainText(eur(expectedPenalty));
const slopePerGameMs = (pkg.reward * 0.45) / span; // abandonPenaltyPct's SPAN across the package lifetime
expect(Math.abs(Number(penaltyText) - expectedPenalty)).toBeLessThanOrEqual(slopePerGameMs * DRIFT_MS + 0.01);
// Fly to the destination through the UI — the button is the destination's name.
const flyBtn = page.getByTestId(`fly-to-${pkg.id}`);
+26 -12
View File
@@ -1,13 +1,15 @@
import { test, expect, type Page } from '@playwright/test';
// v0.52.0 (ISS-117) fixed world clock against the live origin: idle-pause freezing a grounded
// fleet, background-tick advance at a raised rate while something is active, package expiry via
// the FLIGHT_TEST_HOOKS hooks (enabled in docker-compose.yml), and market top-up.
// v0.52.0 (ISS-117) fixed world clock against the live origin: a fully grounded fleet slowing to
// real time instead of freezing (ISS-128), background-tick advance at a raised rate while
// something is active, package expiry via the FLIGHT_TEST_HOOKS hooks (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 = 1000; // test rate for fast-forwarding legs
const WORLD = 24; // the fixed world rate (ISS-117) — must match lib/worldClock.ts
const IDLE = 1; // the idle real-time rate (ISS-128) — must match IDLE_WORLD_TIME_SCALE in lib/worldClock.ts (= WORLD / 24)
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;
@@ -46,9 +48,10 @@ async function setHealth(page: Page, planeId: string, fields: Record<string, unk
expect(res.status()).toBe(200);
}
// Idle-pause (ISS-117) freezes a fully grounded fleet — put the starter in repair so the clock
// advances for tests that need it. The €1.5k repair cost is irrelevant to every spec's relative
// cash assertions; deactivateWorld restores full health and no gate afterwards.
// A fully grounded fleet only advances at the idle real-time rate (ISS-128) — put the starter in
// repair so the clock runs at FULL scale for tests that need fast-forwarding. The €1.5k repair
// cost is irrelevant to every spec's relative cash assertions; deactivateWorld restores full health
// and no gate afterwards.
async function activateWorld(page: Page): Promise<void> {
await setHealth(page, STARTER, { health: 50 });
expect((await page.request.post(`/api/planes/${STARTER}/repair`)).status()).toBe(200);
@@ -70,14 +73,25 @@ test.afterEach(async ({ page }) => {
await setScale(page, WORLD);
});
test('a fully grounded fleet freezes the clock exactly across polls (idle-pause)', async ({ page }) => {
test('a fully grounded fleet slows the clock to real time instead of freezing it (ISS-128)', async ({ page }) => {
const s = await getState(page);
expect(s.planes.every((p) => p.status === 'available'), 'fleet should be grounded at spec start').toBe(true);
const frozen = s.simState.clockMs;
for (let i = 0; i < 3; i++) {
await sleep(500);
expect((await getState(page)).simState.clockMs, 'clock must not advance while the fleet is idle').toBe(frozen);
}
const t0 = Date.now();
const c0 = s.simState.clockMs;
await sleep(2_500); // ~2.5 s of real idleness — several background ticks plus the reads below
const after = await getState(page);
const elapsedReal = Date.now() - t0;
const delta = after.simState.clockMs - c0;
expect(delta, 'the idle clock must advance (no longer frozen)').toBeGreaterThan(0);
// ≈ real time: each tick counts min(gap, MAX_TICK_ELAPSED_MS) × IDLE rate, so the total can only fall
// short of the wall span when a tick gap was clamped — worst case is one clamped idle-rate tick.
expect(delta).toBeGreaterThanOrEqual(Math.min(elapsedReal * IDLE * 0.5, MAX_TICK_ELAPSED_MS * IDLE));
// …and never more than the wall span itself (plus one clamp of pre-read downtime for slow boxes)…
expect(delta).toBeLessThanOrEqual(elapsedReal + MAX_TICK_ELAPSED_MS);
// …which is clearly below the full world rate: at ×24 this window would be ≥ ~60 game seconds.
expect(delta).toBeLessThan(elapsedReal * WORLD);
});
test('timeScale 1000 advances the clock by roughly real-time × 1000 between polls', async ({ page }) => {
+17 -6
View File
@@ -8,6 +8,9 @@ import { test, expect } from '@playwright/test';
const repoRoot = path.join(__dirname, '..');
const composeArgs = process.env.E2E_COMPOSE_ARGS ?? '';
// Must match lib/sim/engine.ts — kept in sync manually (same pattern as e2e/sim.spec.ts).
const MAX_TICK_ELAPSED_MS = 2_000;
interface StateBody {
account: { cash: number; reputation: number };
simState: { clockMs: number }; // ISS-117: the snapshot carries only the game clock
@@ -42,8 +45,9 @@ test('GET /api/state returns a seeded snapshot', async ({ page }) => {
test('state survives a container restart (sim clock + account persisted)', async ({ page }) => {
test.setTimeout(180_000);
// Self-heal to a fully grounded fleet first — idle-pause (ISS-117) then freezes the clock, so the
// post-restart snapshot must match byte-for-byte instead of within one tick bound.
// Self-heal to a fully grounded fleet first — while idle the clock advances at real time
// (ISS-128), so the post-restart snapshot is bounded by one clamped tick of downtime plus live
// ticks, not byte-for-byte identical.
await expect
.poll(async () => {
const res = await page.request.get('/api/state');
@@ -56,6 +60,7 @@ test('state survives a container restart (sim clock + account persisted)', async
const resBefore = await page.request.get('/api/state');
expect(resBefore.status()).toBe(200);
const before = (await resBefore.json()) as StateBody;
const tBefore = Date.now();
execSync(`docker compose ${composeArgs} restart flight`, { cwd: repoRoot, stdio: 'pipe' });
@@ -75,11 +80,17 @@ test('state survives a container restart (sim clock + account persisted)', async
expect(after).toBeDefined();
const a = after!;
const tAfter = Date.now();
// The DB file on the volume must be loaded, not re-seeded (PLAN.md: "sim clock
// persisted across sessions"): account and entity ids are byte-identical. With the fleet
// grounded above, idle-pause keeps the clock frozen across the restart — exact equality.
expect(a.simState.clockMs).toBe(before.simState.clockMs);
// The DB file on the volume must be loaded, not re-seeded (PLAN.md: "sim clock persisted across
// sessions"): account and entity ids are byte-identical. With the fleet grounded above, ISS-128
// advances the clock at real time while idle — bounded tolerance instead of exact equality: no
// ticks run while the container is down, so the first post-restart tick counts the downtime
// clamped to MAX_TICK_ELAPSED_MS × IDLE rate (= 1), and every other counted gap lies inside the
// live wall span [tBefore, tAfter] at that same rate. A re-seed would reset clockMs backwards.
const delta = a.simState.clockMs - before.simState.clockMs;
expect(delta).toBeGreaterThanOrEqual(0); // never goes backwards — not re-seeded
expect(delta).toBeLessThanOrEqual(MAX_TICK_ELAPSED_MS + (tAfter - tBefore));
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());