diablo2-web/tests/cast-overlay-113c.test.ts

759 lines
26 KiB
TypeScript

/**
* 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<number, string> = {
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)
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)
})
})