import { describe, it, expect } from 'vitest' import { readFileSync, existsSync } from 'node:fs' import { join } from 'node:path' import { computeObjectPhaseOffsetMs, updateAnimatedObject, updateAnimatedObjects, type ObjectDrawable, type AnimatedObjectFrame, } from '../src/scene/act-scene.ts' describe('Diablo II 1.13c Map Object Animations (Issue #387)', () => { describe('Spatial phase offset (SplitMix64 anti-resonance)', () => { it('returns 0 when totalDurationMs is 0 or negative', () => { expect(computeObjectPhaseOffsetMs(100, 200, 0)).toBe(0) expect(computeObjectPhaseOffsetMs(100, 200, -10)).toBe(0) }) it('is strictly deterministic for identical coordinates', () => { const p1 = computeObjectPhaseOffsetMs(152, 364, 1024) const p2 = computeObjectPhaseOffsetMs(152, 364, 1024) expect(p1).toBe(p2) }) it('produces diverse phase offsets for different torch locations', () => { const torchCoords = [ { x: 100, y: 200 }, { x: 105, y: 200 }, { x: 120, y: 250 }, { x: 300, y: 400 }, { x: 310, y: 400 }, { x: 500, y: 600 }, ] const totalDuration = 20 * 51.2 // 1024ms for 20-frame torch const phases = torchCoords.map(c => computeObjectPhaseOffsetMs(c.x, c.y, totalDuration)) // All phases must be within [0, totalDuration) for (const phase of phases) { expect(phase).toBeGreaterThanOrEqual(0) expect(phase).toBeLessThan(totalDuration) } // Check entropy: no two distinct torch coordinates should have the exact same phase const uniquePhases = new Set(phases) expect(uniquePhases.size).toBe(torchCoords.length) }) }) describe('Runtime frame ticking (updateAnimatedObject)', () => { const dummyAtlasFrame = (i: number) => ({ x: i * 32, y: 0, width: 32, height: 64, }) const createTorchDrawable = (phaseOffsetMs = 0): ObjectDrawable => { const frameCount = 20 const frameDurationMs = 51.2 const frames: AnimatedObjectFrame[] = Array.from({ length: frameCount }, (_, i) => ({ frame: dummyAtlasFrame(i), page: 0, offsetX: -16, offsetY: -32 + (i % 2), // slightly fluctuating DC6/DCC offset })) return { frame: frames[0]!.frame, x: 100 + frames[0]!.offsetX, y: 200 + frames[0]!.offsetY, depth: 60, page: 0, baseX: 100, baseY: 200, animatedFrames: frames, frameDurationMs, cycleAnim: true, phaseOffsetMs, currentFrameIndex: 0, } } it('ignores static or single-frame objects', () => { const staticObj: ObjectDrawable = { frame: dummyAtlasFrame(0), x: 100, y: 200, depth: 60, page: 0, } expect(updateAnimatedObject(staticObj, 1000)).toBe(false) expect(staticObj.currentFrameIndex).toBeUndefined() }) it('advances frames sequentially with time', () => { const torch = createTorchDrawable(0) // At t=0ms, frame should be 0 expect(updateAnimatedObject(torch, 0)).toBe(false) expect(torch.currentFrameIndex).toBe(0) // At t=60ms (> 51.2ms), frame should be 1 expect(updateAnimatedObject(torch, 60)).toBe(true) expect(torch.currentFrameIndex).toBe(1) expect(torch.frame.x).toBe(32) expect(torch.x).toBe(100 - 16) expect(torch.y).toBe(200 - 32 + 1) // At t=110ms (> 2 * 51.2 = 102.4ms), frame should be 2 expect(updateAnimatedObject(torch, 110)).toBe(true) expect(torch.currentFrameIndex).toBe(2) expect(torch.frame.x).toBe(64) // Advancing to the same time interval does not re-trigger update expect(updateAnimatedObject(torch, 115)).toBe(false) }) it('wraps smoothly in a loop when cycleAnim is true', () => { const torch = createTorchDrawable(0) const cycleDuration = 20 * 51.2 // 1024 ms // Exactly at one full cycle (1024ms), it wraps back to frame 0 updateAnimatedObject(torch, 1024 + 10) expect(torch.currentFrameIndex).toBe(0) // Halfway through next cycle: frame 10 updateAnimatedObject(torch, 1024 + 520) expect(torch.currentFrameIndex).toBe(10) }) it('clamps to the final frame when cycleAnim is false', () => { const nonLoopingObj = createTorchDrawable(0) // Modify to non-cycling ;(nonLoopingObj as any).cycleAnim = false // Beyond end of cycle (2000 ms > 1024 ms) updateAnimatedObject(nonLoopingObj, 2000) expect(nonLoopingObj.currentFrameIndex).toBe(19) // clamped to last frame (index 19) }) it('respects spatial phase offset', () => { // Torch A at phase 0, Torch B at phase 256ms (~5 frames ahead) const torchA = createTorchDrawable(0) const torchB = createTorchDrawable(256) updateAnimatedObject(torchA, 0) updateAnimatedObject(torchB, 0) expect(torchA.currentFrameIndex).toBe(0) expect(torchB.currentFrameIndex).toBe(5) // floor(256 / 51.2) = 5 }) it('updates array of objects via updateAnimatedObjects', () => { const list = [ createTorchDrawable(0), createTorchDrawable(100), createTorchDrawable(200), ] const changedCount = updateAnimatedObjects(list, 60) expect(changedCount).toBeGreaterThan(0) for (const obj of list) { expect(obj.currentFrameIndex).toBeDefined() } }) }) describe('Ground truth 1.13c baked pack validation', () => { const sceneJsonPath = join(process.cwd(), 'samples/d2-packs/act1/1-act-1-town-towne1/scene.json') it('has baked animatedFrames and 1.13c timing in towne1 pack', () => { if (!existsSync(sceneJsonPath)) { console.warn(`Skipping pack validation test: ${sceneJsonPath} does not exist`) return } const scene = JSON.parse(readFileSync(sceneJsonPath, 'utf8')) expect(scene.objects).toBeDefined() expect(Array.isArray(scene.objects)).toBe(true) // Check Campfire (RB) const campfires = scene.objects.filter((o: any) => o.token === 'RB') expect(campfires.length).toBeGreaterThan(0) for (const fire of campfires) { expect(fire.mode).toBe('ON') expect(fire.animatedFrames).toBeDefined() expect(fire.animatedFrames.length).toBe(20) expect(fire.frameDelta).toBe(256) // 25 FPS expect(fire.frameDurationMs).toBe(40) expect(fire.cycleAnim).toBe(true) expect(fire.sync).toBe(false) } // Check Torches (TO) const torches = scene.objects.filter((o: any) => o.token === 'TO') expect(torches.length).toBeGreaterThan(0) for (const torch of torches) { expect(torch.mode).toBe('ON') expect(torch.animatedFrames).toBeDefined() expect(torch.animatedFrames.length).toBe(20) expect(torch.frameDelta).toBe(200) // ~19.5 FPS expect(torch.frameDurationMs).toBe(51.2) expect(torch.cycleAnim).toBe(true) expect(torch.sync).toBe(false) } // Check Flags (N1, N2) const flagsN1 = scene.objects.filter((o: any) => o.token === 'N1') const flagsN2 = scene.objects.filter((o: any) => o.token === 'N2') expect(flagsN1.length).toBeGreaterThan(0) expect(flagsN2.length).toBeGreaterThan(0) for (const flag of [...flagsN1, ...flagsN2]) { expect(flag.mode).toBe('NU') expect(flag.animatedFrames).toBeDefined() expect(flag.animatedFrames.length).toBe(10) expect(flag.frameDelta).toBe(128) // 12.5 FPS expect(flag.frameDurationMs).toBe(80) expect(flag.cycleAnim).toBe(true) expect(flag.sync).toBe(false) } // Check Waypoint (WP) const waypoints = scene.objects.filter((o: any) => o.token === 'WP') expect(waypoints.length).toBeGreaterThan(0) for (const wp of waypoints) { expect(wp.mode).toBe('ON') expect(wp.animatedFrames).toBeDefined() expect(wp.animatedFrames.length).toBe(15) expect(wp.frameDelta).toBe(200) expect(wp.cycleAnim).toBe(true) expect(wp.sync).toBe(true) } // Check Stash (B6) const stashes = scene.objects.filter((o: any) => o.token === 'B6') expect(stashes.length).toBeGreaterThan(0) for (const stash of stashes) { expect(stash.token).toBe('B6') // Static object has no animatedFrames expect(stash.animatedFrames).toBeUndefined() // Single frame is preserved expect(stash.frame).toBeDefined() } }) }) })