From a0560ec767aad6a863e285831c2e95de47c8a8bd Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Tue, 22 Sep 2026 10:50:21 +0000 Subject: [PATCH] Fixed world clock: no player time controls (ISS-117) 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. --- CHANGELOG.md | 5 + app/api/packages/[id]/abandon/route.test.ts | 2 +- app/api/packages/[id]/load/route.test.ts | 2 +- app/api/planes/[planeId]/fly/route.test.ts | 2 +- app/api/sim/route.test.ts | 116 ---------- app/api/sim/route.ts | 42 ---- app/api/state/route.test.ts | 4 +- app/api/test/set-timescale/route.ts | 47 ++++ app/page.tsx | 4 +- components/LeftPane.tsx | 228 +------------------- components/left-pane.test.ts | 91 -------- components/right-pane.test.ts | 2 +- e2e/actions.spec.ts | 36 ++-- e2e/api.spec.ts | 3 +- e2e/hover.spec.ts | 47 ++-- e2e/maintenance.spec.ts | 20 +- e2e/map-interactions.spec.ts | 44 ++-- e2e/panes.spec.ts | 145 ++++++------- e2e/queue.spec.ts | 33 ++- e2e/relocation.spec.ts | 25 +-- e2e/right-pane.spec.ts | 46 ++-- e2e/sim.spec.ts | 226 ++++++++++--------- e2e/slow-on-landing.spec.ts | 141 ------------ e2e/state.spec.ts | 29 +-- e2e/two-planes.spec.ts | 66 ++---- instrumentation.ts | 9 + lib/db/client.test.ts | 7 +- lib/deriveEvents.test.ts | 4 +- lib/game/actions.test.ts | 44 +--- lib/game/actions.ts | 8 - lib/game/seed.test.ts | 20 +- lib/game/seed.ts | 20 +- lib/game/state.ts | 6 +- lib/mapData.test.ts | 2 +- lib/openapi.test.ts | 29 +-- lib/openapi.ts | 55 +---- lib/sim/engine.test.ts | 214 ++++++------------ lib/sim/engine.ts | 109 +++++----- lib/timeScale.test.ts | 81 ------- lib/timeScale.ts | 33 --- lib/useGameState.test.ts | 2 +- lib/useGameState.ts | 2 +- lib/useSimClock.test.ts | 32 ++- lib/useSimClock.ts | 44 ++-- lib/worldClock.ts | 4 + package.json | 2 +- 46 files changed, 631 insertions(+), 1502 deletions(-) delete mode 100644 app/api/sim/route.test.ts delete mode 100644 app/api/sim/route.ts create mode 100644 app/api/test/set-timescale/route.ts delete mode 100644 components/left-pane.test.ts delete mode 100644 e2e/slow-on-landing.spec.ts create mode 100644 instrumentation.ts delete mode 100644 lib/timeScale.test.ts delete mode 100644 lib/timeScale.ts create mode 100644 lib/worldClock.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 57f1838..26edd3c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## v0.52.0 — 2026-09-22 10:45 +- **Fixed world clock, no player time controls (ISS-117).** The pause button and the 1×–1000× scale slider are gone from the left pane (`components/LeftPane.tsx`) — the world now always runs at a fixed `WORLD_TIME_SCALE = 24` (new client-safe `lib/worldClock.ts`, re-exported by the engine). The server advances time in a background tick: new `instrumentation.ts` starts `startBackgroundTick()` (1 s interval, singleton-guarded) under `NEXT_RUNTIME === 'nodejs'`, so the sim moves even with no open browser. **Idle-pause:** while no plane is flying or in repair the clock is frozen exactly — pure `shouldAdvanceTime(planes)` in `lib/sim/engine.ts` gates every tick (no catch-up jump on wake; `lastRealTickMs` still updates). Fresh worlds start at year 2000 (`STARTING_CLOCK_MS = Date.UTC(2000, 0, 1)` in `lib/game/seed.ts`; existing saves untouched — no migration). The client clock (`lib/useSimClock.ts`) extrapolates between polls at the fixed rate and freezes while idle, mirroring the server. +- **API.** `POST /api/sim` deleted (route + OpenAPI entry; spec now lists twelve endpoints) and its controls removed from the engine (`applyTimeControls`, `clampTimeScale`, MIN/MAX_TIME_SCALE). The `/api/state` snapshot's `simState` is now `{ clockMs }` only — no `timeScale`/`paused`. New test-only hook `POST /api/test/set-timescale` behind the existing `FLIGHT_TEST_HOOKS=1` gate (sibling of `/api/test/set-health`, same 404-when-disabled convention): body `{ timeScale: finite number in [1, 100_000] }` else 400; ticks first under the sim lock and returns `{ clockMs, timeScale }`. The e2e suite's per-spec `setScale` helpers now call it (and verify from the response); every spec restores the world rate in `afterEach`. +- **Tests.** Unit: engine tests re-anchored to `STARTING_CLOCK_MS` with a `makeWorldActive` helper (fresh DBs seed a grounded starter, so advancing-world tests put one in flight first) plus new idle-pause coverage (`shouldAdvanceTime` synthetic arrays; grounded fleet freezes the clock exactly; repairing keeps it advancing); actions/seed/db/openapi tests re-anchored to the year-2000 origin and fixed world rate; `lib/timeScale.ts`, its test, `components/left-pane.test.ts` and the old `/api/sim` route + test deleted. E2E: all specs rewritten off `POST /api/sim` — pause tests replaced by idle-pause assertions (e.g. `right-pane.spec.ts` verifies a double-read 1.5 s apart that a grounded fleet freezes the clock exactly, which also makes its penalty-preview math deterministic); `sim.spec.ts` activates the world via set-health + `/repair` for its advance/expiry tests and pins the new hook's validation; `panes.spec.ts` drops the slider-commit test (controls gone) and samples the smooth client clock at the fixed 24× rate, asserting the pause/slider testids are absent; `state.spec.ts` restart test self-heals to a fully grounded fleet first and now asserts the post-restart clock is byte-identical (frozen across the restart); `two-planes.spec.ts` loses its ISS-057 slow-on-landing assertions — the auto-chain observation leg now flies at world rate so polls can see it; `api.spec.ts` endpoint list drops `/api/sim` (thirteen → twelve). + ## v0.51.0 — 2026-09-22 08:21 - **Max range display is now guaranteed flyable in all weather (ISS-099).** The spec card's "Max range" (`components/RightPane.tsx`) showed `maxRangeKm` — the weighted-model closed form with NO wind — while dispatch's fuel guard (`flyPlane`, and the engine's auto-chain) uses wind-adjusted burn (up to +20% headwind, v0.36.0), so a leg inside the displayed range could 409 "insufficient fuel" when the departure airport had a headwind. New `MAX_WIND_BURN_FACTOR` (= 1.2 — pinned in tests to be exactly `windBurnFactor`'s maximum) and `guaranteedRangeKm(burnPerKm, emptyWeightKg, maxFuel)` = `maxRangeKm` under worst-case headwind burn (`lib/sim/movement.ts`). The UI now shows the guaranteed range: any leg within it is flyable in all weather. ATR 72-600: 4436 → 3696 km; Boeing 777: 13045 → 10871 km — matching the `maxRangeKm ÷ 1.2` convention seed.ts already uses for its job-generation cap - **Tests.** Unit +15: new `guaranteedRangeKm` block in `lib/sim/movement.test.ts` (3 tests — MAX_WIND_BURN_FACTOR pinned to `windBurnFactor(30, 90, 90)`, ATR ≈ 3697 km ±5%, zero-fuel/invalid-weight guards) and a per-type consistency loop in `lib/game/catalog.test.ts` (12 tests — for ALL six catalog types: `requiredFuelFor(guaranteed range)` ≤ maxFuel under worst-case headwind, tightness at 1.05× > maxFuel, and the no-wind closed form still an exact inverse). E2E: `e2e/right-pane.spec.ts` market-tab test now imports and asserts `guaranteedRangeKm` directly (local mirror deleted) diff --git a/app/api/packages/[id]/abandon/route.test.ts b/app/api/packages/[id]/abandon/route.test.ts index b4fb39a..57026d4 100644 --- a/app/api/packages/[id]/abandon/route.test.ts +++ b/app/api/packages/[id]/abandon/route.test.ts @@ -94,7 +94,7 @@ describe('POST /api/packages/:id/abandon', () => { // Response convention (v0.8.0): the FULL snapshot, same shape as GET /api/state. const body = (await res.json()) as { account: { cash: number }; - simState: { clockMs: number; timeScale: number; paused: boolean }; + simState: { clockMs: number }; // ISS-117 packages: Array<{ id: string; status: string; planeId: string | null }>; }; expect(Object.keys(body).sort()).toEqual([ diff --git a/app/api/packages/[id]/load/route.test.ts b/app/api/packages/[id]/load/route.test.ts index f840e80..dc715e2 100644 --- a/app/api/packages/[id]/load/route.test.ts +++ b/app/api/packages/[id]/load/route.test.ts @@ -92,7 +92,7 @@ describe('POST /api/packages/:id/load', () => { // Response convention (v0.8.0): the FULL snapshot, same shape as GET /api/state. const body = (await res.json()) as { account: { cash: number }; - simState: { clockMs: number; timeScale: number; paused: boolean }; + simState: { clockMs: number }; // ISS-117 planes: Array>; packages: Array<{ id: string; status: string; planeId: string | null }>; }; diff --git a/app/api/planes/[planeId]/fly/route.test.ts b/app/api/planes/[planeId]/fly/route.test.ts index 267ca37..bc22a77 100644 --- a/app/api/planes/[planeId]/fly/route.test.ts +++ b/app/api/planes/[planeId]/fly/route.test.ts @@ -84,7 +84,7 @@ describe('POST /api/planes/:planeId/fly', () => { // Response convention (v0.8.0): the FULL snapshot, same shape as GET /api/state. const body = (await res.json()) as { account: { cash: number }; - simState: { clockMs: number; timeScale: number; paused: boolean }; + simState: { clockMs: number }; // ISS-117 planes: Array<{ id: string; status: string; destIcao: string | null; departureSimTime: number | null; locationIcao: string }>; }; expect(Object.keys(body).sort()).toEqual([ diff --git a/app/api/sim/route.test.ts b/app/api/sim/route.test.ts deleted file mode 100644 index c3b1b73..0000000 --- a/app/api/sim/route.test.ts +++ /dev/null @@ -1,116 +0,0 @@ -import fs from 'node:fs/promises'; -import os from 'node:os'; -import path from 'node:path'; -import { afterAll, beforeAll, describe, expect, it } from 'vitest'; -import { getGameDb } from '@/lib/db/client'; -import * as schema from '@/lib/db/schema'; -import { POST } from './route'; - -let dir: string; - -beforeAll(async () => { - // Same pattern as app/api/state/route.test.ts: point the DB singleton at a - // throwaway file (fresh module registry per test file → fresh singleton). - dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-sim-api-')); - process.env.FLIGHT_DB_PATH = path.join(dir, 'sim.db'); -}); - -afterAll(async () => { - delete process.env.FLIGHT_DB_PATH; - await fs.rm(dir, { recursive: true, force: true }); -}); - -const post = (body: unknown) => POST(new Request('http://localhost/api/sim', { method: 'POST', body: JSON.stringify(body) })); -const postRaw = (text: string) => POST(new Request('http://localhost/api/sim', { method: 'POST', body: text })); - -describe('POST /api/sim', () => { - it('returns the current simState for an empty body (tick + no-op)', async () => { - const res = await post({}); - expect(res.status).toBe(200); - const body = (await res.json()) as { clockMs: number; timeScale: number; paused: boolean }; - expect(typeof body.clockMs).toBe('number'); - expect(body.timeScale).toBe(1); // seeded default - expect(body.paused).toBe(false); - }); - - it('sets and clamps timeScale into [1, 1000]', async () => { - const hi = await post({ timeScale: 2000 }); - expect(hi.status).toBe(200); - expect(((await hi.json()) as { timeScale: number }).timeScale).toBe(1000); - - const lo = await post({ timeScale: 0.1 }); - expect(lo.status).toBe(200); - expect(((await lo.json()) as { timeScale: number }).timeScale).toBe(1); - - // The top of the ladder passes through unclamped (v0.14.0). - const max = await post({ timeScale: 1000 }); - expect(max.status).toBe(200); - expect(((await max.json()) as { timeScale: number }).timeScale).toBe(1000); - - // In-range values pass through unclamped — ladder and off-ladder alike. - const ladder = await post({ timeScale: 500 }); - expect(ladder.status).toBe(200); - expect(((await ladder.json()) as { timeScale: number }).timeScale).toBe(500); - - const mid = await post({ timeScale: 37 }); - expect(mid.status).toBe(200); - expect(((await mid.json()) as { timeScale: number }).timeScale).toBe(37); - - // Restore the default for later tests. - await post({ timeScale: 1 }); - }); - - it('toggles paused and freezes the clock across ticks', async () => { - const pause = await post({ paused: true }); - expect(pause.status).toBe(200); - expect(((await pause.json()) as { paused: boolean }).paused).toBe(true); - - // Two reads while paused → identical clock (tick-on-read advances nothing). - const a = await post({}); - const b = await post({}); - expect(((await a.json()) as { clockMs: number }).clockMs).toBe( - ((await b.json()) as { clockMs: number }).clockMs, - ); - - const resume = await post({ paused: false }); - expect(resume.status).toBe(200); - expect(((await resume.json()) as { paused: boolean }).paused).toBe(false); - }); - - it('applies both fields in one call', async () => { - const res = await post({ timeScale: 64, paused: true }); - expect(res.status).toBe(200); - const body = (await res.json()) as { timeScale: number; paused: boolean }; - expect(body.timeScale).toBe(64); - expect(body.paused).toBe(true); - await post({ timeScale: 1, paused: false }); // restore - }); - - it('records lastSelectedTimeScale when timeScale is posted; a paused-only post leaves it untouched (ISS-057)', async () => { - const inst = await getGameDb(); // same singleton the route's applyTimeControls writes through - const row = async () => (await inst.db.select().from(schema.simState))[0]; - - await post({ timeScale: 250 }); - expect((await row()).lastSelectedTimeScale).toBe(250); // manual intent recorded… - - await post({ paused: true }); - expect((await row()).paused).toBe(true); - expect((await row()).lastSelectedTimeScale).toBe(250); // …and a {paused}-only post does not touch it - - await post({ timeScale: 1, paused: false }); // restore - expect((await row()).lastSelectedTimeScale).toBe(1); - }); - - it('rejects invalid input with 400', async () => { - for (const body of [{ timeScale: 'fast' }, { timeScale: Infinity }, { paused: 'yes' }, { bogus: 1 }]) { - const res = await post(body); - expect(res.status).toBe(400); - expect(((await res.json()) as { error: string }).error).toBeTruthy(); - } - - for (const text of ['nope{', '[1,2]', '"str"', 'null']) { - const res = await postRaw(text); - expect(res.status).toBe(400); - } - }); -}); diff --git a/app/api/sim/route.ts b/app/api/sim/route.ts deleted file mode 100644 index ea06c8b..0000000 --- a/app/api/sim/route.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { applyTimeControls } from '@/lib/sim/engine'; - -// POST /api/sim — set sim time controls (v0.7.0). Body: { timeScale?: 1–1000, paused?: boolean }. -// Ticks the clock first (so the response reflects current game time), then applies -// and persists the new controls. Returns the updated simState. - -export async function POST(req: Request) { - let body: unknown; - try { - const text = await req.text(); - body = text ? JSON.parse(text) : {}; - } catch { - return Response.json({ error: 'invalid JSON body' }, { status: 400 }); - } - if (typeof body !== 'object' || body === null || Array.isArray(body)) { - return Response.json({ error: 'body must be a JSON object' }, { status: 400 }); - } - - const input = body as Record; - for (const key of Object.keys(input)) { - if (key !== 'timeScale' && key !== 'paused') { - return Response.json({ error: `unknown field "${key}"` }, { status: 400 }); - } - } - if (input.timeScale !== undefined && (typeof input.timeScale !== 'number' || !Number.isFinite(input.timeScale))) { - return Response.json({ error: 'timeScale must be a finite number (1–1000)' }, { status: 400 }); - } - if (input.paused !== undefined && typeof input.paused !== 'boolean') { - return Response.json({ error: 'paused must be a boolean' }, { status: 400 }); - } - - try { - const sim = await applyTimeControls({ - timeScale: input.timeScale as number | undefined, - paused: input.paused as boolean | undefined, - }); - return Response.json({ clockMs: sim.clockMs, timeScale: sim.timeScale, paused: sim.paused }); - } catch (err) { - console.error('failed to apply sim controls', err); - return Response.json({ error: 'failed to apply sim controls' }, { status: 500 }); - } -} diff --git a/app/api/state/route.test.ts b/app/api/state/route.test.ts index 3711c70..82ff544 100644 --- a/app/api/state/route.test.ts +++ b/app/api/state/route.test.ts @@ -43,10 +43,8 @@ describe('GET /api/state', () => { expect(typeof account.cash).toBe('number'); expect(typeof account.reputation).toBe('number'); - const sim = body.simState as { clockMs: number; timeScale: number; paused: boolean }; + const sim = body.simState as { clockMs: number }; // ISS-117: time controls are gone — clock only expect(typeof sim.clockMs).toBe('number'); - expect(typeof sim.timeScale).toBe('number'); - expect(sim.paused).toBe(false); const planes = body.planes as Array>; expect(planes.length).toBeGreaterThanOrEqual(1); diff --git a/app/api/test/set-timescale/route.ts b/app/api/test/set-timescale/route.ts new file mode 100644 index 0000000..f242869 --- /dev/null +++ b/app/api/test/set-timescale/route.ts @@ -0,0 +1,47 @@ +import { setTestTimeScale } from '@/lib/sim/engine'; + +// TEST-ONLY hook (v0.52.0, ISS-117) — lets the e2e suite override the effective time scale now +// that there are no player-facing controls; without it every spec would run at the fixed world +// rate and a cross-Europe leg would take hours of real time. Disabled unless FLIGHT_TEST_HOOKS=1 +// (set in docker-compose.yml for the e2e instance only); every other deployment 404s here. +// Sibling of /api/test/set-health — same gate, same conventions. +// +// POST /api/test/set-timescale +// body { timeScale: number } — a finite number in [1, 100_000] (400 otherwise). +// Ticks first so the write lands on current game state, under the sim lock. +// Returns { clockMs, timeScale } — the new effective scale for test assertions. + +export async function POST(req: Request) { + if (process.env.FLIGHT_TEST_HOOKS !== '1') { + return Response.json({ error: 'test hooks disabled' }, { status: 404 }); + } + + let body: unknown = {}; + try { + const text = await req.text(); + body = text ? JSON.parse(text) : {}; + } catch { + return Response.json({ error: 'invalid JSON body' }, { status: 400 }); + } + if (typeof body !== 'object' || body === null || Array.isArray(body)) { + return Response.json({ error: 'body must be a JSON object' }, { status: 400 }); + } + const input = body as Record; + for (const key of Object.keys(input)) { + if (key !== 'timeScale') { + return Response.json({ error: `unknown field "${key}"` }, { status: 400 }); + } + } + const timeScale = input.timeScale; + if (typeof timeScale !== 'number' || !Number.isFinite(timeScale) || timeScale < 1 || timeScale > 100_000) { + return Response.json({ error: 'timeScale must be a finite number in [1, 100000]' }, { status: 400 }); + } + + try { + const row = await setTestTimeScale(timeScale); + return Response.json({ clockMs: row.clockMs, timeScale: row.timeScale }); + } catch (err) { + console.error('test hook failed', err); + return Response.json({ error: 'test hook failed' }, { status: 500 }); + } +} diff --git a/app/page.tsx b/app/page.tsx index 261d675..81b8bb3 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -16,7 +16,7 @@ export default function Home() { // Single polling source for the whole shell (v0.9.0): one /api/state loop, snapshot passed // down as props so later panes can share it without extra poll loops. applySnapshot (v0.10.0) // lets a pane push an action endpoint's full response snapshot in immediately. - const { state, loading, error, refresh, applySnapshot } = useGameState(1000); + const { state, loading, error, applySnapshot } = useGameState(1000); // ISS-002 (v0.15.0): the side panels' hover target — shared here so hovering a row in // either pane highlights the matching plane/route/airports on the map. @@ -45,7 +45,7 @@ export default function Home() {
{/* ISS-021 (v0.20.0) — applySnapshot also reaches the left pane: its On board section's fly/abandon actions push their response snapshot into the shared state */} - + {/* v0.11.0 — the same polled snapshot drives the map's plane markers + route lines; ISS-010 (v0.18.0) — applySnapshot lets the airport popup's Relocate row push its action response into the shared state, exactly like RightPane does */} diff --git a/components/LeftPane.tsx b/components/LeftPane.tsx index 8716647..068ceeb 100644 --- a/components/LeftPane.tsx +++ b/components/LeftPane.tsx @@ -1,12 +1,11 @@ 'use client'; -import { useCallback, useEffect, useRef, useState, type ChangeEvent } from 'react'; +import { useEffect, useRef, useState } from 'react'; import type { GeoJsonFeatureCollection } from '@/lib/airports'; import type { PlaneState, StateSnapshot } from '@/lib/game/state'; import { PLANE_TYPES } from '@/lib/game/catalog'; import { abandonPenalty } from '@/lib/game/penalty'; import { formatCash, formatDeadlineRelative, formatFuel, formatKm, formatSimClock, formatSpecialLabel } from '@/lib/format'; -import { TIME_SCALES, scaleToSlider, sliderToScale } from '@/lib/timeScale'; import { useGameAction } from '@/lib/useGameAction'; import { useSimClock } from '@/lib/useSimClock'; import { cargoWeightFor } from './RightPane'; @@ -16,8 +15,6 @@ interface LeftPaneProps { state: StateSnapshot | null; loading: boolean; error: string | null; - // The shell's single-poll refresh — called after a sim-control POST so the pane updates immediately. - onRefresh: () => void; // ISS-021 (v0.20.0): push the full snapshot returned by an action endpoint into the shared shell // state — needed now that the per-plane cargo rows' fly/abandon actions live in this pane. applySnapshot: (snapshot: StateSnapshot) => void; @@ -27,12 +24,6 @@ interface LeftPaneProps { onError?: (message: string) => void; } -// Discrete ladder slider (v0.9.2, values reworked in v0.14.0): position t ∈ [0,1] → nearest -// step of the ladder 1×/2×/5×/10×/25×/50×/100×/250×/500×/1000× — each of the 10 positions -// roughly doubles the speed. -const SCALE_SLIDER_STEP = 1 / (TIME_SCALES.length - 1); // one ladder step per keyboard-arrow step -const SCALE_COMMIT_DEBOUNCE_MS = 250; - // Flying planes show their current leg from the snapshot's dest fields (v0.12.0). function planeLocation(p: PlaneState): string { if (p.status === 'flying' && p.destName) return `${p.locationName} → ${p.destName}`; @@ -47,51 +38,7 @@ function loadoutFor(state: StateSnapshot, p: PlaneState): string { return `${(used / 1000).toFixed(1)}t / ${(maxPayloadKg / 1000).toFixed(1)}t`; } -// ISS-001 — an optimistic slider value is cleared exactly when a rendered snapshot CONFIRMS it -// (server scale === pending), never earlier: clearing at commit time would snap the pointer back -// to the stale scale for one poll cycle, then jump forward again on the confirming snapshot. -export function pendingScaleConfirmed(pendingScale: number | null, serverTimeScale: number): boolean { - return pendingScale !== null && serverTimeScale === pendingScale; -} - -// ISS-008 fix (v0.17.0, CI runs #44/#47): bounded retry for POST /api/sim — a commit dropped by -// the runner's flaky uplink used to leave the UI optimistic while the server kept the old scale. -// Up to maxAttempts tries with delayMs between them; stops early once superseded() flips (a newer -// intent arrived and the caller's loop sends it instead). post is idempotent set-semantics, so -// re-sending a body whose response was lost cannot double-apply. Pure core — unit-tested in node. -export const SIM_POST_MAX_ATTEMPTS = 3; -export const SIM_POST_RETRY_DELAY_MS = 500; - -export type SimPostRetryResult = - | { ok: true } - | { ok: false; reason: 'exhausted'; error: unknown } - | { ok: false; reason: 'superseded' }; - -export async function retrySimPost( - post: () => Promise, - superseded: () => boolean, - sleep: (ms: number) => Promise = (ms) => new Promise((r) => setTimeout(r, ms)), - maxAttempts: number = SIM_POST_MAX_ATTEMPTS, - delayMs: number = SIM_POST_RETRY_DELAY_MS, -): Promise { - let lastError: unknown; - for (let attempt = 1; attempt <= maxAttempts; attempt++) { - if (superseded()) return { ok: false, reason: 'superseded' }; // a newer intent takes over — don't even send this body - try { - await post(); - return { ok: true }; - } catch (err) { - lastError = err; - if (superseded()) return { ok: false, reason: 'superseded' }; // a newer intent arrived during the attempt — don't wait out the backoff - if (attempt < maxAttempts) await sleep(delayMs); - } - } - return { ok: false, reason: 'exhausted', error: lastError }; -} - -export default function LeftPane({ state, loading, error, onRefresh, applySnapshot, onHover, onError }: LeftPaneProps) { - const [busy, setBusy] = useState(false); - +export default function LeftPane({ state, loading, error, applySnapshot, onHover, onError }: LeftPaneProps) { // ISS-021 (v0.20.0): shared action runner for the per-plane cargo/queue actions (moved here from RightPane). const { runAction, busy: actionsBusy, actionError } = useGameAction(state, applySnapshot, onError); @@ -123,73 +70,6 @@ export default function LeftPane({ state, loading, error, onRefresh, applySnapsh void airportsPromiseRef.current.then(setAirportOptions).catch(() => {}); }, []); - // POST /api/sim (retried up to SIM_POST_MAX_ATTEMPTS× on failure — CI runs #44/#47), then - // refetch the full snapshot immediately so the pane reflects the new controls without waiting - // for the next poll tick. After the final attempt fails keep the stale UI and surface an error - // toast (the running poll retries on its own). A commit that lands while another POST is in - // flight — including mid-retry — is held in a single slot (last intent wins) and supersedes - // the in-flight body, so every user intent reaches the server (the [1,1000] clamp stays as - // backstop). The gate is a REF, not React state: closures never see a stale "busy" value, - // which used to let a flush re-queue instead of send and silently drop a commit. - const queuedRef = useRef<{ timeScale?: number; paused?: boolean } | null>(null); - const busyRef = useRef(false); - - type SimControlsOutcome = 'sent' | 'queued' | 'failed'; - - // 'sent': this body reached the server (a confirming snapshot follows via onRefresh). - // 'queued': an in-flight call holds it — its flush will send it. - // 'failed': the POST for this body never succeeded; callers drop optimistic values only then. - const applySimControls = useCallback( - async (body: { timeScale?: number; paused?: boolean }): Promise => { - if (busyRef.current) { - queuedRef.current = body; // last intent wins — the in-flight call's flush sends it - return 'queued'; - } - busyRef.current = true; - setBusy(true); - let sentOwn = false; - try { - let current: { timeScale?: number; paused?: boolean } | null = body; - while (current !== null) { - const target = current; // stable across this retry round - const result = await retrySimPost( - async () => { - const res = await fetch('/api/sim', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(target), - cache: 'no-store', - }); - if (!res.ok) throw new Error(`POST /api/sim → HTTP ${res.status}`); - }, - () => queuedRef.current !== null, // a newer intent supersedes the in-flight body mid-retry - ); - if (result.ok) { - onRefresh(); - if (target === body) sentOwn = true; // object identity — queued bodies are fresh literals - } else if (result.reason === 'exhausted') { - throw result.error; // only after the final attempt fails → error toast below - } - current = queuedRef.current; // pick up anything that arrived while we were in flight - queuedRef.current = null; - } - } catch (err) { - console.error('failed to apply sim controls', err); - // ISS-008: surface the failure as an error toast too — previously console-only. - onError?.(`Failed to update time controls (${err instanceof Error ? err.message : String(err)})`); - queuedRef.current = null; // don't replay a stale intent after a failed round-trip - } finally { - busyRef.current = false; - setBusy(false); - } - return sentOwn ? 'sent' : 'failed'; - }, - [onRefresh, onError], - ); - - const paused = state?.simState.paused ?? false; - const timeScale = state?.simState.timeScale ?? 1; - // Snapshot clock — feeds the deadline/penalty previews in the per-plane cargo rows. const clockMs = state?.simState.clockMs ?? 0; @@ -208,69 +88,6 @@ export default function LeftPane({ state, loading, error, onRefresh, applySnapsh relocatePlane?.status === 'flying' && relocateDest !== null && (relocatePlane.destIcao === relocateDest.icao || relocatePlane.queue.includes(relocateDest.icao)); - // Slider commit (v0.9.1): the displayed value follows the drag optimistically via - // pendingScale; POST /api/sim fires after a 250 ms debounce, or immediately when the - // user releases the slider (browsers fire a native 'change' on release). The server's - // [1,1000] clamp stays as the backstop. - const [pendingScale, setPendingScaleState] = useState(null); - const pendingRef = useRef(null); - const commitTimerRef = useRef | null>(null); - - const setPending = useCallback((s: number | null) => { - pendingRef.current = s; - setPendingScaleState(s); - }, []); - - const commitNow = useCallback(() => { - if (commitTimerRef.current !== null) { - clearTimeout(commitTimerRef.current); - commitTimerRef.current = null; - } - const s = pendingRef.current; - if (s === null) return; - // ISS-001: keep the chosen value on screen until a snapshot CONFIRMS it — clearing at - // commit time would snap the slider back to the stale scale for one poll cycle, then jump - // forward again when the confirming snapshot lands. 'queued' also keeps it (the in-flight - // call flushes it); only a genuine failure drops it so the display falls back to truth. - void applySimControls({ timeScale: s }).then((outcome) => { - if (outcome === 'failed') setPending(null); - }); - }, [applySimControls, setPending]); - - // Confirmation: once a rendered snapshot carries the pending scale AND no commit is in - // flight, the optimistic value has been superseded by server truth — clear it (display is - // unchanged either way). The busy guard matters: while a commit POST is still out, an OLD - // snapshot that happens to match the pending value is coincidence, not confirmation (e.g. a - // quick drag back to the previous scale must not be swallowed before its own commit fires). - useEffect(() => { - if (!busy && state && pendingScaleConfirmed(pendingScale, state.simState.timeScale)) setPending(null); - }, [state, pendingScale, busy, setPending]); - - const onSliderInput = useCallback( - (e: ChangeEvent) => { - setPending(sliderToScale(Number(e.target.value))); - if (commitTimerRef.current !== null) clearTimeout(commitTimerRef.current); - commitTimerRef.current = setTimeout(commitNow, SCALE_COMMIT_DEBOUNCE_MS); - }, - [commitNow, setPending], - ); - - const sliderRef = useRef(null); - useEffect(() => { - const el = sliderRef.current; - if (!el) return; - el.addEventListener('change', commitNow); // flush on pointer-up / keyboard release - return () => el.removeEventListener('change', commitNow); - }, [commitNow]); - - // Drop any pending debounce timer on unmount. - useEffect( - () => () => { - if (commitTimerRef.current !== null) clearTimeout(commitTimerRef.current); - }, - [], - ); - // ISS-008: browser-notification opt-in. Starts null (SSR-safe — the real permission is read // in an effect after hydration, so server and client render identically) and follows the // result of Notification.requestPermission(). 'unsupported' = no Notification API at all. @@ -290,13 +107,11 @@ export default function LeftPane({ state, loading, error, onRefresh, applySnapsh ? 'Notifications unavailable' : 'Enable notifications'; - const displayedScale = pendingScale ?? timeScale; - - // ISS-007: the clock ticks client-side between polls, re-syncing to server truth on every - // snapshot. Fed with EFFECTIVE controls so a scale drag / pause click takes effect immediately, - // not when the confirming snapshot lands. Only the sim-clock paragraph uses this — deadline and - // penalty previews stay snapshot-based (deterministic against the server clock). - const simClockMs = useSimClock(state, { timeScale: displayedScale, paused }); + // ISS-007: the clock ticks client-side between polls at the fixed world rate, re-syncing to + // server truth on every snapshot (and freezing while nothing is happening). Only the sim-clock + // paragraph uses this — deadline and penalty previews stay snapshot-based (deterministic against + // the server clock). + const simClockMs = useSimClock(state); return (