/** * Diablo II: Lord of Destruction v1.13c — Comprehensive Missile Skills Test Suite * * Covers all missile skills across the 4 tiers: * - Tier 1 (1:1 Direct Projectile): Fire Ball, Ice Bolt, Ice Blast, Magic Arrow, Fire Arrow, Cold Arrow, Ice Arrow * - Tier 2 (Multi-Projectile Fan / Ring): Teeth, Multiple Shot, Poison Nova * - Tier 3 (Kinematics: Pierce, Homing, Spiral, Jitter): Bone Spear, Blessed Hammer, Bone Spirit, Charged Bolt * - Tier 4 (AoE Splash & Entity Filters): Glacial Spike, Exploding Arrow, Holy Bolt * - Asset & WebGL Rendering: 22 baked DCC missile & explosion atlases, 32-dir DCC lookup, anchor offset rendering */ import { describe, expect, it } from 'vitest' import fs from 'node:fs' import path from 'node:path' import { inflateSync } from 'node:zlib' import { parseMissilesTxt, getMissileTxtData, CANONICAL_113C_MISSILES, tickProjectiles, type Projectile, } from '../src/game/skills.ts' import { getSkillRange, getSkillMana, castSkill, drawMissileProjectile, drawExplosion, type LoadedMissileArt, } 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 { MISSILE_METAS, velocityToDccDirection } from '../src/render/missiles-meta.ts' const DUMMY_INPUT = { movement: { x: 0, y: 0 }, attacking: false, pickingUp: false, talking: false, digits: [], saving: false, loading: false, } function createTestEngine(monsters?: { x: number; y: number; hp?: number; isUndead?: boolean; name?: string }[]) { const engine = new GameEngine( { widthPx: 1000, heightPx: 1000, overlap: () => 0, }, { spawn: { x: 100, y: 100 }, stats: [], xpTable: DEMO_EXPERIENCE, skills: DEMO_SKILLS, 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, }, ) if (monsters) { engine.world.monsters.length = 0 for (let i = 0; i < monsters.length; i++) { const m = monsters[i]! const isUndead = m.isUndead ?? false engine.world.monsters.push({ index: i, stats: { id: isUndead ? 'skeleton' : 'fallen', name: m.name ?? (isUndead ? 'Skeleton' : 'Fallen'), hp: m.hp ?? 100, damage: 5, cooldownTicks: 25, reach: 25, aggroRadius: 200, speed: 100, xp: 10, }, x: m.x, y: m.y, hp: m.hp ?? 100, cooldown: 0, state: 'idle', facing: 0, hitFlash: 0, corpseTicks: 0, ...(isUndead ? { isUndead: true } : {}), }) } } return engine } describe('Diablo II 1.13c Missile Skills Suite', () => { const missilesTxtPath = path.resolve(__dirname, '../samples/fixtures/data/global/excel/Missiles.txt') const missilesTxtContent = fs.readFileSync(missilesTxtPath, 'latin1') const parsedMissiles = parseMissilesTxt(missilesTxtContent) // ------------------------------------------------------------- // TIER 1: Direct 1:1 Projectiles // ------------------------------------------------------------- describe('Tier 1: Direct 1:1 Projectiles', () => { it('Fire Ball (47): authentic physics, fireball missileType, 48px AoE splash', () => { const fbData = getMissileTxtData('fireball', parsedMissiles) expect(fbData.vel).toBe(20) expect(fbData.range).toBe(50) expect(fbData.speedPxPerSec).toBe(500) expect(fbData.distancePx).toBe(1000) expect(fbData.explosionMissile).toMatch(/fireexplode|explodingarrowexp/) const engine = createTestEngine([ { x: 150, y: 100, hp: 100 }, // direct target { x: 170, y: 100, hp: 100 }, // splash target within 48px { x: 300, y: 100, hp: 100 }, // out of splash radius ]) const status = { textContent: '' } as HTMLElement castSkill(47, 200, 100, { engine, runtime: {} as any, hudManager: null, status, }) expect(engine.projectiles.length).toBe(1) const proj = engine.projectiles[0]! expect(proj.missileType).toBe('fireball') expect(proj.aoeRadius).toBe(48) expect(proj.damage).toBe(35) // Advance until impact for (let t = 0; t < 5; t++) { engine.tick(DUMMY_INPUT) } // Direct target hit, splash target also damaged, distant target untouched expect(engine.world.monsters[0]!.hp).toBeLessThan(100) expect(engine.world.monsters[1]!.hp).toBeLessThan(100) expect(engine.world.monsters[2]!.hp).toBe(100) // Active explosion spawned with fireexplode or explodingarrowexp expect(engine.explosions.length).toBeGreaterThan(0) expect(['fireexplode', 'explodingarrowexp']).toContain(engine.explosions[0]!.missileType) }) it('Ice Bolt (39): chill status slows monster speed by 50%', () => { const engine = createTestEngine([{ x: 140, y: 100, hp: 100 }]) const status = { textContent: '' } as HTMLElement castSkill(39, 200, 100, { engine, runtime: {} as any, hudManager: null, status, }) const proj = engine.projectiles[0]! expect(proj.missileType).toBe('icebolt') expect(proj.statusEffect).toBe('chill') for (let t = 0; t < 5; t++) { engine.tick(DUMMY_INPUT) } const target = engine.world.monsters[0]! expect(target.chillTicks).toBeGreaterThan(0) }) it('Ice Blast (45): freeze status incapacitates monster to idle', () => { const engine = createTestEngine([{ x: 140, y: 100, hp: 100 }]) const status = { textContent: '' } as HTMLElement castSkill(45, 200, 100, { engine, runtime: {} as any, hudManager: null, status, }) const proj = engine.projectiles[0]! expect(proj.missileType).toBe('iceblast') expect(proj.statusEffect).toBe('freeze') for (let t = 0; t < 5; t++) { engine.tick(DUMMY_INPUT) } const target = engine.world.monsters[0]! expect(target.frozenTicks).toBeGreaterThan(0) expect(target.state).toBe('idle') }) it('Amazon Elemental Arrows: Magic Arrow, Fire Arrow, Cold Arrow, Ice Arrow', () => { const engine = createTestEngine() const status = { textContent: '' } as HTMLElement // Magic Arrow (6) castSkill(6, 200, 100, { engine, runtime: {} as any, hudManager: null, status }) expect(engine.projectiles[0]!.missileType).toBe('magicarrow') engine.projectiles.length = 0 engine.world.player.cooldown = 0 // Fire Arrow (7) castSkill(7, 200, 100, { engine, runtime: {} as any, hudManager: null, status }) expect(engine.projectiles[0]!.missileType).toBe('firearrow') engine.projectiles.length = 0 engine.world.player.cooldown = 0 // Cold Arrow (11) castSkill(11, 200, 100, { engine, runtime: {} as any, hudManager: null, status }) expect(engine.projectiles[0]!.missileType).toBe('icearrow') expect(engine.projectiles[0]!.statusEffect).toBe('chill') engine.projectiles.length = 0 engine.world.player.cooldown = 0 // Ice Arrow (21) castSkill(21, 200, 100, { engine, runtime: {} as any, hudManager: null, status }) expect(engine.projectiles[0]!.missileType).toBe('icearrow') expect(engine.projectiles[0]!.statusEffect).toBe('freeze') engine.projectiles.length = 0 }) }) // ------------------------------------------------------------- // TIER 2: Multi-Projectile Spread (Fan & 360 Nova) // ------------------------------------------------------------- describe('Tier 2: Multi-Projectile Spread', () => { it('Teeth (67): spawns 3 teeth missiles across fan arc', () => { const engine = createTestEngine() const status = { textContent: '' } as HTMLElement castSkill(67, 200, 100, { engine, runtime: {} as any, hudManager: null, status }) expect(engine.projectiles.length).toBe(3) for (const p of engine.projectiles) { expect(p.missileType).toBe('teeth') } // Check angles diverge const angles = engine.projectiles.map(p => Math.atan2(p.vy, p.vx)) expect(angles[0]).toBeLessThan(angles[1]!) expect(angles[1]).toBeLessThan(angles[2]!) }) it('Multiple Shot (12): spawns 5 arrows in fan spread', () => { const engine = createTestEngine() const status = { textContent: '' } as HTMLElement castSkill(12, 200, 100, { engine, runtime: {} as any, hudManager: null, status }) expect(engine.projectiles.length).toBe(5) for (const p of engine.projectiles) { expect(p.missileType).toBe('arrow') } }) it('Poison Nova (92): spawns 32 missiles in 360-degree ring with poison status', () => { const engine = createTestEngine([{ x: 100, y: 150, hp: 100 }]) const status = { textContent: '' } as HTMLElement castSkill(92, 100, 100, { engine, runtime: {} as any, hudManager: null, status }) expect(engine.projectiles.length).toBe(32) for (const p of engine.projectiles) { expect(p.missileType).toBe('poisonnova') expect(p.statusEffect).toBe('poison') // In 2:1 isometric projection, vy is scaled by 0.5 compared to vx const norm = Math.pow(p.vx, 2) + Math.pow(p.vy / 0.5, 2) expect(Math.sqrt(norm)).toBeCloseTo(Math.hypot(p.vx, p.vy / 0.5), 1) } // Tick until nova strikes target for (let t = 0; t < 6; t++) { engine.tick(DUMMY_INPUT) } const target = engine.world.monsters[0]! expect(target.poisonTicks).toBeGreaterThan(0) }) }) // ------------------------------------------------------------- // TIER 3: Kinematic Variations (Pierce, Spiral, Homing, Jitter) // ------------------------------------------------------------- describe('Tier 3: Kinematic Variations', () => { it('Bone Spear (84): pierces through multiple aligned monsters', () => { const engine = createTestEngine([ { x: 140, y: 100, hp: 100 }, { x: 180, y: 100, hp: 100 }, { x: 220, y: 100, hp: 100 }, ]) const status = { textContent: '' } as HTMLElement castSkill(84, 300, 100, { engine, runtime: {} as any, hudManager: null, status }) expect(engine.projectiles[0]!.pierce).toBe(true) // Advance through all 3 monsters for (let t = 0; t < 10; t++) { engine.tick(DUMMY_INPUT) } expect(engine.world.monsters[0]!.hp).toBeLessThan(100) expect(engine.world.monsters[1]!.hp).toBeLessThan(100) expect(engine.world.monsters[2]!.hp).toBeLessThan(100) }) it('Blessed Hammer (112): moves along Archimedean spiral trajectory on 2:1 isometric ground plane', () => { const engine = createTestEngine() const status = { textContent: '' } as HTMLElement castSkill(112, 100, 200, { engine, runtime: {} as any, hudManager: null, status }) const hammer = engine.projectiles[0]! expect(hammer.missileType).toBe('blessedhammer') expect(hammer.spiral).toBeDefined() expect(hammer.pierce).toBe(true) const startDist = Math.hypot(hammer.x - hammer.spiral!.originX, (hammer.y - hammer.spiral!.originY) / 0.5) // Tick 5 times for (let t = 0; t < 5; t++) { engine.tick(DUMMY_INPUT) } const currentHammer = engine.projectiles[0]! expect(currentHammer).toBeDefined() const currentDist = Math.hypot(currentHammer.x - currentHammer.spiral!.originX, (currentHammer.y - currentHammer.spiral!.originY) / 0.5) // Archimedean spiral radius expands outward over time expect(currentDist).toBeGreaterThan(startDist) // Verify that coordinates lie on the 2:1 isometric ground ellipse const radX = currentHammer.spiral!.radius const radY = radX * 0.5 const ellipseRatio = Math.pow((currentHammer.x - currentHammer.spiral!.originX) / radX, 2) + Math.pow((currentHammer.y - currentHammer.spiral!.originY) / radY, 2) expect(ellipseRatio).toBeCloseTo(1.0, 1) }) it('Bone Spirit (93): homes in on nearest living enemy', () => { const engine = createTestEngine([{ x: 200, y: 160, hp: 100 }]) const status = { textContent: '' } as HTMLElement // Cast towards (200, 100), but target is at (200, 160) castSkill(93, 200, 100, { engine, runtime: {} as any, hudManager: null, status }) const spirit = engine.projectiles[0]! expect(spirit.missileType).toBe('bonespirit') expect(spirit.homing).toBe(true) // Track trajectory turning towards monster at y = 160 for (let t = 0; t < 6; t++) { engine.tick(DUMMY_INPUT) } const currentSpirit = engine.projectiles[0] // vy should turn positive towards y = 160 if (currentSpirit) { expect(currentSpirit.vy).toBeGreaterThan(0) } }) it('Charged Bolt (38): wobbles with perpendicular sinusoidal jitter', () => { const engine = createTestEngine() const status = { textContent: '' } as HTMLElement castSkill(38, 200, 100, { engine, runtime: {} as any, hudManager: null, status }) expect(engine.projectiles.length).toBe(4) for (const bolt of engine.projectiles) { expect(bolt.missileType).toBe('chargedbolt') expect(bolt.jitter).toBe(true) } }) }) // ------------------------------------------------------------- // TIER 4: AoE Splash & Entity Filters // ------------------------------------------------------------- describe('Tier 4: AoE Splash & Entity Filters', () => { it('Glacial Spike (55): AoE 64px freeze and damage on all monsters in radius', () => { const engine = createTestEngine([ { x: 150, y: 100, hp: 100 }, // direct { x: 175, y: 110, hp: 100 }, // within 64px { x: 300, y: 200, hp: 100 }, // outside ]) const status = { textContent: '' } as HTMLElement castSkill(55, 200, 100, { engine, runtime: {} as any, hudManager: null, status }) for (let t = 0; t < 5; t++) { engine.tick(DUMMY_INPUT) } expect(engine.world.monsters[0]!.frozenTicks).toBeGreaterThan(0) expect(engine.world.monsters[1]!.frozenTicks).toBeGreaterThan(0) expect(engine.world.monsters[2]!.frozenTicks).toBeUndefined() }) it('Holy Bolt (101): only damages Undead monsters', () => { const engine = createTestEngine([ { x: 140, y: 100, hp: 100, isUndead: false, name: 'Fallen' }, // Non-undead Demon ]) const status = { textContent: '' } as HTMLElement castSkill(101, 200, 100, { engine, runtime: {} as any, hudManager: null, status }) for (let t = 0; t < 5; t++) { engine.tick(DUMMY_INPUT) } // Non-undead takes ZERO damage! expect(engine.world.monsters[0]!.hp).toBe(100) // Now with an undead Skeleton const undeadEngine = createTestEngine([ { x: 140, y: 100, hp: 100, isUndead: true, name: 'Skeleton' }, ]) castSkill(101, 200, 100, { engine: undeadEngine, runtime: {} as any, hudManager: null, status }) for (let t = 0; t < 5; t++) { undeadEngine.tick(DUMMY_INPUT) } // Undead takes full Holy Bolt damage! expect(undeadEngine.world.monsters[0]!.hp).toBeLessThan(100) }) it('Lightning Bolt (20): piercing lightning javelin', () => { const engine = createTestEngine([ { x: 140, y: 100, hp: 100 }, { x: 180, y: 100, hp: 100 }, ]) const status = { textContent: '' } as HTMLElement castSkill(20, 200, 100, { engine, runtime: {} as any, hudManager: null, status }) expect(engine.projectiles[0]!.pierce).toBe(true) expect(engine.projectiles[0]!.missileType).toBe('lightningjavelin') for (let t = 0; t < 5; t++) { engine.tick(DUMMY_INPUT) } expect(engine.world.monsters[0]!.hp).toBeLessThan(100) expect(engine.world.monsters[1]!.hp).toBeLessThan(100) }) }) // ------------------------------------------------------------- // ASSET & WEBGL RENDERING PARITY // ------------------------------------------------------------- describe('Missile Assets & WebGL Rendering Parity', () => { it('Verifies all 22 baked missile metas are exported with authentic DCC properties', () => { const expectedMetas = [ 'firebolt', 'fireball', 'fireexplode', 'icebolt', 'iceblast', 'glacialspike', 'iceexplode', 'freezeexplode', 'chargedbolt', 'teeth', 'teethexplode', 'bonespear', 'bonespirit', 'bonespiritexplode', 'poisonnova', 'holybolt', 'blessedhammer', 'magicarrow', 'firearrow', 'icearrow', 'arrow', 'lightningjavelin' ] for (const name of expectedMetas) { const meta = MISSILE_METAS[name] expect(meta, `Meta missing for ${name}`).toBeDefined() expect(meta.width).toBeGreaterThan(0) expect(meta.height).toBeGreaterThan(0) expect(meta.groups.length).toBe(meta.directions) expect(meta.groups[0]!.length).toBe(meta.framesPerDirection) } }) it('32-direction DCC lookups resolve accurately for teeth, arrows, poison nova', () => { // East (vx > 0, vy = 0) const east32 = velocityToDccDirection(1, 0, 32) expect(east32).toBeDefined() // South (vx = 0, vy > 0) const south32 = velocityToDccDirection(0, 1, 32) expect(south32).toBeDefined() // North (vx = 0, vy < 0) const north32 = velocityToDccDirection(0, -1, 32) expect(north32).toBeDefined() // West (vx < 0, vy = 0) const west32 = velocityToDccDirection(-1, 0, 32) expect(west32).toBeDefined() }) it('drawMissileProjectile and drawExplosion render with mock WebGL SpriteRenderer', () => { const draws: { frame: any; x: number; y: number }[] = [] const fakeRenderer = { draw: (frame: any, x: number, y: number) => { draws.push({ frame, x, y }) }, drawSolid: () => {}, } as unknown as SpriteRenderer const dummyArt: LoadedMissileArt = { meta: MISSILE_METAS.bonespear!, handle: 1 as unknown as AtlasHandle, frames: [ [{ x: 0, y: 0, width: 32, height: 32, anchorX: -16, anchorY: -16 }] ], } const shot: Projectile = { skillId: '84', missileType: 'bonespear', x: 100, y: 100, vx: 24, vy: 0, damage: 30, ttl: 40, fromPlayer: true, } drawMissileProjectile(fakeRenderer, shot, dummyArt) expect(draws.length).toBe(1) expect(draws[0]!.x).toBe(100 - 16) // anchor applied! expect(draws[0]!.y).toBe(100 - 16) draws.length = 0 const expArt: LoadedMissileArt = { meta: MISSILE_METAS.fireexplode!, handle: 2 as unknown as AtlasHandle, frames: [ [{ x: 0, y: 0, width: 48, height: 48, anchorX: -24, anchorY: -24 }] ], } drawExplosion(fakeRenderer, { missileType: 'fireexplode', x: 150, y: 150, frame: 0, maxFrames: 12 }, expArt) expect(draws.length).toBe(1) expect(draws[0]!.x).toBe(150 - 24) expect(draws[0]!.y).toBe(150 - 24) }) }) // ------------------------------------------------------------- // TIER 5: Isometric Ground Perspective (2:1 Ratio) & Map Coordinates Parity // ------------------------------------------------------------- describe('Tier 5: Isometric Ground Perspective (2:1 Ratio) & Map Coordinates Parity', () => { it('Blessed Hammer spiral trajectory forms an exact 2:1 ellipse on the ground plane across multiple ticks', () => { const engine = createTestEngine() const status = { textContent: '' } as HTMLElement castSkill(112, 100, 200, { engine, runtime: {} as any, hudManager: null, status }) const hammer = engine.projectiles[0]! const originX = hammer.spiral!.originX const originY = hammer.spiral!.originY for (let t = 0; t < 12; t++) { engine.tick(DUMMY_INPUT) const cur = engine.projectiles[0]! expect(cur).toBeDefined() const rX = cur.spiral!.radius const rY = rX * 0.5 // 2:1 isometric ground foreshortening const ellipseNorm = Math.pow((cur.x - originX) / rX, 2) + Math.pow((cur.y - originY) / rY, 2) expect(ellipseNorm).toBeCloseTo(1.0, 2) // Verify tangential velocity also reflects the 2:1 aspect ratio const speedRatio = Math.abs(cur.vy) / (Math.abs(cur.vx) || 0.001) // vy is scaled by 0.5 relative to horizontal component expect(Math.abs(cur.vy)).toBeLessThanOrEqual(18 * 0.5 + 0.01) } }) it('Poison Nova 32-missile radial ring expands as a 2:1 ground ellipse', () => { const engine = createTestEngine() const status = { textContent: '' } as HTMLElement castSkill(92, 100, 100, { engine, runtime: {} as any, hudManager: null, status }) expect(engine.projectiles.length).toBe(32) for (const p of engine.projectiles) { // Velocities must lie on the ellipse: (vx / speed)^2 + (vy / (0.5 * speed))^2 == 1 const speed = Math.hypot(p.vx, p.vy / 0.5) const vEllipse = Math.pow(p.vx / speed, 2) + Math.pow(p.vy / (speed * 0.5), 2) expect(vEllipse).toBeCloseTo(1.0, 2) } // Advance 5 ticks for (let t = 0; t < 5; t++) { engine.tick(DUMMY_INPUT) } for (const p of engine.projectiles) { const dx = p.x - engine.world.player.x const dy = p.y - engine.world.player.y const groundRadius = Math.hypot(dx, dy / 0.5) const posEllipse = Math.pow(dx / groundRadius, 2) + Math.pow(dy / (groundRadius * 0.5), 2) expect(posEllipse).toBeCloseTo(1.0, 2) } }) it('AoE splash damage evaluates ground distance with 2:1 isometric metric (Math.hypot(dx, dy / 0.5) <= aoeRadius)', () => { // Fireball has aoeRadius = 48 // Origin at (100, 100) // Monster A at (140, 100): dx=40, dy=0 -> groundDist = 40 <= 48 (HIT) // Monster B at (100, 120): dx=0, dy=20 -> groundDist = 20 / 0.5 = 40 <= 48 (HIT) // Monster C at (100, 130): dx=0, dy=30 -> groundDist = 30 / 0.5 = 60 > 48 (MISS, even though Euclidean dy=30 < 48!) const engine = createTestEngine([ { x: 100, y: 100, hp: 100 }, // direct hit target { x: 140, y: 100, hp: 100 }, // A: horizontal within AoE { x: 100, y: 120, hp: 100 }, // B: vertical within isometric AoE (dy=20 -> ground 40) { x: 100, y: 130, hp: 100 }, // C: vertical outside isometric AoE (dy=30 -> ground 60) ]) const status = { textContent: '' } as HTMLElement castSkill(47, 100, 100, { engine, runtime: {} as any, hudManager: null, status }) // Advance until fireball hits for (let t = 0; t < 5; t++) { engine.tick(DUMMY_INPUT) } const monsters = engine.world.monsters expect(monsters[0]!.hp).toBeLessThan(100) // direct target damaged expect(monsters[1]!.hp).toBeLessThan(100) // monster A damaged expect(monsters[2]!.hp).toBeLessThan(100) // monster B damaged (within 2:1 AoE) expect(monsters[3]!.hp).toBe(100) // monster C UNTOUCHED (outside 2:1 ground AoE) }) it('Strictly distinguishes map simulation coordinates from rendered sprite icon anchor offsets', () => { const draws: { frame: any; x: number; y: number }[] = [] const fakeRenderer = { draw: (frame: any, x: number, y: number) => { draws.push({ frame, x, y }) }, } as unknown as SpriteRenderer const dummyArt: LoadedMissileArt = { meta: MISSILE_METAS.blessedhammer!, handle: 1 as unknown as AtlasHandle, frames: [ [{ x: 0, y: 0, width: 32, height: 32, anchorX: -16, anchorY: -20 }] ], } // Map coordinate on the ground plane const mapX = 250 const mapY = 180 const shot: Projectile = { skillId: '112', missileType: 'blessedhammer', x: mapX, y: mapY, vx: 12, vy: 6, // 2:1 velocity damage: 40, ttl: 50, fromPlayer: true, } drawMissileProjectile(fakeRenderer, shot, dummyArt) expect(draws.length).toBe(1) // The projectile coordinate (map coordinate) is unaltered expect(shot.x).toBe(mapX) expect(shot.y).toBe(mapY) // The rendered icon is translated by the DCC frame anchor, NOT stretched expect(draws[0]!.x).toBe(mapX + (-16)) expect(draws[0]!.y).toBe(mapY + (-20)) expect(draws[0]!.frame.width).toBe(32) expect(draws[0]!.frame.height).toBe(32) }) }) describe('Tier 6: Trans = 1 Missile Transparency & Additive Blending (Zero Black Border) Parity', () => { function decodeRgbaPng(filePath: string): { width: number; height: number; pixels: Uint8Array } { const buffer = fs.readFileSync(filePath) const width = buffer.readUInt32BE(16) const height = buffer.readUInt32BE(20) const bitDepth = buffer.readUInt8(24) const colorType = buffer.readUInt8(25) expect(bitDepth).toBe(8) expect(colorType).toBe(6) // Must be 32-bit RGBA let offset = 8 const idatChunks: Buffer[] = [] while (offset < buffer.length) { const len = buffer.readUInt32BE(offset) const type = buffer.subarray(offset + 4, offset + 8).toString('ascii') if (type === 'IDAT') { idatChunks.push(buffer.subarray(offset + 8, offset + 8 + len)) } offset += 12 + len } const decompressed = inflateSync(Buffer.concat(idatChunks)) const stride = 4 const rowBytes = width * stride const pixels = new Uint8Array(width * height * stride) let srcOffset = 0 let dstOffset = 0 let prevRow: Uint8Array | null = null for (let y = 0; y < height; y++) { const filterType = decompressed[srcOffset++]! const currentRow = new Uint8Array(rowBytes) for (let x = 0; x < rowBytes; x++) { const byte = decompressed[srcOffset++]! const left = x >= stride ? currentRow[x - stride]! : 0 const up = prevRow ? prevRow[x]! : 0 let val = byte if (filterType === 0) { val = byte } else if (filterType === 1) { val = (byte + left) & 0xff } else if (filterType === 2) { val = (byte + up) & 0xff } else if (filterType === 3) { val = (byte + Math.floor((left + up) / 2)) & 0xff } else if (filterType === 4) { const upLeft = prevRow && x >= stride ? prevRow[x - stride]! : 0 const p = left + up - upLeft const pa = Math.abs(p - left) const pb = Math.abs(p - up) const pc = Math.abs(p - upLeft) let pr = left if (pb < pa && pb <= pc) pr = up else if (pc < pa) pr = upLeft val = (byte + pr) & 0xff } currentRow[x] = val } pixels.set(currentRow, dstOffset) dstOffset += rowBytes prevRow = currentRow } return { width, height, pixels } } it('All baked Trans=1 magical missiles and explosions have colorType=6 and ZERO opaque black pixels', () => { const magicalMissiles = [ 'firebolt', 'fireball', 'fireexplode', 'icebolt', 'iceblast', 'glacialspike', 'iceexplode', 'freezeexplode', 'chargedbolt', 'teeth', 'teethexplode', 'bonespear', 'bonespirit', 'bonespiritexplode', 'poisonnova', 'holybolt', 'blessedhammer', 'magicarrow', 'firearrow', 'icearrow', 'lightningjavelin', ] for (const name of magicalMissiles) { const pngPath = path.resolve(__dirname, `../public/missiles/${name}.png`) expect(fs.existsSync(pngPath), `Missing ${name}.png in public/missiles`).toBe(true) const { width, height, pixels } = decodeRgbaPng(pngPath) expect(width).toBeGreaterThan(0) expect(height).toBeGreaterThan(0) let blackNonTransparentCount = 0 let visibleCount = 0 for (let i = 0; i < pixels.length; i += 4) { const r = pixels[i]! const g = pixels[i + 1]! const b = pixels[i + 2]! const a = pixels[i + 3]! const maxRgb = Math.max(r, g, b) // Black fringe / dark background (max(r,g,b) <= 4, e.g. palette index 172 (4,4,4) or 0 (0,0,0)) if (maxRgb <= 4 && a > 0) { blackNonTransparentCount++ } if (a > 0 && maxRgb >= 30) { visibleCount++ } } // Strict invariant: 0 opaque black pixels, eliminating any black border or box artifacts expect(blackNonTransparentCount, `${name}.png contains ${blackNonTransparentCount} opaque black pixels!`).toBe(0) expect(visibleCount, `${name}.png must contain visible spell pixels`).toBeGreaterThan(0) } }) it('Physical arrow.png is 32-bit RGBA and retains authentic arrow pixels', () => { const arrowPngPath = path.resolve(__dirname, '../public/missiles/arrow.png') expect(fs.existsSync(arrowPngPath)).toBe(true) const { width, height, pixels } = decodeRgbaPng(arrowPngPath) expect(width).toBeGreaterThan(0) expect(height).toBeGreaterThan(0) let opaqueCount = 0 for (let i = 0; i < pixels.length; i += 4) { const a = pixels[i + 3]! if (a > 200) opaqueCount++ } expect(opaqueCount).toBeGreaterThan(0) }) it('drawMissileProjectile passes blendMode: "additive" for magical missiles and normal for physical arrow', () => { const drawCalls: any[] = [] const mockRenderer = { draw(frame: any, x: number, y: number, options?: any) { drawCalls.push({ frame, x, y, options }) }, } as unknown as SpriteRenderer const dummyFrame = [{ x: 0, y: 0, width: 32, height: 32, anchorX: -16, anchorY: -16 }] // 1. Magical missile (e.g. firebolt) const fireboltArt: LoadedMissileArt = { meta: MISSILE_METAS.firebolt!, handle: 1 as unknown as AtlasHandle, frames: [dummyFrame], } const fireboltShot: Projectile = { skillId: '36', missileType: 'firebolt', x: 100, y: 100, vx: 20, vy: 0, damage: 10, ttl: 50, fromPlayer: true, } drawMissileProjectile(mockRenderer, fireboltShot, fireboltArt) expect(drawCalls.length).toBe(1) expect(drawCalls[0]!.options?.blendMode).toBe('additive') // 2. Physical arrow (arrow) drawCalls.length = 0 const arrowArt: LoadedMissileArt = { meta: MISSILE_METAS.arrow!, handle: 2 as unknown as AtlasHandle, frames: [dummyFrame], } const arrowShot: Projectile = { skillId: 'arrow', missileType: 'arrow', x: 100, y: 100, vx: 20, vy: 0, damage: 10, ttl: 50, fromPlayer: true, } drawMissileProjectile(mockRenderer, arrowShot, arrowArt) expect(drawCalls.length).toBe(1) expect(drawCalls[0]!.options?.blendMode).toBeUndefined() }) it('drawExplosion passes blendMode: "additive" for spell explosions', () => { const drawCalls: any[] = [] const mockRenderer = { draw(frame: any, x: number, y: number, options?: any) { drawCalls.push({ frame, x, y, options }) }, } as unknown as SpriteRenderer const dummyFrame = [{ x: 0, y: 0, width: 48, height: 48, anchorX: -24, anchorY: -24 }] const explodeArt: LoadedMissileArt = { meta: MISSILE_METAS.fireexplode!, handle: 3 as unknown as AtlasHandle, frames: [dummyFrame], } drawExplosion(mockRenderer, { missileType: 'fireexplode', x: 150, y: 150, frame: 2, maxFrames: 12, }, explodeArt) expect(drawCalls.length).toBe(1) expect(drawCalls[0]!.options?.blendMode).toBe('additive') }) it('SpriteRenderer dynamically toggles gl.blendFunc and flushes batches on blendMode transition', () => { let nextId = 1 const blendFuncCalls: Array<[number, number]> = [] const drawElementsCalls: number[] = [] const gl: any = { VERTEX_SHADER: 35633, FRAGMENT_SHADER: 35632, COMPILE_STATUS: 35713, LINK_STATUS: 35714, MAX_TEXTURE_SIZE: 4096, MAX_TEXTURE_IMAGE_UNITS: 16, TEXTURE_2D: 3553, RGBA: 6408, 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, BLEND: 3042, ONE: 1, SRC_ALPHA: 770, ONE_MINUS_SRC_ALPHA: 771, DEPTH_TEST: 2929, COLOR_BUFFER_BIT: 16384, TEXTURE0: 33984, drawingBufferWidth: 800, drawingBufferHeight: 600, isContextLost: () => false, createProgram: () => ({ id: nextId++ }), deleteProgram: () => {}, attachShader: () => {}, linkProgram: () => {}, getProgramParameter: () => true, getProgramInfoLog: () => '', useProgram: () => {}, createShader: () => ({ id: nextId++ }), shaderSource: () => {}, compileShader: () => {}, getShaderParameter: () => true, getShaderInfoLog: () => '', deleteShader: () => {}, createVertexArray: () => ({ id: nextId++ }), bindVertexArray: () => {}, createBuffer: () => ({ id: nextId++ }), bindBuffer: () => {}, bufferData: () => {}, bufferSubData: () => {}, getAttribLocation: () => 0, enableVertexAttribArray: () => {}, vertexAttribPointer: () => {}, createTexture: () => ({ id: nextId++ }), bindTexture: () => {}, texParameteri: () => {}, pixelStorei: () => {}, texImage2D: () => {}, getParameter: (p: number) => (p === 3379 ? 4096 : p === 34930 ? 16 : 0), getUniformLocation: () => ({}), uniform2f: () => {}, uniform1f: () => {}, uniform1i: () => {}, uniform1iv: () => {}, enable: () => {}, disable: () => {}, blendFunc: (s: number, d: number) => { blendFuncCalls.push([s, d]) }, viewport: () => {}, clearColor: () => {}, clear: () => {}, drawElements: (_mode: number, count: number) => { drawElementsCalls.push(count) }, deleteBuffer: () => {}, deleteVertexArray: () => {}, deleteTexture: () => {}, } const canvas: any = { getContext: () => gl, width: 800, height: 600, addEventListener: () => {}, removeEventListener: () => {}, } const renderer = new SpriteRenderer(canvas) // Start frame renderer.begin([0, 0], 1) // gl.blendFunc was set to (SRC_ALPHA, ONE_MINUS_SRC_ALPHA) on initialize expect(blendFuncCalls.length).toBeGreaterThan(0) expect(blendFuncCalls[0]).toEqual([gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA]) const frame = { x: 0, y: 0, width: 32, height: 32 } // 1. Draw normal quad renderer.draw(frame, 10, 10, { blendMode: 'normal' }) // 2. Draw additive quad -> must flush and switch blendFunc to (SRC_ALPHA, ONE) const prevCalls = blendFuncCalls.length renderer.draw(frame, 20, 20, { blendMode: 'additive' }) expect(blendFuncCalls.length).toBe(prevCalls + 1) expect(blendFuncCalls[blendFuncCalls.length - 1]).toEqual([gl.SRC_ALPHA, gl.ONE]) expect(drawElementsCalls.length).toBe(1) // Flushed normal batch (6 indices) // 3. Draw another additive quad -> should batch together without switching blendFunc const callsAfterSecondAdditive = blendFuncCalls.length renderer.draw(frame, 30, 30, { blendMode: 'additive' }) expect(blendFuncCalls.length).toBe(callsAfterSecondAdditive) // 4. Draw normal quad again -> must flush additive batch and switch blendFunc back renderer.draw(frame, 40, 40, { blendMode: 'normal' }) expect(blendFuncCalls[blendFuncCalls.length - 1]).toEqual([gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA]) expect(drawElementsCalls.length).toBe(2) // Flushed additive batch // 5. drawSolid ensures normal blend mode renderer.draw(frame, 50, 50, { blendMode: 'additive' }) expect(blendFuncCalls[blendFuncCalls.length - 1]).toEqual([gl.SRC_ALPHA, gl.ONE]) renderer.drawSolid(0, 0, 10, 10, [1, 0, 0, 1]) expect(blendFuncCalls[blendFuncCalls.length - 1]).toEqual([gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA]) renderer.end() }) }) })