diablo2-web/tests/loop.test.ts

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)
})
})