feat: v0.16.1 — ISS-007 smooth client-side sim clock with per-snapshot re-sync
CI / build + unit + e2e (pull_request) Failing after 6m42s
CI / build + unit + e2e (pull_request) Failing after 6m42s
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { advanceSimClock, type SimClockAnchor } from './useSimClock';
|
||||
|
||||
// ISS-007 (v0.16.1) — the pure extrapolation core of lib/useSimClock.ts. The hook itself is a
|
||||
// thin setInterval + re-sync wrapper covered by e2e (panes.spec.ts "ticks smoothly between polls");
|
||||
// this suite pins the math: game time advances real-time × timeScale, frozen while paused.
|
||||
|
||||
const anchor = (over: Partial<SimClockAnchor> = {}): SimClockAnchor => ({
|
||||
clockMs: 1_750_000_000_000,
|
||||
at: 1_000_000,
|
||||
timeScale: 1,
|
||||
paused: false,
|
||||
...over,
|
||||
});
|
||||
|
||||
describe('advanceSimClock', () => {
|
||||
it('advances by exactly elapsed real-time × timeScale while running', () => {
|
||||
expect(advanceSimClock(anchor(), 1_001_000)).toBe(1_750_000_000_000 + 1_000); // scale 1: 1 s real = 1 s game
|
||||
expect(advanceSimClock(anchor({ timeScale: 25 }), 1_002_500)).toBe(1_750_000_000_000 + 62_500);
|
||||
// The e2e scenario: scale 1000, 2.5 s real → 2 500 game seconds.
|
||||
expect(advanceSimClock(anchor({ timeScale: 1000 }), 1_002_500)).toBe(1_750_000_000_000 + 2_500_000);
|
||||
});
|
||||
|
||||
it('returns the anchor value exactly at zero elapsed', () => {
|
||||
expect(advanceSimClock(anchor({ timeScale: 1000 }), 1_000_000)).toBe(1_750_000_000_000);
|
||||
});
|
||||
|
||||
it('is frozen while paused regardless of elapsed or scale', () => {
|
||||
const a = anchor({ timeScale: 1000, paused: true });
|
||||
expect(advanceSimClock(a, 1_000_000)).toBe(1_750_000_000_000);
|
||||
expect(advanceSimClock(a, 2_000_000)).toBe(1_750_000_000_000); // a full second later — still frozen
|
||||
});
|
||||
|
||||
it('clamps to the anchor value when nowMs is before the anchor (clock skew), never backwards', () => {
|
||||
expect(advanceSimClock(anchor({ timeScale: 1000 }), 999_999)).toBe(1_750_000_000_000);
|
||||
expect(advanceSimClock(anchor({ paused: true }), 999_999)).toBe(1_750_000_000_000);
|
||||
});
|
||||
|
||||
it('mirrors the server tick model (tickOnce): advance = elapsed × timeScale while actively polled', () => {
|
||||
// lib/sim/engine.ts: clockMs += min(now − lastRealTickMs, MAX_TICK_ELAPSED_MS) × timeScale.
|
||||
// For gaps under the 2 s clamp the client extrapolation is exactly the server's own formula.
|
||||
const scale = 50;
|
||||
for (const gap of [1, 250, 999, 1_999]) {
|
||||
expect(advanceSimClock(anchor({ timeScale: scale }), 1_000_000 + gap)).toBe(
|
||||
1_750_000_000_000 + gap * scale,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import type { StateSnapshot } from './game/state';
|
||||
|
||||
// ISS-007 (v0.16.1): the sim clock ticks smoothly on the CLIENT between polls instead of
|
||||
// jumping once per snapshot. The server's authoritative rate is exactly real-time × timeScale
|
||||
// (tickOnce in lib/sim/engine.ts: advance = min(elapsed, MAX_TICK_ELAPSED_MS) × timeScale), so
|
||||
// client-side extrapolation mirrors the server's own model. Every new snapshot re-anchors to
|
||||
// server truth, bounding any drift by network latency only.
|
||||
|
||||
export interface SimClockAnchor {
|
||||
// Server truth at anchor time — the snapshot's simState.clockMs.
|
||||
clockMs: number;
|
||||
// Date.now() when that snapshot was received.
|
||||
at: number;
|
||||
timeScale: number;
|
||||
paused: boolean;
|
||||
}
|
||||
|
||||
// Pure core of the extrapolation — mirrors tickOnce: while running, game time advances by
|
||||
// real-time × timeScale; while paused it is frozen (advance 0). nowMs before `at` clamps to
|
||||
// the anchor value instead of going backwards. Unit-testable in node.
|
||||
export function advanceSimClock(anchor: SimClockAnchor, nowMs: number): number {
|
||||
if (anchor.paused) return anchor.clockMs;
|
||||
return anchor.clockMs + Math.max(0, nowMs - anchor.at) * anchor.timeScale;
|
||||
}
|
||||
|
||||
// ~10 fps display refresh — smooth for a seconds-precision wall clock without busy re-renders.
|
||||
const DISPLAY_TICK_MS = 100;
|
||||
|
||||
export interface SimClockControls {
|
||||
// EFFECTIVE controls (optimistic pending scale / current pause), not the snapshot's:
|
||||
// UI changes take effect on click/drag without waiting for a confirming snapshot.
|
||||
timeScale: number;
|
||||
paused: boolean;
|
||||
}
|
||||
|
||||
// Returns the display clockMs to render, or null until the first snapshot arrives.
|
||||
export function useSimClock(snapshot: StateSnapshot | null, controls: SimClockControls): number | null {
|
||||
const anchorRef = useRef<SimClockAnchor | null>(null);
|
||||
const [displayMs, setDisplayMs] = useState<number | null>(null);
|
||||
|
||||
// Re-sync on every snapshot AND effective-control change: snap to server truth. The snapshot
|
||||
// object is fresh per poll (useGameState), so this runs once per poll cycle as well.
|
||||
useEffect(() => {
|
||||
if (!snapshot) return;
|
||||
anchorRef.current = {
|
||||
clockMs: snapshot.simState.clockMs,
|
||||
at: Date.now(),
|
||||
timeScale: controls.timeScale,
|
||||
paused: controls.paused,
|
||||
};
|
||||
setDisplayMs(advanceSimClock(anchorRef.current, Date.now()));
|
||||
}, [snapshot, controls.timeScale, controls.paused]);
|
||||
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => {
|
||||
const anchor = anchorRef.current;
|
||||
if (anchor) setDisplayMs(advanceSimClock(anchor, Date.now()));
|
||||
}, DISPLAY_TICK_MS);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
// First paint after a snapshot arrives happens before the re-sync effect runs — show the
|
||||
// snapshot value directly so the clock never blanks for a frame.
|
||||
return displayMs ?? (snapshot ? snapshot.simState.clockMs : null);
|
||||
}
|
||||
Reference in New Issue
Block a user