/** * Diablo II: Lord of Destruction v1.13c — Cast Overlay System Parity Test Suite * * Requirements (Issue #386): * 1. 1.13c Ground Truth: Overlay.txt & Skills.txt authentic data extraction & verification. * 2. Complete mapping for all 24 Sorceress skills to their 1.13c castoverlay entries. * 3. Exact entity lifecycle: ActiveOverlay frame progression (animRate / 25 = 0.64/tick) & expiration (tick 22 for 14 frames). * 4. Spatial tracking: ActiveOverlay follows caster movement and instant teleportation. * 5. Viewport rendering: SpriteRenderer additive blending (Trans === 3, SRC_ALPHA + ONE), anchor alignment, & point lighting. * 6. Offline asset pipeline verification: Baked PNG/JSON in public/overlays/ and samples/d2-packs/overlays/. */ import { describe, expect, it } from 'vitest' import fs from 'node:fs' import path from 'node:path' import { Rng } from '../src/game/rng.ts' import { parseOverlayTxt, getOverlayTxtData, createActiveOverlay, parseSkillsTxt, getSkillCastOverlay, SORCERESS_SKILL_CAST_OVERLAYS, CANONICAL_113C_OVERLAYS, BATCH1_SKILLS, type ActiveOverlay, type OverlayTxtData, castSkill, } from '../src/game/skills.ts' import { drawCastOverlay, loadOverlayArtMap, type LoadedOverlayArt, } from '../src/scene/act-scene.ts' import { GameEngine } from '../src/game/engine.ts' import { DEMO_EXPERIENCE, DEMO_SKILLS, DEMO_QUESTS } from '../src/game/demo-data.ts' import { SpriteRenderer, type AtlasHandle } from '../src/render/renderer.ts' import { FIRE_CAST_1_META, TELEPORT_META, OVERLAY_METAS, } from '../src/render/overlays-meta.ts' describe('Issue #386 — Diablo II v1.13c Cast Overlay Parity', () => { const overlayTxtPath = path.resolve(__dirname, '../samples/fixtures/data/global/excel/Overlay.txt') const skillsTxtPath = path.resolve(__dirname, '../samples/fixtures/data/global/excel/Skills.txt') const overlayTxtContent = fs.readFileSync(overlayTxtPath, 'latin1') const skillsTxtContent = fs.readFileSync(skillsTxtPath, 'latin1') it('1. Parses authentic 1.13c Overlay.txt and verifies elemental cast overlays', () => { expect(overlayTxtContent.length).toBeGreaterThan(5000) const overlayMap = parseOverlayTxt(overlayTxtContent) // Verify fire_cast_1 const fireCast1 = overlayMap.get('fire_cast_1') expect(fireCast1).toBeDefined() expect(fireCast1!.filename).toBe('FireCast_for_Sorceress') expect(fireCast1!.frames).toBe(14) expect(fireCast1!.animRate).toBe(16) expect(fireCast1!.trans).toBe(3) // additive blending expect(fireCast1!.preDraw).toBe(false) // foreground (0 in Overlay.txt) expect(fireCast1!.radius).toBe(9) expect(fireCast1!.red).toBe(255) expect(fireCast1!.green).toBe(178) expect(fireCast1!.blue).toBe(64) // Verify fire_cast_2 const fireCast2 = overlayMap.get('fire_cast_2') expect(fireCast2).toBeDefined() expect(fireCast2!.filename).toBe('FireCast2') expect(fireCast2!.frames).toBe(16) expect(fireCast2!.animRate).toBe(16) expect(fireCast2!.trans).toBe(3) expect(fireCast2!.preDraw).toBe(false) expect(fireCast2!.radius).toBe(9) // Verify ice_cast_1 const iceCast1 = overlayMap.get('ice_cast_1') expect(iceCast1).toBeDefined() expect(iceCast1!.filename).toBe('IceCastNew01') expect(iceCast1!.frames).toBe(15) expect(iceCast1!.animRate).toBe(16) expect(iceCast1!.trans).toBe(3) expect(iceCast1!.preDraw).toBe(false) expect(iceCast1!.radius).toBe(9) expect(iceCast1!.red).toBe(81) expect(iceCast1!.green).toBe(81) expect(iceCast1!.blue).toBe(255) // Verify light_cast_1 const lightCast1 = overlayMap.get('light_cast_1') expect(lightCast1).toBeDefined() expect(lightCast1!.filename).toBe('LightningCast') expect(lightCast1!.frames).toBe(10) expect(lightCast1!.animRate).toBe(16) expect(lightCast1!.trans).toBe(3) expect(lightCast1!.preDraw).toBe(false) expect(lightCast1!.radius).toBe(9) expect(lightCast1!.red).toBe(255) expect(lightCast1!.green).toBe(255) expect(lightCast1!.blue).toBe(255) // Verify teleport const teleport = overlayMap.get('teleport') expect(teleport).toBeDefined() expect(teleport!.filename).toBe('Teleport') expect(teleport!.frames).toBe(18) expect(teleport!.animRate).toBe(16) expect(teleport!.trans).toBe(3) expect(teleport!.preDraw).toBe(false) expect(teleport!.radius).toBe(5) expect(teleport!.red).toBe(255) expect(teleport!.green).toBe(255) expect(teleport!.blue).toBe(200) }) it('2. Parses authentic 1.13c Skills.txt and verifies castoverlay for Sorceress skills', () => { expect(skillsTxtContent.length).toBeGreaterThan(100000) const skillsMap = parseSkillsTxt(skillsTxtContent) // Fire Bolt (Id: 36) -> fire_cast_1 const fireBolt = skillsMap.get('36') expect(fireBolt).toBeDefined() expect(fireBolt!.castOverlay).toBe('fire_cast_1') // Charged Bolt (Id: 38) -> light_cast_1 const chargedBolt = skillsMap.get('38') expect(chargedBolt).toBeDefined() expect(chargedBolt!.castOverlay).toBe('light_cast_1') // Ice Bolt (Id: 39) -> ice_cast_1 const iceBolt = skillsMap.get('39') expect(iceBolt).toBeDefined() expect(iceBolt!.castOverlay).toBe('ice_cast_1') // Frost Nova (Id: 44) -> ice_cast_2 const frostNova = skillsMap.get('44') expect(frostNova).toBeDefined() expect(frostNova!.castOverlay).toBe('ice_cast_2') // Fire Ball (Id: 47) -> fire_cast_2 const fireBall = skillsMap.get('47') expect(fireBall).toBeDefined() expect(fireBall!.castOverlay).toBe('fire_cast_2') // Teleport (Id: 54) -> teleport const teleportSkill = skillsMap.get('54') expect(teleportSkill).toBeDefined() expect(teleportSkill!.castOverlay).toBe('teleport') // Meteor (Id: 56) -> fire_cast_2 const meteor = skillsMap.get('56') expect(meteor).toBeDefined() expect(meteor!.castOverlay).toBe('fire_cast_2') // Frozen Orb (Id: 64) -> ice_cast_3 const frozenOrb = skillsMap.get('64') expect(frozenOrb).toBeDefined() expect(frozenOrb!.castOverlay).toBe('ice_cast_3') }) it('3. Complete canonical mapping matches all 24 Sorceress elemental spells', () => { const expectedMappings: Record = { 36: 'fire_cast_1', // Fire Bolt 38: 'light_cast_1', // Charged Bolt 39: 'ice_cast_1', // Ice Bolt 40: 'ice_cast_1', // Frozen Armor 42: 'light_cast_1', // Static Field 43: 'light_cast_2', // Telekinesis 44: 'ice_cast_2', // Frost Nova 45: 'ice_cast_1', // Ice Blast 46: 'fire_cast_2', // Blaze 47: 'fire_cast_2', // Fire Ball 48: 'light_cast_1', // Nova 49: 'light_cast_1', // Lightning 50: 'ice_cast_2', // Shiver Armor 51: 'fire_cast_2', // Fire Wall 53: 'light_cast_1', // Chain Lightning 54: 'teleport', // Teleport 55: 'ice_cast_2', // Glacial Spike 56: 'fire_cast_2', // Meteor 57: 'light_cast_2', // Thunder Storm 58: 'light_cast_2', // Energy Shield 59: 'ice_cast_3', // Blizzard 60: 'ice_cast_3', // Chilling Armor 62: 'fire_cast_2', // Hydra 64: 'ice_cast_3', // Frozen Orb } for (const [idStr, overlay] of Object.entries(expectedMappings)) { const id = parseInt(idStr, 10) expect(SORCERESS_SKILL_CAST_OVERLAYS[id]).toBe(overlay) expect(getSkillCastOverlay(id)).toBe(overlay) expect(getSkillCastOverlay(String(id))).toBe(overlay) } // Verify BATCH1_SKILLS has castOverlay attached expect(BATCH1_SKILLS[36]?.castOverlay).toBe('fire_cast_1') expect(BATCH1_SKILLS[39]?.castOverlay).toBe('ice_cast_1') expect(BATCH1_SKILLS[44]?.castOverlay).toBe('ice_cast_2') expect(BATCH1_SKILLS[47]?.castOverlay).toBe('fire_cast_2') expect(BATCH1_SKILLS[54]?.castOverlay).toBe('teleport') }) it('4. getOverlayTxtData resolves parameters from text, map, or built-in canonical fallback', () => { const fromTxt = getOverlayTxtData('fire_cast_1', overlayTxtContent) expect(fromTxt.frames).toBe(14) expect(fromTxt.animRate).toBe(16) expect(fromTxt.trans).toBe(3) expect(fromTxt.radius).toBe(9) const fromMap = getOverlayTxtData('fire_cast_1', parseOverlayTxt(overlayTxtContent)) expect(fromMap.frames).toBe(14) expect(fromMap.animRate).toBe(16) // Guaranteed zero-runtime-MPQ and missing-file fallback const fallback = getOverlayTxtData('fire_cast_1', undefined) expect(fallback.frames).toBe(14) expect(fallback.animRate).toBe(16) expect(fallback.trans).toBe(3) expect(fallback.red).toBe(255) expect(fallback.green).toBe(178) expect(fallback.blue).toBe(64) }) it('5. ActiveOverlay entity lifecycle: 1.13c frame progression and exact tick-22 expiration', () => { const overlay = createActiveOverlay('fire_cast_1', { x: 500, y: 300 }, 'player') expect(overlay.frame).toBe(0) expect(overlay.maxFrames).toBe(14) expect(overlay.animRate).toBe(16) expect(overlay.preDraw).toBe(false) expect(overlay.trans).toBe(3) expect(overlay.lightRadius).toBe(9) expect(overlay.lightColor).toEqual([255, 178, 64]) expect(overlay.expired).toBe(false) // Simulate 25 FPS engine progression: step = 16 / 25 = 0.64 const step = overlay.animRate / 25 expect(step).toBe(0.64) for (let tick = 1; tick <= 21; tick++) { overlay.frame += step if (overlay.frame >= overlay.maxFrames) { overlay.expired = true } expect(overlay.expired).toBe(false) expect(overlay.frame).toBeCloseTo(tick * 0.64, 4) } // At tick 21: 21 * 0.64 = 13.44 < 14 -> Not expired expect(overlay.frame).toBeCloseTo(13.44, 4) expect(overlay.expired).toBe(false) // At tick 22: 22 * 0.64 = 14.08 >= 14 -> Expired! overlay.frame += step if (overlay.frame >= overlay.maxFrames) { overlay.expired = true } expect(overlay.frame).toBeCloseTo(14.08, 4) expect(overlay.expired).toBe(true) }) it('6. GameEngine spawns overlay on skill cast, tracks player, and advances frames', () => { const terrain = { widthPx: 1000, heightPx: 1000, overlap: () => 0 } const engine = new GameEngine(terrain, { spawn: { x: 200, y: 200 }, stats: [], xpTable: DEMO_EXPERIENCE, skills: DEMO_SKILLS, // index 1 is firebolt (id: 36) npcDefs: [], questDefs: DEMO_QUESTS, combatOptions: { playerSpeed: 4, playerReach: 50, playerCooldownTicks: 10, playerDamage: 5, playerManaPerAttack: 1, respawnTicks: 100, }, talkRadius: 50, pickupRadius: 30, inventoryCols: 10, inventoryRows: 4, monsterCount: 0, }) expect(engine.overlays.length).toBe(0) // Select Fire Bolt (skill index 1, digits: [2]) engine.tick({ movement: { x: 0, y: 0 }, attacking: false, pickingUp: false, talking: false, digits: [2], saving: false, loading: false, }) expect(engine.selectedSkill).toBe(1) expect(engine.opts.skills[engine.selectedSkill]?.id).toBe('firebolt') // Cast Fire Bolt engine.tick({ movement: { x: 0, y: 0 }, attacking: true, pickingUp: false, talking: false, digits: [], saving: false, loading: false, }) expect(engine.overlays.length).toBe(1) const active = engine.overlays[0]! expect(active.overlayName).toBe('fire_cast_1') expect(active.casterId).toBe('player') expect(active.x).toBe(engine.world.player.x) expect(active.y).toBe(engine.world.player.y) expect(active.frame).toBeCloseTo(0.64, 4) // Player moves 10 ticks: overlay must follow player position for (let i = 0; i < 10; i++) { engine.tick({ movement: { x: 1, y: 0 }, attacking: false, pickingUp: false, talking: false, digits: [], saving: false, loading: false, }) expect(engine.overlays[0]!.x).toBe(engine.world.player.x) expect(engine.overlays[0]!.y).toBe(engine.world.player.y) } // Advance until expiration (22 ticks total from cast) // We already ticked 1 (cast tick) + 10 (movement ticks) = 11 ticks. // Need 11 more ticks to reach tick 22. for (let i = 0; i < 11; i++) { engine.tick({ movement: { x: 0, y: 0 }, attacking: false, pickingUp: false, talking: false, digits: [], saving: false, loading: false, }) } // Overlay has expired and was removed expect(engine.overlays.length).toBe(0) }) it('7. SpriteRenderer additive blending mode (Trans === 3) flushes and switches blendFunc', () => { let nextId = 1 let blendSrc = 0 let blendDst = 0 const gl: any = { VERTEX_SHADER: 35633, FRAGMENT_SHADER: 35632, COMPILE_STATUS: 35713, LINK_STATUS: 35714, MAX_TEXTURE_SIZE: 3379, MAX_TEXTURE_IMAGE_UNITS: 34930, TEXTURE_2D: 3553, RGBA: 6408, RGBA8: 32856, UNSIGNED_BYTE: 5121, UNSIGNED_INT: 5125, FLOAT: 5126, ARRAY_BUFFER: 34962, ELEMENT_ARRAY_BUFFER: 34963, DYNAMIC_DRAW: 35048, STATIC_DRAW: 35044, TRIANGLES: 4, TEXTURE_MIN_FILTER: 10241, TEXTURE_MAG_FILTER: 10240, TEXTURE_WRAP_S: 10242, TEXTURE_WRAP_T: 10243, CLAMP_TO_EDGE: 33071, NEAREST: 9728, UNPACK_ALIGNMENT: 3317, BLEND: 3042, SRC_ALPHA: 770, ONE: 1, ONE_MINUS_SRC_ALPHA: 771, DEPTH_TEST: 2929, COLOR_BUFFER_BIT: 16384, TEXTURE0: 33984, drawingBufferWidth: 800, drawingBufferHeight: 600, isContextLost: () => false, createProgram: () => ({ id: nextId++, type: 'program' }), deleteProgram: () => {}, attachShader: () => {}, detachShader: () => {}, linkProgram: () => {}, getProgramParameter: () => true, getProgramInfoLog: () => '', useProgram: () => {}, createShader: (type: number) => ({ id: nextId++, type }), shaderSource: () => {}, compileShader: () => {}, getShaderParameter: () => true, getShaderInfoLog: () => '', deleteShader: () => {}, createVertexArray: () => ({ id: nextId++, type: 'vao' }), deleteVertexArray: () => {}, bindVertexArray: () => {}, createBuffer: () => ({ id: nextId++, type: 'buffer' }), deleteBuffer: () => {}, bindBuffer: () => {}, bufferData: () => {}, bufferSubData: () => {}, getAttribLocation: (_p: any, name: string) => name === 'a_position' ? 0 : name === 'a_uv' ? 1 : name === 'a_tint' ? 2 : name === 'a_unit' ? 3 : 4, enableVertexAttribArray: () => {}, vertexAttribPointer: () => {}, createTexture: () => ({ id: nextId++, type: 'texture' }), deleteTexture: () => {}, bindTexture: () => {}, texParameteri: () => {}, pixelStorei: () => {}, texImage2D: () => {}, getParameter: (param: number) => (param === 3379 ? 4096 : param === 34930 ? 16 : 0), getUniformLocation: (_p: any, name: string) => ({ name }), uniform2f: () => {}, uniform1f: () => {}, uniform1i: () => {}, uniform1iv: () => {}, enable: () => {}, disable: () => {}, blendFunc: (src: number, dst: number) => { blendSrc = src blendDst = dst }, viewport: () => {}, clearColor: () => {}, clear: () => {}, activeTexture: () => {}, drawArrays: () => {}, drawElements: () => {}, } const canvas: any = { width: 800, height: 600, clientWidth: 800, clientHeight: 600, getContext: (type: string) => (type === 'webgl2' ? gl : null), addEventListener: () => {}, removeEventListener: () => {}, } const renderer = new SpriteRenderer(canvas as HTMLCanvasElement) expect(renderer.blendMode).toBe('normal') // Switch to additive renderer.setBlendMode('additive') expect(renderer.blendMode).toBe('additive') expect(blendSrc).toBe(gl.SRC_ALPHA) expect(blendDst).toBe(gl.ONE) // Switch back to normal renderer.setBlendMode('normal') expect(renderer.blendMode).toBe('normal') expect(blendSrc).toBe(gl.SRC_ALPHA) expect(blendDst).toBe(gl.ONE_MINUS_SRC_ALPHA) // begin() resets blend mode to 'normal' renderer.setBlendMode('additive') const camera = { x: 0, y: 0, zoom: 1 } renderer.begin(camera) expect(renderer.blendMode).toBe('normal') }) it('8. drawCastOverlay executes procedural fallback and sprite rendering with anchor offsets', () => { const draws: any[] = [] const solids: any[] = [] let blendMode: string = 'normal' const rendererMock = { blendMode: 'normal', setBlendMode: (mode: 'normal' | 'additive') => { blendMode = mode }, draw: (frame: any, x: number, y: number, options: any) => { draws.push({ frame, x, y, options }) }, drawSolid: (x: number, y: number, w: number, h: number, color: any, options: any) => { solids.push({ x, y, w, h, color, options }) }, } const overlay = createActiveOverlay('fire_cast_1', { x: 300, y: 400 }) // Test procedural fallback (no overlayArt) drawCastOverlay(rendererMock as any, overlay, undefined) expect(solids.length).toBeGreaterThan(0) // Halos drawn with additive blending expect(solids[0]!.options?.blendMode).toBe('additive') // Reset to normal expect(blendMode).toBe('normal') // Test with loaded overlay art draws.length = 0 solids.length = 0 const dummyArt: LoadedOverlayArt = { meta: FIRE_CAST_1_META, handle: 1 as unknown as AtlasHandle, frames: [ FIRE_CAST_1_META.groups[0]!.map(([x, y, width, height, anchorX, anchorY]) => ({ x, y, width, height, anchorX, anchorY, })), ], } drawCastOverlay(rendererMock as any, overlay, dummyArt) expect(draws.length).toBe(1) // Verify no residual translucent solid boxes are drawn when overlay art is rendered expect(solids.length).toBe(0) const drawCall = draws[0]! expect(drawCall.options.blendMode).toBe('additive') // Check anchor alignment: drawX = overlay.x + anchorX, drawY = overlay.y + anchorY const firstFrame = dummyArt.frames[0]![0]! expect(drawCall.x).toBe(overlay.x + firstFrame.anchorX!) expect(drawCall.y).toBe(overlay.y + firstFrame.anchorY!) expect(blendMode).toBe('normal') }) it('9. Verifies offline baked authentic cast overlay assets and metadata', () => { const requiredAssets = [ 'fire_cast_1', 'fire_cast_2', 'ice_cast_1', 'ice_cast_2', 'ice_cast_3', 'light_cast_1', 'light_cast_2', 'teleport', ] for (const name of requiredAssets) { // Check in public/overlays/ const publicPng = path.resolve(__dirname, `../public/overlays/${name}.png`) const publicJson = path.resolve(__dirname, `../public/overlays/${name}.json`) expect(fs.existsSync(publicPng), `Missing ${publicPng}`).toBe(true) expect(fs.existsSync(publicJson), `Missing ${publicJson}`).toBe(true) const meta = JSON.parse(fs.readFileSync(publicJson, 'utf-8')) expect(meta.name).toBe(name) expect(meta.width).toBeGreaterThan(0) expect(meta.height).toBeGreaterThan(0) expect(meta.groups.length).toBe(1) expect(meta.groups[0].length).toBeGreaterThan(0) // Check in samples/d2-packs/overlays/ const packPng = path.resolve(__dirname, `../samples/d2-packs/overlays/${name}.png`) const packJson = path.resolve(__dirname, `../samples/d2-packs/overlays/${name}.json`) expect(fs.existsSync(packPng), `Missing ${packPng}`).toBe(true) expect(fs.existsSync(packJson), `Missing ${packJson}`).toBe(true) // Check in OVERLAY_METAS export expect(OVERLAY_METAS[name]).toBeDefined() } // Verify Teleport meta expect(TELEPORT_META.name).toBe('teleport') expect(TELEPORT_META.celFile).toBe('Teleport') expect(TELEPORT_META.lightColor).toEqual([255, 255, 200]) expect(TELEPORT_META.lightRadius).toBe(5) }) it('10. SpriteRenderer.setBlendMode safely early-returns when disposed (F1)', () => { let blendFuncCalls = 0 const gl: any = { VERTEX_SHADER: 35633, FRAGMENT_SHADER: 35632, COMPILE_STATUS: 35713, LINK_STATUS: 35714, MAX_TEXTURE_SIZE: 3379, MAX_TEXTURE_IMAGE_UNITS: 34930, TEXTURE_2D: 3553, RGBA: 6408, RGBA8: 32856, UNSIGNED_BYTE: 5121, UNSIGNED_INT: 5125, FLOAT: 5126, ARRAY_BUFFER: 34962, ELEMENT_ARRAY_BUFFER: 34963, DYNAMIC_DRAW: 35048, STATIC_DRAW: 35044, TRIANGLES: 4, TEXTURE_MIN_FILTER: 10241, TEXTURE_MAG_FILTER: 10240, TEXTURE_WRAP_S: 10242, TEXTURE_WRAP_T: 10243, CLAMP_TO_EDGE: 33071, NEAREST: 9728, UNPACK_ALIGNMENT: 3317, BLEND: 3042, SRC_ALPHA: 770, ONE: 1, ONE_MINUS_SRC_ALPHA: 771, DEPTH_TEST: 2929, COLOR_BUFFER_BIT: 16384, TEXTURE0: 33984, drawingBufferWidth: 800, drawingBufferHeight: 600, isContextLost: () => false, createProgram: () => ({ id: 1, type: 'program' }), deleteProgram: () => {}, attachShader: () => {}, detachShader: () => {}, linkProgram: () => {}, getProgramParameter: () => true, getProgramInfoLog: () => '', useProgram: () => {}, createShader: (type: number) => ({ id: 1, type }), shaderSource: () => {}, compileShader: () => {}, getShaderParameter: () => true, getShaderInfoLog: () => '', deleteShader: () => {}, createVertexArray: () => ({ id: 1, type: 'vao' }), deleteVertexArray: () => {}, bindVertexArray: () => {}, createBuffer: () => ({ id: 1, type: 'buffer' }), deleteBuffer: () => {}, bindBuffer: () => {}, bufferData: () => {}, bufferSubData: () => {}, getAttribLocation: () => 0, enableVertexAttribArray: () => {}, vertexAttribPointer: () => {}, createTexture: () => ({ id: 1, type: 'texture' }), deleteTexture: () => {}, bindTexture: () => {}, texParameteri: () => {}, pixelStorei: () => {}, texImage2D: () => {}, getParameter: (param: number) => (param === 3379 ? 4096 : param === 34930 ? 16 : 0), getUniformLocation: (_p: any, name: string) => ({ name }), uniform2f: () => {}, uniform1f: () => {}, uniform1i: () => {}, uniform1iv: () => {}, enable: () => {}, disable: () => {}, blendFunc: () => { blendFuncCalls++ }, viewport: () => {}, clearColor: () => {}, clear: () => {}, activeTexture: () => {}, drawArrays: () => {}, drawElements: () => {}, } const canvas: any = { width: 800, height: 600, clientWidth: 800, clientHeight: 600, getContext: (type: string) => (type === 'webgl2' ? gl : null), addEventListener: () => {}, removeEventListener: () => {}, } const renderer = new SpriteRenderer(canvas as HTMLCanvasElement) renderer.setBlendMode('additive') const callsBeforeDispose = blendFuncCalls expect(callsBeforeDispose).toBeGreaterThan(0) // Dispose renderer renderer.dispose() expect(renderer.isDisposed).toBe(true) // Attempting to setBlendMode after dispose must safely return early renderer.setBlendMode('normal') expect(blendFuncCalls).toBe(callsBeforeDispose) }) it('11. loadOverlayArtMap safely returns empty map in headless/Node without createImageBitmap (F2)', async () => { const dummyRenderer: any = {} // In Node.js environment, createImageBitmap is undefined by default expect(typeof createImageBitmap).toBe('undefined') const artMap = await loadOverlayArtMap(dummyRenderer) expect(artMap.size).toBe(0) }) it('12. ice_cast_3 documented and handles 15 (table) vs 16 (DCC) frame discrepancy (F3)', () => { // Overlay.txt table lists 15 frames const overlayMap = parseOverlayTxt(overlayTxtContent) const tableIceCast3 = overlayMap.get('ice_cast_3') expect(tableIceCast3?.frames).toBe(15) // CANONICAL_113C_OVERLAYS uses 16 frames matching DCC asset IceCastNew03.dcc expect(CANONICAL_113C_OVERLAYS['ice_cast_3']?.frames).toBe(16) expect(OVERLAY_METAS['ice_cast_3']?.frames).toBe(16) }) it('13. Rapid consecutive casting replaces active overlay for same casterId to prevent oversaturation (F4)', () => { const terrain = { widthPx: 1000, heightPx: 1000, overlap: () => 0 } const engine = new GameEngine(terrain, { spawn: { x: 200, y: 200 }, stats: [], xpTable: DEMO_EXPERIENCE, skills: DEMO_SKILLS, npcDefs: [], questDefs: DEMO_QUESTS, combatOptions: { playerSpeed: 4, playerReach: 50, playerCooldownTicks: 0, playerDamage: 5, playerManaPerAttack: 1, respawnTicks: 100, }, talkRadius: 50, pickupRadius: 30, inventoryCols: 10, inventoryRows: 4, monsterCount: 0, }) // Spawn initial overlay for player const ov1 = engine.spawnOverlay('fire_cast_1', { x: 200, y: 200 }, 'player') expect(engine.overlays.length).toBe(1) expect(engine.overlays[0]).toBe(ov1) expect(ov1.expired).toBe(false) // Rapid consecutive cast replaces player's active overlay const ov2 = engine.spawnOverlay('fire_cast_2', { x: 200, y: 200 }, 'player') expect(engine.overlays.length).toBe(1) expect(engine.overlays[0]).toBe(ov2) expect(ov1.expired).toBe(true) expect(ov2.expired).toBe(false) // Overlays for different casterId (e.g. minion or monster) coexist independently const monsterOv = engine.spawnOverlay('ice_cast_1', { x: 300, y: 300 }, 'monster_1') expect(engine.overlays.length).toBe(2) expect(engine.overlays).toContain(ov2) expect(engine.overlays).toContain(monsterOv) // A 3rd cast by player replaces ov2, but leaves monsterOv intact const ov3 = engine.spawnOverlay('teleport', { x: 250, y: 250 }, 'player') expect(engine.overlays.length).toBe(2) expect(ov2.expired).toBe(true) expect(monsterOv.expired).toBe(false) expect(engine.overlays).toContain(ov3) expect(engine.overlays).toContain(monsterOv) }) })