759 lines
26 KiB
TypeScript
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)
|
|
})
|
|
})
|