Fixed world clock: no player time controls (ISS-117)
CI / build + unit + e2e (pull_request) Failing after 12m10s
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:
+73
-72
@@ -1,20 +1,14 @@
|
||||
import { test, expect, type Page } from '@playwright/test';
|
||||
|
||||
// v0.9.0 — single-screen 3-pane shell + left Assets pane against the live origin.
|
||||
// The panes render from the shell's single /api/state poll (lib/useGameState.ts); time
|
||||
// controls POST to /api/sim and refetch immediately, so UI assertions settle within a tick.
|
||||
// The panes render from the shell's single /api/state poll (lib/useGameState.ts).
|
||||
|
||||
const SCALE = 1000; // fastest sim speed — top of the v0.14.0 ladder (same convention as e2e/sim.spec.ts)
|
||||
|
||||
interface SimState {
|
||||
clockMs: number;
|
||||
timeScale: number;
|
||||
paused: boolean;
|
||||
}
|
||||
const WORLD = 24; // the fixed world rate (ISS-117) — must match lib/worldClock.ts
|
||||
const STARTER = 'plane-starter';
|
||||
|
||||
interface StateBody {
|
||||
account: { cash: number; reputation: number };
|
||||
simState: SimState;
|
||||
simState: { clockMs: number }; // ISS-117: the snapshot carries only the game clock
|
||||
planes: Array<Record<string, unknown>>;
|
||||
packages: Array<Record<string, unknown>>;
|
||||
}
|
||||
@@ -25,11 +19,42 @@ async function getState(page: Page): Promise<StateBody> {
|
||||
return (await res.json()) as StateBody;
|
||||
}
|
||||
|
||||
// Leave the live instance in a sane state for the other specs (scale 1, running) — same
|
||||
// Test hook (FLIGHT_TEST_HOOKS=1): set the world rate and VERIFY from the response that it stuck.
|
||||
async function setScale(page: Page, scale: number): Promise<void> {
|
||||
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/test/set-timescale`).toBe(scale);
|
||||
}
|
||||
|
||||
async function setHealth(page: Page, planeId: string, fields: Record<string, unknown>): Promise<void> {
|
||||
const res = await page.request.post('/api/test/set-health', { data: { planeId, ...fields } });
|
||||
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.
|
||||
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);
|
||||
}
|
||||
|
||||
// Shorten the repair gate to "now" — the next advancing tick flips the plane back to available.
|
||||
async function deactivateWorld(page: Page): Promise<void> {
|
||||
await setHealth(page, STARTER, { health: 100, repairUntilMs: 0 });
|
||||
await expect
|
||||
.poll(async () => ((await getState(page)).planes.find((p) => p.id === STARTER) as { status: string } | undefined)?.status ?? '', {
|
||||
timeout: 30_000,
|
||||
message: 'waiting for the starter to flip back to available',
|
||||
})
|
||||
.toBe('available');
|
||||
}
|
||||
|
||||
// Leave the live instance in a sane state for the other specs (the fixed world rate) — same
|
||||
// pattern as e2e/sim.spec.ts.
|
||||
test.afterEach(async ({ page }) => {
|
||||
const res = await page.request.post('/api/sim', { data: { timeScale: 1, paused: false } });
|
||||
expect(res.status()).toBe(200);
|
||||
await setScale(page, WORLD);
|
||||
});
|
||||
|
||||
test('left pane renders wallet, reputation and the starter fleet at Frankfurt', async ({ page }) => {
|
||||
@@ -79,71 +104,47 @@ test('left pane renders wallet, reputation and the starter fleet at Frankfurt',
|
||||
// Refuel heading or row anymore.
|
||||
await expect(page.getByTestId('left-pane').getByRole('heading', { name: 'Refuel', exact: true })).toHaveCount(0);
|
||||
await expect(page.getByTestId('right-pane').getByRole('heading', { name: 'Refuel', exact: true })).toHaveCount(0);
|
||||
|
||||
// ISS-117 (v0.52.0): the time controls are gone — no pause button or scale slider in the left pane.
|
||||
await expect(page.getByTestId('pause-toggle')).toHaveCount(0);
|
||||
await expect(page.getByTestId('time-scale-slider')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('sim clock ticks smoothly between polls — seconds advance without waiting for a snapshot (ISS-007)', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
const clock = page.getByTestId('sim-clock');
|
||||
await expect(clock).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
// Fastest scale so game seconds move visibly inside the sampling window.
|
||||
const post = await page.request.post('/api/sim', { data: { timeScale: SCALE, paused: false } });
|
||||
expect(post.status()).toBe(200);
|
||||
|
||||
// "YYYY-MM-DD HH:MM:SS UTC" → epoch ms (UTC). While running there is no "(paused)" suffix.
|
||||
const sample = async () => {
|
||||
const text = ((await clock.textContent()) ?? '').trim();
|
||||
expect(text, `clock format: ${text}`).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} UTC$/);
|
||||
return Date.parse(text.replace(' ', 'T').replace(' UTC', 'Z'));
|
||||
};
|
||||
|
||||
// Three samples ~1.3 s apart real time: at scale 1000 each gap is ≈1300 game seconds, so the
|
||||
// displayed clock must strictly advance between CONSECUTIVE samples — with the old poll-only
|
||||
// rendering a sample pair could straddle zero snapshot updates and show no change at all.
|
||||
const t0 = await sample();
|
||||
await new Promise((r) => setTimeout(r, 1_300));
|
||||
const t1 = await sample();
|
||||
await new Promise((r) => setTimeout(r, 1_300));
|
||||
const t2 = await sample();
|
||||
|
||||
expect(t1).toBeGreaterThan(t0);
|
||||
expect(t2).toBeGreaterThan(t1);
|
||||
// Sanity: ~2.6 s real × 1000 ≈ 2.6 M game ms — generous bounds keep slow CI boxes safe while
|
||||
// still catching a frozen or wildly mis-scaled clock.
|
||||
const total = t2 - t0;
|
||||
expect(total).toBeGreaterThanOrEqual(1_000_000);
|
||||
expect(total).toBeLessThanOrEqual(6_000_000);
|
||||
});
|
||||
|
||||
test('slider commit keeps the chosen scale on screen until confirmed — no snap-back (ISS-001)', async ({ page }) => {
|
||||
test('sim clock ticks smoothly between polls at the fixed world rate (ISS-007, ISS-117)', async ({ page }) => {
|
||||
test.setTimeout(60_000);
|
||||
await page.goto('/');
|
||||
const slider = page.getByTestId('time-scale-slider');
|
||||
await expect(slider).toBeVisible({ timeout: 15_000 });
|
||||
// Idle-pause freezes a grounded fleet — put the starter in repair so the clock advances.
|
||||
await activateWorld(page);
|
||||
try {
|
||||
await page.goto('/');
|
||||
const clock = page.getByTestId('sim-clock');
|
||||
await expect(clock).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
// Commit to max via keyboard (input + change → immediate commitNow POST)…
|
||||
await slider.focus();
|
||||
await page.keyboard.press('End');
|
||||
// "YYYY-MM-DD HH:MM:SS UTC" → epoch ms (UTC).
|
||||
const sample = async () => {
|
||||
const text = ((await clock.textContent()) ?? '').trim();
|
||||
expect(text, `clock format: ${text}`).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} UTC$/);
|
||||
return Date.parse(text.replace(' ', 'T').replace(' UTC', 'Z'));
|
||||
};
|
||||
|
||||
// …and sample the displayed value rapidly across the whole commit→confirm window. With the
|
||||
// ISS-001 fix the optimistic value stays until a snapshot confirms it, so EVERY sample must
|
||||
// be '1000×' — never snapping back to the previous scale for one poll cycle (the old bug).
|
||||
const seen: string[] = [];
|
||||
for (let i = 0; i < 30; i++) {
|
||||
seen.push(((await page.getByTestId('time-scale-value').textContent()) ?? '').trim());
|
||||
await new Promise((r) => setTimeout(r, 25));
|
||||
// Three samples ~1.3 s apart real time: at the fixed 24× world rate each gap is ≈31 game seconds,
|
||||
// so the displayed clock must strictly advance between CONSECUTIVE samples — with poll-only
|
||||
// rendering a sample pair could straddle zero snapshot updates and show no change at all.
|
||||
const t0 = await sample();
|
||||
await new Promise((r) => setTimeout(r, 1_300));
|
||||
const t1 = await sample();
|
||||
await new Promise((r) => setTimeout(r, 1_300));
|
||||
const t2 = await sample();
|
||||
|
||||
expect(t1).toBeGreaterThan(t0);
|
||||
expect(t2).toBeGreaterThan(t1);
|
||||
// Sanity: ~2.6 s real × 24 ≈ 62 game seconds — generous bounds keep slow CI boxes safe while
|
||||
// still catching a frozen or wildly mis-scaled clock.
|
||||
const total = t2 - t0;
|
||||
expect(total).toBeGreaterThanOrEqual(50_000);
|
||||
expect(total).toBeLessThanOrEqual(200_000);
|
||||
} finally {
|
||||
await deactivateWorld(page);
|
||||
}
|
||||
expect(
|
||||
seen.every((v) => v === '1000×'),
|
||||
`displayed scale must stay 1000× during the commit window (saw: ${[...new Set(seen)].join(', ')})`,
|
||||
).toBe(true);
|
||||
|
||||
// …and the server confirms it.
|
||||
await expect.poll(async () => (await getState(page)).simState.timeScale, { timeout: 10_000 }).toBe(1000);
|
||||
|
||||
// Restore to min for later tests (afterEach restores scale anyway).
|
||||
await page.keyboard.press('Home');
|
||||
await expect.poll(async () => (await getState(page)).simState.timeScale, { timeout: 10_000 }).toBe(1);
|
||||
});
|
||||
|
||||
test('right pane placeholder exists with the Jobs & Market heading', async ({ page }) => {
|
||||
|
||||
Reference in New Issue
Block a user