import { describe, it, expect } from 'vitest' import { readFileSync, existsSync, readdirSync } from 'node:fs' import { join } from 'node:path' import { computeObjectPhaseOffsetMs, updateAnimatedObject, updateAnimatedObjects, PERSISTENT_ILLUMINATION_TOKENS, type ObjectDrawable, type AnimatedObjectFrame, } from '../src/scene/act-scene.ts' import { updateAnimatableTiles, type AnimatableTile, DEFAULT_ANIMATED_TILE_FRAME_DURATION_MS, } from '../src/game/animated-tiles.ts' describe('Dungeon & Wilderness Illumination & Animated Tile Sequences (Issue #398)', () => { describe('PERSISTENT_ILLUMINATION_TOKENS definitions', () => { it('includes all canonical illumination and environmental fire tokens', () => { const requiredTokens = ['TO', 'A1', 'A2', '3O', '3o', 'FX', 'FY', 'FZ', 'BR', 'BF', 'FL'] for (const token of requiredTokens) { expect(PERSISTENT_ILLUMINATION_TOKENS.has(token)).toBe(true) } }) }) describe('Persistent illumination looping parity (updateAnimatedObject)', () => { const dummyAtlasFrame = (i: number) => ({ x: i * 32, y: 0, width: 32, height: 64, }) const createTestIlluminationDrawable = ( token: string, frameCount = 10, frameDurationMs = 80, forceCycleAnim?: boolean, ): ObjectDrawable => { const frames: AnimatedObjectFrame[] = Array.from({ length: frameCount }, (_, i) => ({ frame: dummyAtlasFrame(i), page: 0, offsetX: -16, offsetY: -32, })) return { token, frame: frames[0]!.frame, x: 100, y: 200, depth: 60, page: 0, baseX: 100, baseY: 200, animatedFrames: frames, frameDurationMs, cycleAnim: forceCycleAnim, phaseOffsetMs: 0, currentFrameIndex: 0, } } it('cycles continuously even if cycleAnim was false for persistent illumination tokens', () => { // Tokens like FX, FY, FZ (environmental fires) or BR (braziers) might have cycleAnim=0 in Objects.txt mode 0 const tokensToTest = ['TO', 'A1', 'A2', '3O', '3o', 'FX', 'FY', 'FZ', 'BR', 'BF', 'FL'] for (const token of tokensToTest) { const obj = createTestIlluminationDrawable(token, 10, 80, false) const totalDuration = 10 * 80 // 800ms // At t=0 updateAnimatedObject(obj, 0) expect(obj.currentFrameIndex).toBe(0) // At t=160ms (frame 2) updateAnimatedObject(obj, 160) expect(obj.currentFrameIndex).toBe(2) // Beyond cycle: at t=800ms, should loop back to frame 0 (NOT clamped to frame 9) updateAnimatedObject(obj, 800) expect(obj.currentFrameIndex).toBe(0) // At t=1040ms (800 + 3 * 80), should be frame 3 updateAnimatedObject(obj, 1040) expect(obj.currentFrameIndex).toBe(3) } }) it('clamps non-persistent objects when cycleAnim is false', () => { const nonFireObj = createTestIlluminationDrawable('XX', 10, 80, false) // At t=1000ms (> 800ms total duration) updateAnimatedObject(nonFireObj, 1000) expect(nonFireObj.currentFrameIndex).toBe(9) // clamped to last frame }) it('maintains independent spatial phase offsets across torches and candles', () => { const duration = 20 * 51.2 const p1 = computeObjectPhaseOffsetMs(100, 200, duration) const p2 = computeObjectPhaseOffsetMs(104, 208, duration) expect(p1).not.toBe(p2) 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', () => { it('correctly unpacks extended animated tile rows [frameIndex, x, y, cx, cy, ...animFrames]', () => { // Simulate scene frames const dummyFrames = [ { x: 0, y: 0, width: 160, height: 80 }, { x: 160, y: 0, width: 160, height: 80 }, { x: 320, y: 0, width: 160, height: 80 }, { x: 480, y: 0, width: 160, height: 80 }, ] const framePlacement = [ [0, 0, 0, 160, 80], [0, 160, 0, 160, 80], [0, 320, 0, 160, 80], [0, 480, 0, 160, 80], ] // Row with 4 animated frames: indices 0, 1, 2, 3 const row = [0, 100, 200, 5, 10, 0, 1, 2, 3] const frameIndex = row[0] ?? 0 const animFrames = row.length > 5 ? row.slice(5) : undefined const anim = animFrames && animFrames.length > 1 ? animFrames.map(fIdx => ({ frame: dummyFrames[fIdx]!, page: framePlacement[fIdx]?.[0] ?? 0, })) : undefined const tile: AnimatableTile = { frame: dummyFrames[frameIndex]!, page: framePlacement[frameIndex]?.[0] ?? 0, animatedFrames: anim, currentFrameIndex: 0, } expect(tile.animatedFrames).toBeDefined() expect(tile.animatedFrames!.length).toBe(4) // Verify updateAnimatableTiles advances frames const tiles = [tile] // At t=0ms updateAnimatableTiles(tiles, 0, 'time', DEFAULT_ANIMATED_TILE_FRAME_DURATION_MS) expect(tile.currentFrameIndex).toBe(0) // At t=150ms (> 128ms) updateAnimatableTiles(tiles, 150, 'time', DEFAULT_ANIMATED_TILE_FRAME_DURATION_MS) expect(tile.currentFrameIndex).toBe(1) expect(tile.frame).toEqual(dummyFrames[1]) // At t=550ms (> 4 * 128 = 512ms, looped to frame 0) updateAnimatableTiles(tiles, 550, 'time', DEFAULT_ANIMATED_TILE_FRAME_DURATION_MS) expect(tile.currentFrameIndex).toBe(0) expect(tile.frame).toEqual(dummyFrames[0]) }) }) describe('Baked Map Scene Inspection (Wilderness & Dungeons)', () => { const packsDir = join(process.cwd(), 'samples/d2-packs') it('verifies town scene (towne1) preserves all animated objects', () => { const townPath = join(packsDir, 'act1/1-act-1-town-towne1/scene.json') expect(existsSync(townPath)).toBe(true) const scene = JSON.parse(readFileSync(townPath, 'utf8')) const objects = scene.objects as any[] // Campfire (RB) const rbs = objects.filter(o => o.token === 'RB') expect(rbs.length).toBeGreaterThan(0) for (const rb of rbs) { expect(rb.animatedFrames?.length).toBe(20) expect(rb.cycleAnim).toBe(true) } // Torches (TO) const tos = objects.filter(o => o.token === 'TO') expect(tos.length).toBeGreaterThan(0) for (const to of tos) { expect(to.animatedFrames?.length).toBe(20) expect(to.cycleAnim).toBe(true) } }) it('verifies Cave (Level 10) packs have animated illumination objects', () => { const act1Dir = join(packsDir, 'act1') 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') 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()) ) expect(illumination.length).toBeGreaterThan(0) for (const item of illumination) { if (item.animatedFrames) { expect(item.animatedFrames.length).toBeGreaterThan(1) expect(item.cycleAnim).toBe(true) expect(item.frameDelta).toBeGreaterThan(0) } } } }) it('verifies Catacombs (Level 34) packs have animated candles (A1, A2) and fires (BF, BR)', () => { const act1Dir = join(packsDir, 'act1') expect(existsSync(act1Dir)).toBe(true) const catacombsDirs = readdirSync(act1Dir).filter(name => name.startsWith('34-act-1-catacombs')) expect(catacombsDirs.length).toBeGreaterThan(0) let totalCandlesOrFires = 0 for (const d of catacombsDirs) { const scenePath = join(act1Dir, d, 'scene.json') 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()) ) for (const item of items) { totalCandlesOrFires += 1 if (item.animatedFrames) { expect(item.animatedFrames.length).toBeGreaterThan(1) expect(item.cycleAnim).toBe(true) expect(item.frameDurationMs).toBeGreaterThan(0) } } } expect(totalCandlesOrFires).toBeGreaterThan(0) }) it('verifies Wilderness and Crypt maps contain cycleAnim=true on illumination objects', () => { const act1Dir = join(packsDir, 'act1') 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') 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()) ) for (const item of items) { if (item.animatedFrames) { expect(item.cycleAnim).toBe(true) } } } }) it('verifies DT1 animated tile sequences in scenes that contain animated tiles', () => { const act4Dir = join(packsDir, 'act4') 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')) expect(chaosDirs.length).toBeGreaterThan(0) let totalAnimatedTiles = 0 for (const d of chaosDirs) { const scenePath = join(act4Dir, d, 'scene.json') expect(existsSync(scenePath)).toBe(true) const scene = JSON.parse(readFileSync(scenePath, 'utf8')) const draws = [ ...(scene.floors ?? []), ...(scene.walls ?? []), ...(scene.shadows ?? []), ...(scene.roofs ?? []), ] const animatedTileDraws = draws.filter((row: number[]) => row.length > 5) totalAnimatedTiles += animatedTileDraws.length if (animatedTileDraws.length > 0) { for (const row of animatedTileDraws) { const animFrames = row.slice(5) expect(animFrames.length).toBeGreaterThan(1) // Each frame index should be valid in scene.frames for (const fIdx of animFrames) { expect(fIdx).toBeGreaterThanOrEqual(0) expect(fIdx).toBeLessThan(scene.frames.length) } } } } expect(totalAnimatedTiles).toBeGreaterThan(0) }) }) })