import { describe, it, expect, vi } from 'vitest' import { GameLoop } from '../src/sim/loop.ts' describe('GameLoop (Issue #19: Decouple simulation from rAF)', () => { it('1. Deterministic ticking at 25 tps in headless/step mode', () => { let tickCount = 0 const ticks: number[] = [] const loop = new GameLoop({ tickRate: 25, // 40ms per tick onTick: (tick) => { tickCount++ ticks.push(tick) }, onRender: () => {} }) expect(loop.tickMs).toBe(40) expect(loop.tick).toBe(0) // Advancing by less than tickMs does not produce a tick loop.step(20) expect(tickCount).toBe(0) expect(loop.accumulator).toBe(20) expect(loop.alpha).toBeCloseTo(0.5, 2) // Advancing another 20ms completes 40ms -> exactly 1 tick loop.step(20) expect(tickCount).toBe(1) expect(loop.tick).toBe(1) expect(loop.accumulator).toBe(0) expect(loop.alpha).toBe(0) // Advancing by 80ms produces exactly 2 ticks loop.step(80) expect(tickCount).toBe(3) expect(loop.tick).toBe(3) expect(loop.accumulator).toBe(0) // Advancing by 1000ms produces exactly 25 ticks loop.step(1000) expect(tickCount).toBe(28) // 3 + 25 expect(loop.tick).toBe(28) expect(loop.tickRate).toBe(25.0) expect(loop.simTps).toBe(25.0) }) it('1b. Deterministic ticking at 25 tps with timers', async () => { let tickCount = 0 const loop = new GameLoop({ tickRate: 25, // 40ms per tick onTick: () => { tickCount++ }, onRender: () => {} }) loop.start() expect(loop.isRunning).toBe(true) // Wait ~260ms -> should produce at least 5 ticks (40ms * 5 = 200ms) await new Promise(resolve => setTimeout(resolve, 260)) loop.stop() expect(loop.isRunning).toBe(false) expect(tickCount).toBeGreaterThanOrEqual(5) expect(tickCount).toBeLessThanOrEqual(8) }) it('2. Artificial render delay test: when onRender has a 50ms stall, simulation ticks do NOT drop and world.tick advances smoothly', async () => { const world = { tick: 0 } let renderCalls = 0 const loop = new GameLoop({ tickRate: 25, // 40ms per tick onTick: (tick) => { world.tick = tick }, onRender: () => { renderCalls++ // Simulate heavy render frame stall of 50ms const stallStart = performance.now() while (performance.now() - stallStart < 50) { // busy wait } } }) loop.start() // Run for ~220ms // With 50ms render stall per frame, at most 4 render calls can happen (~20 FPS) // But simulation runs at 25 TPS (40ms per tick) -> should produce ~5 ticks await new Promise(resolve => setTimeout(resolve, 220)) loop.stop() expect(renderCalls).toBeLessThanOrEqual(4) expect(world.tick).toBeGreaterThanOrEqual(5) // Simulation TPS remains high despite slowed rendering expect(loop.simTps).toBeGreaterThanOrEqual(20) }) it('3. Accumulator recovery and no catastrophic drop on transient lag', () => { let tickCount = 0 const loop = new GameLoop({ tickRate: 25, // 40ms per tick maxCatchUp: 5, // max 5 ticks (200ms) per step onTick: () => { tickCount++ }, onRender: () => {} }) // Transient lag of 150ms (3.75 ticks) loop.step(150) // Consumed 3 ticks (120ms), 30ms remaining expect(tickCount).toBe(3) expect(loop.accumulator).toBe(30) expect(loop.alpha).toBeCloseTo(30 / 40, 2) // Next step of 20ms: 30ms + 20ms = 50ms >= 40ms -> consumes 1 tick, 10ms left loop.step(20) expect(tickCount).toBe(4) expect(loop.accumulator).toBe(10) expect(loop.alpha).toBeCloseTo(10 / 40, 2) // Huge lag of 500ms (exceeding maxCatchUp * tickMs = 200ms) // Old implementation had `if (this.accumulator > this.tickMs * this.maxCatchUp) this.accumulator = 0` // which wiped out all remaining simulation time! // New implementation clamps the backlog to maxBacklog (200ms) instead of wiping to 0. loop.step(500) // 5 ticks executed in this step (tickCount 4 + 5 = 9) expect(tickCount).toBe(9) // Accumulator is clamped to maxBacklog (200ms), NOT catastrophically dropped to 0! expect(loop.accumulator).toBe(200) // Subsequent steps recover and consume the preserved backlog loop.step(40) // Consumes 5 ticks from the 200ms backlog + 40ms new time expect(tickCount).toBe(14) expect(loop.accumulator).toBe(40) loop.step(40) expect(tickCount).toBe(16) expect(loop.accumulator).toBe(0) }) it('4. Clean start / stop lifecycle and timer cleanup', async () => { let tickCount = 0 let renderCount = 0 const loop = new GameLoop({ tickRate: 25, onTick: () => { tickCount++ }, onRender: () => { renderCount++ } }) expect(loop.isRunning).toBe(false) // Start loop.start() expect(loop.isRunning).toBe(true) // Calling start() again is idempotent loop.start() expect(loop.isRunning).toBe(true) await new Promise(resolve => setTimeout(resolve, 120)) expect(tickCount).toBeGreaterThanOrEqual(1) expect(renderCount).toBeGreaterThanOrEqual(1) // Stop loop.stop() expect(loop.isRunning).toBe(false) const stoppedTicks = tickCount const stoppedRenders = renderCount // Calling stop() again is idempotent loop.stop() expect(loop.isRunning).toBe(false) // Wait and verify timers are cleaned up and no more callbacks fire await new Promise(resolve => setTimeout(resolve, 100)) expect(tickCount).toBe(stoppedTicks) expect(renderCount).toBe(stoppedRenders) // Restart cleanly loop.start() expect(loop.isRunning).toBe(true) await new Promise(resolve => setTimeout(resolve, 120)) expect(tickCount).toBeGreaterThan(stoppedTicks) expect(renderCount).toBeGreaterThan(stoppedRenders) loop.stop() }) it('5. Interpolation alpha calculation and manual render stepping', () => { let lastAlpha = -1 let lastFrameMs = -1 const loop = new GameLoop({ tickRate: 25, // 40ms onTick: () => {}, onRender: (alpha, frameMs) => { lastAlpha = alpha lastFrameMs = frameMs } }) loop.render(16.6) expect(lastAlpha).toBe(0) expect(lastFrameMs).toBeCloseTo(16.6, 1) loop.step(10) // accumulator = 10ms (10/40 = 0.25) loop.render(16.6) expect(lastAlpha).toBeCloseTo(0.25, 2) loop.step(20) // accumulator = 30ms (30/40 = 0.75) loop.render(16.6) expect(lastAlpha).toBeCloseTo(0.75, 2) loop.step(10) // accumulator = 40ms -> ticks, accumulator = 0ms loop.render(16.6) expect(lastAlpha).toBe(0) }) })