/** * Issue #439: Fire Wall (#51) Collision Grid Check & SubLoop Animation * * 1.13c Ground Truth Invariants: * 1. Collision Indexing: `isBlocked` correctly indexes collision cells using `isMissileBlockedAt` * so walkable ground spawns all 15 fire wall patches instead of dropping them all as false-positive walls. * 2. SubLoop Animation: `firewall` (`groundFireBig`, 37 frames) uses authentic SubLoop structure: * - Frames 0..11: emergence flame rise * - Frames 12..36: 25-frame sustain loop * - Extinction (ttl <= 12): reverse frame sequence (11 down to 0) + shrinking scale & alpha fade. * 3. Constant Parity: `skills.ts` canonical missile definition matches Missiles.txt row 69 * (id: 69, celFile: 'groundFireBig', animLen: 37). */ import { describe, expect, it, vi } from 'vitest' import { GameEngine } from '../../../src/server/engine/engine.ts' import { drawMissileProjectile } from '../../../src/client/scene/missile-overlay-renderer.ts' import { castSkill } from '../../../src/server/scene/skill-caster.ts' import type { LoadedMissileArt } from '../../../src/client/scene/missile-overlay-renderer.ts' import type { SpriteRenderer, AtlasHandle } from '../../../src/client/render/renderer.ts' import { CANONICAL_113C_MISSILES, type Projectile } from '../../../src/server/skills/skills.ts' import { FIREWALL_META } from '../../../src/client/render/missiles-meta.ts' import { DEMO_SKILLS } from '../../../src/common/data/demo-data.ts' describe('Issue #439: Fire Wall (#51) Collision Check & SubLoop Animation', () => { it('correctly spawns all 15 fire wall patches on open walkable ground without false obstacle clipping', () => { const engine = new GameEngine( { widthPx: 2000, heightPx: 2000, overlap: () => 0 }, { spawn: { x: 500, y: 500 }, stats: [], questDefs: [], npcDefs: [], skills: DEMO_SKILLS } as any, ) const player = engine.world.player player.x = 500 player.y = 500 player.mana = 500 player.cooldown = 0 // Construct a standard collision grid (all walkable: blocked array all 0s) const cellsX = 20 const cellsY = 20 const subTilesX = cellsX * 5 const subTilesY = cellsY * 5 const mockGrid = { cellsX, cellsY, gridWidth: subTilesX, gridHeight: subTilesY, blocked: new Uint8Array(subTilesX * subTilesY), baseX: 0, baseY: 0, } const mockRuntime = { grid: mockGrid, walkable: { isWalkable: () => true }, } as any const statusEl = { textContent: '' } as HTMLElement // Cast Fire Wall towards (700, 500) const castOk = castSkill(51, 700, 500, { engine, runtime: mockRuntime, hudManager: null, status: statusEl, }) expect(castOk).toBe(true) // Verify all 15 patches were successfully spawned const fireWallPatches = engine.projectiles.filter(p => p.missileType === 'firewall') expect(fireWallPatches).toHaveLength(15) // Verify patch attributes for (const patch of fireWallPatches) { expect(patch.ttl).toBe(90) // 3.60s per Missiles.txt expect(patch.fireDuration).toBe(90) expect(patch.pierce).toBe(true) expect(patch.damage).toBeGreaterThan(0) } }) it('animates firewall with emergence (0..11), sustain loop (12..36), and reverse extinction decay', () => { const drawnCalls: Array<{ frameIdx: number; width?: number; height?: number; tint?: readonly number[] }> = [] const mockRenderer = { draw(frame: any, _x: number, _y: number, options: any = {}) { drawnCalls.push({ frameIdx: frame.frameIdx, width: options.width, height: options.height, tint: options.tint, }) }, drawSolid: vi.fn(), } as unknown as SpriteRenderer const makeIndexedArt = (meta: any): LoadedMissileArt => ({ meta, handle: 1 as unknown as AtlasHandle, frames: [ meta.groups[0].map(([x, y, w, h, ax, ay]: number[], idx: number) => ({ x, y, width: w, height: h, anchorX: ax, anchorY: ay, frameIdx: idx, })), ], }) const firewallArt = makeIndexedArt(FIREWALL_META) // 1. Emergence: frames 0..11 for (let elapsed = 0; elapsed < 12; elapsed++) { drawnCalls.length = 0 const shot: Projectile = { skillId: '51', missileType: 'firewall', x: 100, y: 100, vx: 0, vy: 0, damage: 10, ttl: 90 - elapsed, ageTicks: elapsed, fromPlayer: true, fireDuration: 90, } drawMissileProjectile(mockRenderer, shot, firewallArt) expect(drawnCalls[0]!.frameIdx).toBe(elapsed) } // 2. Sustain Loop: frames 12..36 for (let elapsed = 12; elapsed < 50; elapsed++) { drawnCalls.length = 0 const shot: Projectile = { skillId: '51', missileType: 'firewall', x: 100, y: 100, vx: 0, vy: 0, damage: 10, ttl: 90 - elapsed, ageTicks: elapsed, fromPlayer: true, fireDuration: 90, } drawMissileProjectile(mockRenderer, shot, firewallArt) const expectedFrame = 12 + ((elapsed - 12) % 25) expect(drawnCalls[0]!.frameIdx).toBe(expectedFrame) expect(drawnCalls[0]!.frameIdx).toBeGreaterThanOrEqual(12) expect(drawnCalls[0]!.frameIdx).toBeLessThanOrEqual(36) } // 3. Extinction Phase: ttl <= 12 for (let ttl = 12; ttl >= 1; ttl--) { drawnCalls.length = 0 const shot: Projectile = { skillId: '51', missileType: 'firewall', x: 100, y: 100, vx: 0, vy: 0, damage: 10, ttl, ageTicks: 90 - ttl, fromPlayer: true, fireDuration: 90, } drawMissileProjectile(mockRenderer, shot, firewallArt) const call = drawnCalls[0]! const expectedFrame = Math.max(0, Math.min(11, ttl - 1)) expect(call.frameIdx).toBe(expectedFrame) expect(call.width).toBeDefined() expect(call.tint).toBeDefined() } }) it('aligns canonical CANONICAL_113C_MISSILES.firewall with 1.13c Missiles.txt row 69', () => { const fw = CANONICAL_113C_MISSILES['firewall'] expect(fw).toBeDefined() expect(fw!.id).toBe(69) expect(fw!.celFile).toBe('groundFireBig') expect(fw!.animLen).toBe(37) expect(fw!.range).toBe(90) }) })