diff --git a/CHANGELOG.md b/CHANGELOG.md index 9577498..feb31c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## v0.16.1 — 2026-09-19 14:00 +- **Smooth client-side sim clock with per-snapshot re-sync (ISS-007).** The left-pane "Time" display used to jump once per 1 s poll because it rendered the snapshot's `clockMs` verbatim. It now ticks smoothly on the client between polls: new hook `lib/useSimClock.ts` keeps an anchor `{clockMs, at, timeScale, paused}` and re-derives the display clock every ~100 ms as `anchor.clockMs + (now − at) × timeScale` — exactly the server's own tick model (`tickOnce`: real-time × timeScale), so client extrapolation mirrors authoritative game time. Every new snapshot re-anchors to server truth, bounding any drift by network latency only; while paused the display is frozen at the anchor value (advance 0). The pure core `advanceSimClock(anchor, nowMs)` is exported and unit-tested in node +- **LeftPane consumes it for the `sim-clock` paragraph ONLY** (`components/LeftPane.tsx`): fed with EFFECTIVE controls `{timeScale: pendingScale ?? snapshot scale, paused}` so a slider drag or pause click takes effect on click/drag without waiting for a confirming snapshot; the `(paused)` suffix keeps coming from the effective paused flag. Right-pane deadline/penalty previews are untouched — they stay computed from the snapshot's `clockMs` (deterministic against server truth) +- No server or OpenAPI changes — display-only client work +- Tests: unit — new suite in `lib/useSimClock.test.ts` pins the extrapolation math (exact elapsed × scale advance, frozen while paused at any scale, zero-elapsed identity, clock-skew clamp never goes backwards, mirrors the tickOnce formula under the 2 s clamp). e2e (`panes.spec.ts`) — new test: at scale 1000 sample `sim-clock` three times ~1.3 s apart real time and assert the displayed clock strictly advances between CONSECUTIVE samples (with poll-only rendering a pair could straddle zero snapshots and not change), plus format check and a total-advance sanity bound +- Verified: `tsc --noEmit` clean; unit suite green locally; e2e not run locally by design — CI verifies against an isolated stack + ## v0.16.0 — 2026-09-19 12:18 - **Weight-proportional fuel burn (ISS-005, part A).** Fuel no longer burns at a constant L/km — the instantaneous rate is proportional to total weight, `r(F) = k·(We + ρF)` L/km with We the empty weight and ρ = 0.8 kg/L (Jet-A1), so a heavier plane burns more per km and a tank gets lighter as it empties. The constant `k` is calibrated so a FULL tank burns exactly the catalog's old `burnPerKm`, keeping full-tank departure behavior identical to before; divergence only grows in flight as fuel drops off. Closed forms of `dF/dx = −k(We + ρF)` live in `lib/sim/movement.ts` (single source of truth): - `fuelAtWeighted(startFuel, burnPerKm, emptyWeightKg, maxFuel, distanceKm, progress)` — fuel remaining after flying `progress` of the leg (replaces the old constant-rate `fuelAt`, which is removed) diff --git a/components/LeftPane.tsx b/components/LeftPane.tsx index 6d2868a..cb5f100 100644 --- a/components/LeftPane.tsx +++ b/components/LeftPane.tsx @@ -5,6 +5,7 @@ import type { PlaneState, StateSnapshot } from '@/lib/game/state'; import { PLANE_TYPES } from '@/lib/game/catalog'; import { formatCash, formatFuel, formatSimClock } from '@/lib/format'; import { TIME_SCALES, scaleToSlider, sliderToScale } from '@/lib/timeScale'; +import { useSimClock } from '@/lib/useSimClock'; import type { HoverTarget } from './hoverTypes'; interface LeftPaneProps { @@ -167,6 +168,12 @@ export default function LeftPane({ state, loading, error, onRefresh, onHover }: 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 }); + return (