Fixed world clock: no player time controls (ISS-117)
CI / build + unit + e2e (pull_request) Failing after 12m10s

The world always runs at WORLD_TIME_SCALE = 24 (lib/worldClock.ts). A server
background tick (instrumentation.ts -> startBackgroundTick, 1 s interval)
advances the sim with no open browser; idle-pause freezes the clock exactly
while no plane is flying or in repair. Fresh worlds start year 2000
(STARTING_CLOCK_MS in seed.ts).

- Removed: POST /api/sim + OpenAPI entry, applyTimeControls/clampTimeScale,
  lib/timeScale.ts, LeftPane pause button + scale slider machinery,
  ISS-057 slow-on-landing/resume in flyPlane.
- Snapshot simState is now { clockMs } only; client extrapolates at the fixed
  rate between polls and freezes while idle (useSimClock).
- New test-only hook POST /api/test/set-timescale behind FLIGHT_TEST_HOOKS=1
  (sibling of set-health); all e2e setScale helpers call it, afterEach restores
  the world rate. E2E pause tests replaced by idle-pause assertions; sim.spec.ts
  activates the world via set-health + /repair for advance/expiry tests.
This commit is contained in:
2026-09-22 10:50:21 +00:00
parent d31bc5d60f
commit a0560ec767
46 changed files with 631 additions and 1502 deletions
+22 -24
View File
@@ -18,7 +18,8 @@ import { STARTING_REPUTATION } from '../lib/game/seed';
// 150% of the leg requirement at exactly its rep-discounted cost (ISS-082). Packages are created via
// POST /api/test/create-package (FLIGHT_TEST_HOOKS=1), same pattern as e2e/actions.spec.ts.
const SCALE = 1000; // fastest sim speed (v0.14.0 ladder top) — keeps flights to seconds of real time
const SCALE = 1000; // test rate for fast-forwarding legs — keeps flights to seconds of real time
const WORLD = 24; // the fixed world rate (ISS-117) — must match lib/worldClock.ts
const HUB = 'EDDF'; // must match HUB_ICAO in lib/game/seed.ts
interface PlaneState {
@@ -57,7 +58,7 @@ interface CatalogEntry {
interface StateBody {
account: { cash: number; reputation: number };
simState: { clockMs: number; timeScale: number; paused: boolean };
simState: { clockMs: number }; // ISS-117: the snapshot carries only the game clock
planes: PlaneState[];
packages: PackageState[];
catalog: CatalogEntry[];
@@ -74,24 +75,18 @@ async function getState(page: Page): Promise<StateBody> {
return (await res.json()) as StateBody;
}
async function postSim(page: Page, body: unknown) {
return page.request.post('/api/sim', { data: body });
}
// Set the time scale and VERIFY from the response that it stuck — a silent reset would
// otherwise surface as an unexplained landing-wait timeout. ISS-041 also clears pause: a
// landing pauses the sim, so any fast-forward after one must resume it or the clock stays frozen.
// Test hook (FLIGHT_TEST_HOOKS=1): set the world rate and VERIFY from the response that it stuck —
// a silent reset would otherwise surface as an unexplained landing-wait timeout.
async function setScale(page: Page, scale: number): Promise<void> {
const res = await page.request.post('/api/sim', { data: { timeScale: scale, paused: false } });
const res = await page.request.post('/api/test/set-timescale', { data: { timeScale: scale } });
expect(res.status()).toBe(200);
const body = (await res.json()) as { timeScale: number };
expect(body.timeScale, `timeScale should be ${scale} after POST /api/sim`).toBe(scale);
expect(body.timeScale, `timeScale should be ${scale} after POST /api/test/set-timescale`).toBe(scale);
}
// Leave the live instance in a sane state for the other specs (scale 1, running).
// Leave the live instance in a sane state for the other specs (the fixed world rate).
test.afterEach(async ({ page }) => {
const res = await postSim(page, { timeScale: 1, paused: false });
expect(res.status()).toBe(200);
await setScale(page, WORLD);
});
// Mirrors haversineKm in lib/geo.ts (same formula → identical floats).
@@ -168,9 +163,8 @@ async function flyHomeViaApi(page: Page): Promise<void> {
.toBe(true);
}
if (starter.locationIcao !== HUB) {
// ISS-041: the landing above paused the sim — setScale clears pause so the return leg
// fast-forwards instead of taking real-time hours. ISS-082: dispatch auto-refuels, so a
// grounded plane can always start any full-tank-flyable leg when cash allows — no top-up retry needed.
// Make sure the world rate is back at SCALE so the return leg fast-forwards. ISS-082: dispatch
// auto-refuels, so a grounded plane can always start any full-tank-flyable leg when cash allows — no top-up retry needed.
await setScale(page, SCALE);
const flyHome = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
expect(flyHome.status()).toBe(200); // repositioning needs no package (v0.12.0 manual flight)
@@ -274,9 +268,15 @@ test('on-board list: fly-to-destination + abandon with penalty preview; flying p
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).
// Freeze the clock first so every snapshot — mine and the UI's last poll — shares one clock value,
// making the preview deterministic instead of racing the 1 s poll loop.
await postSim(page, { paused: true });
// 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;
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 expectedPenalty = penaltyFor(pkg.reward, pkg.createdSimTime, pkg.deadlineSimTime, state.simState.clockMs);
await expect(page.getByTestId(`abandon-penalty-${pkg.id}`)).toContainText(eur(expectedPenalty));
@@ -297,8 +297,7 @@ test('on-board list: fly-to-destination + abandon with penalty preview; flying p
expect(body.planes.find((p) => p.id === 'plane-starter')!.status).toBe('flying');
expect(body.planes.find((p) => p.id === 'plane-starter')!.destIcao).toBe(nearest.icao);
// Resume and fast-forward to arrival (poll the authoritative state).
await postSim(page, { paused: false });
// Fast-forward to arrival (poll the authoritative state) — the takeoff above unfroze the world.
await setScale(page, SCALE);
await expect
.poll(async () => {
@@ -313,8 +312,7 @@ test('on-board list: fly-to-destination + abandon with penalty preview; flying p
expect(body.packages.find((p) => p.id === pkg.id)?.status).toBe('delivered');
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-041: the arrival above paused the sim, so re-set SCALE for this leg (setScale clears pause).
// Restore: fly home at SCALE so the live instance stays in its onboarding state for the next run.
await setScale(page, SCALE);
const homeRes = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
expect(homeRes.status()).toBe(200);