232 lines
6.6 KiB
TypeScript
232 lines
6.6 KiB
TypeScript
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)
|
|
})
|
|
})
|