diff --git a/tests/dungeon-wilderness-animations.test.ts b/tests/dungeon-wilderness-animations.test.ts index b5510cc..ec61f2e 100644 --- a/tests/dungeon-wilderness-animations.test.ts +++ b/tests/dungeon-wilderness-animations.test.ts @@ -104,6 +104,63 @@ describe('Dungeon & Wilderness Illumination & Animated Tile Sequences (Issue #39 expect(p1).toBeGreaterThanOrEqual(0) expect(p1).toBeLessThan(duration) }) + + it('prevents sprite coordinate drift across frame transitions and loops', () => { + const frames: AnimatedObjectFrame[] = [ + { frame: dummyAtlasFrame(0), page: 0, offsetX: -10, offsetY: -20 }, + { frame: dummyAtlasFrame(1), page: 0, offsetX: -15, offsetY: -25 }, + { frame: dummyAtlasFrame(2), page: 0, offsetX: -8, offsetY: -18 }, + ] + const obj: ObjectDrawable = { + token: 'TO', + 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: 100, + cycleAnim: true, + currentFrameIndex: 0, + } + + for (let clock = 0; clock <= 3000; clock += 50) { + updateAnimatedObject(obj, clock) + const expectedFrameIdx = Math.floor(clock / 100) % 3 + expect(obj.currentFrameIndex).toBe(expectedFrameIdx) + const expectedFrame = frames[expectedFrameIdx]! + expect(obj.x).toBe(100 + expectedFrame.offsetX) + expect(obj.y).toBe(200 + expectedFrame.offsetY) + } + }) + + it('safely handles degenerate frame inputs (empty, single frame, zero/negative duration)', () => { + const singleFrameObj: ObjectDrawable = { + token: 'TO', + frame: dummyAtlasFrame(0), + x: 100, + y: 200, + depth: 60, + page: 0, + baseX: 100, + baseY: 200, + animatedFrames: [{ frame: dummyAtlasFrame(0), page: 0, offsetX: 0, offsetY: 0 }], + frameDurationMs: 80, + currentFrameIndex: 0, + } + expect(updateAnimatedObject(singleFrameObj, 1000)).toBe(false) + expect(singleFrameObj.currentFrameIndex).toBe(0) + + const zeroDurationObj = createTestIlluminationDrawable('TO', 5, 0, true) + expect(updateAnimatedObject(zeroDurationObj, 120)).toBe(true) + expect(zeroDurationObj.currentFrameIndex).toBe(3) + + const negClockObj = createTestIlluminationDrawable('TO', 5, 40, true) + expect(updateAnimatedObject(negClockObj, -100)).toBe(false) + expect(negClockObj.currentFrameIndex).toBe(0) + }) }) describe('DT1 animated tile serialization and unpacking parity', () => { @@ -166,7 +223,7 @@ describe('Dungeon & Wilderness Illumination & Animated Tile Sequences (Issue #39 it('verifies town scene (towne1) preserves all animated objects', () => { const townPath = join(packsDir, 'act1/1-act-1-town-towne1/scene.json') - if (!existsSync(townPath)) return + expect(existsSync(townPath)).toBe(true) const scene = JSON.parse(readFileSync(townPath, 'utf8')) const objects = scene.objects as any[] @@ -190,13 +247,13 @@ describe('Dungeon & Wilderness Illumination & Animated Tile Sequences (Issue #39 it('verifies Cave (Level 10) packs have animated illumination objects', () => { const act1Dir = join(packsDir, 'act1') - if (!existsSync(act1Dir)) return + expect(existsSync(act1Dir)).toBe(true) const caveDirs = readdirSync(act1Dir).filter(name => name.startsWith('10-act-1-cave')) expect(caveDirs.length).toBeGreaterThan(0) for (const d of caveDirs) { const scenePath = join(act1Dir, d, 'scene.json') - if (!existsSync(scenePath)) continue + expect(existsSync(scenePath)).toBe(true) const scene = JSON.parse(readFileSync(scenePath, 'utf8')) const illumination = (scene.objects as any[]).filter(o => o.token && PERSISTENT_ILLUMINATION_TOKENS.has(o.token.trim().toUpperCase()) @@ -214,7 +271,7 @@ describe('Dungeon & Wilderness Illumination & Animated Tile Sequences (Issue #39 it('verifies Catacombs (Level 34) packs have animated candles (A1, A2) and fires (BF, BR)', () => { const act1Dir = join(packsDir, 'act1') - if (!existsSync(act1Dir)) return + expect(existsSync(act1Dir)).toBe(true) const catacombsDirs = readdirSync(act1Dir).filter(name => name.startsWith('34-act-1-catacombs')) expect(catacombsDirs.length).toBeGreaterThan(0) @@ -222,7 +279,7 @@ describe('Dungeon & Wilderness Illumination & Animated Tile Sequences (Issue #39 let totalCandlesOrFires = 0 for (const d of catacombsDirs) { const scenePath = join(act1Dir, d, 'scene.json') - if (!existsSync(scenePath)) continue + expect(existsSync(scenePath)).toBe(true) const scene = JSON.parse(readFileSync(scenePath, 'utf8')) const items = (scene.objects as any[]).filter(o => o.token && ['A1', 'A2', 'BF', 'BR', 'TO'].includes(o.token.trim().toUpperCase()) @@ -241,13 +298,13 @@ describe('Dungeon & Wilderness Illumination & Animated Tile Sequences (Issue #39 it('verifies Wilderness and Crypt maps contain cycleAnim=true on illumination objects', () => { const act1Dir = join(packsDir, 'act1') - if (!existsSync(act1Dir)) return + expect(existsSync(act1Dir)).toBe(true) const cryptDirs = readdirSync(act1Dir).filter(name => name.startsWith('18-act-1-crypt') || name.startsWith('19-act-1-crypt')) expect(cryptDirs.length).toBeGreaterThan(0) for (const d of cryptDirs) { const scenePath = join(act1Dir, d, 'scene.json') - if (!existsSync(scenePath)) continue + expect(existsSync(scenePath)).toBe(true) const scene = JSON.parse(readFileSync(scenePath, 'utf8')) const items = (scene.objects as any[]).filter(o => o.token && PERSISTENT_ILLUMINATION_TOKENS.has(o.token.trim().toUpperCase()) @@ -262,7 +319,7 @@ describe('Dungeon & Wilderness Illumination & Animated Tile Sequences (Issue #39 it('verifies DT1 animated tile sequences in scenes that contain animated tiles', () => { const act4Dir = join(packsDir, 'act4') - if (!existsSync(act4Dir)) return + expect(existsSync(act4Dir)).toBe(true) // River of flame (Level 107) and Chaos Sanctuary (Level 108) have animated lava tiles const chaosDirs = readdirSync(act4Dir).filter(name => name.startsWith('108-act-4-diablo') || name.startsWith('107-act-4-lava')) @@ -270,7 +327,7 @@ describe('Dungeon & Wilderness Illumination & Animated Tile Sequences (Issue #39 let totalAnimatedTiles = 0 for (const d of chaosDirs) { const scenePath = join(act4Dir, d, 'scene.json') - if (!existsSync(scenePath)) continue + expect(existsSync(scenePath)).toBe(true) const scene = JSON.parse(readFileSync(scenePath, 'utf8')) const draws = [ ...(scene.floors ?? []),