/** * Issue #434: Lightning (#49) & Chain Lightning (#53) Continuous Beam & Arc Visuals * * 1.13c Ground Truth Invariants Verification Suite: * 1. Sub-Segment Sampling: 3 slices generated per 30px travel step (CltParam1 = 3). * 2. Perpendicular Lateral Jitter: Jitter vector strictly orthogonal to flight direction, * bounded within [-8, +8] px (CltParam2 = 16). * 3. Frame Randomization & Looping: Initial frame in 0..7, loops frames 1..7 (SubLoop = 1, SubStop = 7). * 4. 12-Tick Lingering Lifecycle & Head Decoupling: Slices live for 12 ticks (480ms) and persist * independently when the projectile head terminates on a wall or enemy. * 5. Chain Lightning Connected Arc Network: Multi-hop jumps leave all inter-target arc segments * concurrently active in the trail manager. * 6. Additive Blending & Electric Procedural Fallback: Draws with blendMode: 'additive' and * authentic electric blue/cyan/white palette. */ import { describe, expect, it } from 'vitest' import { LightningTrailManager, drawElectricBoltFallback, type LightningTrailSlice, type LoadedMissileArt, } from '../../../src/client/render/missile-renderer.ts' import { CANONICAL_113C_MISSILES, getMissileTxtData, tickProjectiles, type Projectile, } from '../../../src/server/skills/skills.ts' import { LIGHTNINGBOLT_META, LIGHTNING_META, LIGHTNINGHIT_META, CHAINLIGHTNING_META, LIGHTNINGSTRIKE_META, MISSILE_METAS, } from '../../../src/client/render/missiles-meta.ts' describe('Issue #434: Lightning (#49) & Chain Lightning (#53) Continuous Beam & Arc Visuals', () => { // ========================================================================= // Dimension 1: Canonical 1.13c Registries & Asset Metadata // ========================================================================= describe('Dimension 1: Canonical 1.13c Registries & Asset Metadata', () => { it('verifies lightninghit (ID 99) registration in CANONICAL_113C_MISSILES', () => { const hit = CANONICAL_113C_MISSILES['lightninghit'] expect(hit).toBeDefined() expect(hit!.id).toBe(99) expect(hit!.celFile).toBe('LightningStrike') expect(hit!.range).toBe(12) // Lingers for 12 ticks (480ms) expect(hit!.animLen).toBe(8) expect(hit!.numDirections).toBe(32) expect(hit!.trans).toBe(1) // Additive blending expect(hit!.subLoop).toBe(1) expect(hit!.subStop).toBe(7) }) it('verifies LIGHTNINGBOLT_META, LIGHTNING_META, and LIGHTNINGHIT_META in MISSILE_METAS', () => { expect(LIGHTNINGBOLT_META.name).toBe('lightningbolt') expect(LIGHTNINGBOLT_META.directions).toBe(32) expect(LIGHTNING_META.name).toBe('lightning') expect(LIGHTNINGHIT_META.name).toBe('lightninghit') expect(MISSILE_METAS['lightningbolt']).toBe(LIGHTNINGBOLT_META) expect(MISSILE_METAS['lightning']).toBe(LIGHTNING_META) expect(MISSILE_METAS['lightninghit']).toBe(LIGHTNINGHIT_META) expect(MISSILE_METAS['chainlightning']).toBe(CHAINLIGHTNING_META) }) it('verifies lightninghit is recognized as a harmless visual emitter', () => { // Create a dummy lightninghit projectile at the location of an alive target const target = { index: 0, x: 100, y: 100, radius: 20, alive: true } const projectile: Projectile = { skillId: '49', x: 100, y: 100, vx: 0, vy: 0, damage: 999, ttl: 12, fromPlayer: true, missileType: 'lightninghit', } const outcome = tickProjectiles([projectile], [target], { overlap: () => 0, }) // Must deal 0 damage and produce 0 hits because it is a harmless visual slice expect(outcome.hits.length).toBe(0) expect(outcome.alive.length).toBe(1) expect(outcome.alive[0]!.ttl).toBe(11) }) }) // ========================================================================= // Dimension 2: Sub-Segment Sampling & Kinematics // ========================================================================= describe('Dimension 2: Sub-Segment Sampling & Kinematics', () => { it('generates exactly 3 sub-segment slices per 30px projectile step (CltParam1 = 3)', () => { const manager = new LightningTrailManager() expect(manager.activeCount).toBe(0) // Step 30px along X axis: from (100, 100) to (130, 100) manager.stepMissile('lightningbolt', 100, 100, 130, 100) expect(manager.activeCount).toBe(3) const slices = manager.getSlices() // Sub-segments spaced 10px apart: t = 0, 1/3, 2/3 -> x = 100, 110, 120 expect(slices[0]!.x).toBeCloseTo(100, 4) expect(slices[0]!.y).toBeCloseTo(100, 4) expect(slices[1]!.x).toBeCloseTo(110, 4) expect(slices[1]!.y).toBeCloseTo(100, 4) expect(slices[2]!.x).toBeCloseTo(120, 4) expect(slices[2]!.y).toBeCloseTo(100, 4) }) it('ignores zero-distance stationary steps', () => { const manager = new LightningTrailManager() manager.stepMissile('lightningbolt', 200, 200, 200, 200) expect(manager.activeCount).toBe(0) }) it('aligns DCC direction with 32-direction discrete grid', () => { const manager = new LightningTrailManager() // Moving due East (+X, 0 Y) manager.stepMissile('lightningbolt', 0, 0, 30, 0) const slices = manager.getSlices() for (const slice of slices) { expect(slice.dccDir).toBeGreaterThanOrEqual(0) expect(slice.dccDir).toBeLessThan(32) } }) }) // ========================================================================= // Dimension 3: Perpendicular Lateral Jitter & Frame Cycling // ========================================================================= describe('Dimension 3: Perpendicular Lateral Jitter & Frame Cycling', () => { it('enforces perpendicular lateral jitter strictly within [-8, +8] px (CltParam2 = 16)', () => { // Use deterministic PRNG let seed = 42 const pseudoRng = () => { seed = (seed * 16807) % 2147483647 return (seed - 1) / 2147483646 } const manager = new LightningTrailManager({ rng: pseudoRng }) // Diagonal travel: dx = 21.21, dy = 21.21 (speed ~30) const startX = 50 const startY = 50 const endX = 50 + 30 * Math.SQRT1_2 const endY = 50 + 30 * Math.SQRT1_2 const dirX = (endX - startX) / 30 const dirY = (endY - startY) / 30 for (let step = 0; step < 20; step++) { manager.stepMissile('lightningbolt', startX, startY, endX, endY) } const slices = manager.getSlices() expect(slices.length).toBe(60) for (const slice of slices) { // Dot product of flight direction with jitter vector must be 0 (orthogonal) const dot = dirX * slice.jitterX + dirY * slice.jitterY expect(dot).toBeCloseTo(0, 4) // Jitter magnitude must be <= 8.0 px const jitterDist = Math.hypot(slice.jitterX, slice.jitterY) expect(jitterDist).toBeLessThanOrEqual(8.0001) } }) it('randomizes initial animation frame in 0..7 and loops through 1..7 (SubLoop = 1, SubStop = 7)', () => { const initialFrames = new Set() for (let i = 0; i < 50; i++) { const mgr = new LightningTrailManager() mgr.stepMissile('lightningbolt', 0, 0, 30, 0) for (const s of mgr.getSlices()) { expect(s.frameIndex).toBeGreaterThanOrEqual(0) expect(s.frameIndex).toBeLessThan(8) initialFrames.add(s.frameIndex) } } // Over 150 sampled slices, all frames 0..7 should appear expect(initialFrames.size).toBeGreaterThanOrEqual(6) // Test looping behavior: frame 0 -> 1 -> 2 -> ... -> 7 -> 1 -> 2 const mgr = new LightningTrailManager({ rng: () => 0.0 }) // frameIndex starts at 0 mgr.stepMissile('lightningbolt', 0, 0, 30, 0) const slice = mgr.getSlices()[0]! expect(slice.frameIndex).toBe(0) const expectedFrames = [1, 2, 3, 4, 5, 6, 7, 1, 2, 3, 4] for (const expected of expectedFrames) { mgr.tick() expect(slice.frameIndex).toBe(expected) } }) }) // ========================================================================= // Dimension 4: 12-Tick Lingering Lifetime & Head Decoupling // ========================================================================= describe('Dimension 4: 12-Tick Lingering Lifetime & Head Decoupling', () => { it('persists slices for exactly 12 ticks (480ms) and cleans up on tick 12', () => { const manager = new LightningTrailManager() manager.stepMissile('lightningbolt', 0, 0, 30, 0) expect(manager.activeCount).toBe(3) for (let tick = 1; tick <= 11; tick++) { manager.tick() expect(manager.activeCount).toBe(3) expect(manager.getSlices()[0]!.ttl).toBe(12 - tick) } // On tick 12, TTL reaches 0 and slices expire manager.tick() expect(manager.activeCount).toBe(0) }) it('persists slices independently when the projectile head terminates upon wall hit', () => { const manager = new LightningTrailManager() // Tick 1: Projectile advances from (0, 0) to (30, 0) manager.stepMissile('lightningbolt', 0, 0, 30, 0) expect(manager.activeCount).toBe(3) // Tick 2: Projectile advances from (30, 0) to (60, 0) manager.stepMissile('lightningbolt', 30, 0, 60, 0) expect(manager.activeCount).toBe(6) // Projectile now hits a wall and is deleted from game engine! // But the trail slices MUST remain alive in LightningTrailManager! manager.tick() expect(manager.activeCount).toBe(6) // Run 5 more ticks for (let t = 0; t < 5; t++) { manager.tick() } expect(manager.activeCount).toBe(6) }) }) // ========================================================================= // Dimension 5: Chain Lightning Multi-Target Connected Arc Network // ========================================================================= describe('Dimension 5: Chain Lightning Multi-Target Connected Arc Network', () => { it('maintains continuous connected arcs across multi-target chain hops', () => { const manager = new LightningTrailManager() // Hop 1: Caster (0, 0) -> Target A (90, 0) over 3 ticks (30px/tick) manager.stepMissile('chainlightning', 0, 0, 30, 0) manager.tick() manager.stepMissile('chainlightning', 30, 0, 60, 0) manager.tick() manager.stepMissile('chainlightning', 60, 0, 90, 0) manager.tick() // Hop 1 has deposited 9 slices along Caster -> Target A expect(manager.activeCount).toBe(9) // Hop 1 strikes Target A and terminates! // Hop 2 leaps from Target A (90, 0) -> Target B (90, 90) over 3 ticks manager.stepMissile('chainlightning', 90, 0, 90, 30) manager.tick() manager.stepMissile('chainlightning', 90, 30, 90, 60) manager.tick() manager.stepMissile('chainlightning', 90, 60, 90, 90) // Slices from BOTH Hop 1 and Hop 2 coexist concurrently! // Total slices = 9 from Hop 1 (still within 12 ticks) + 9 from Hop 2 = 18 slices! expect(manager.activeCount).toBe(18) // Verify spatial continuity: slices span both segments const slices = manager.getSlices() const hasSegment1 = slices.some(s => s.x < 80 && Math.abs(s.y) < 15) const hasSegment2 = slices.some(s => Math.abs(s.x - 90) < 15 && s.y > 20) expect(hasSegment1).toBe(true) expect(hasSegment2).toBe(true) }) }) // ========================================================================= // Dimension 6: Additive Blending & Procedural Fallback Palette // ========================================================================= describe('Dimension 6: Additive Blending & Procedural Fallback Palette', () => { it('renders with blendMode: additive when missileArt is provided', () => { const manager = new LightningTrailManager() manager.stepMissile('lightningbolt', 0, 0, 30, 0) const drawCalls: any[] = [] const mockRenderer = { drawSolid: () => {}, draw: (frame: any, x: number, y: number, options: any) => { drawCalls.push({ frame, x, y, options }) }, } const mockArt: LoadedMissileArt = { meta: LIGHTNINGSTRIKE_META, handle: { texture: {} } as any, frames: Array.from({ length: 32 }, () => [ { x: 0, y: 0, width: 112, height: 79, anchorX: -56, anchorY: -40 }, ]), } const artMap = new Map([['lightningstrike', mockArt]]) manager.render(mockRenderer as any, artMap) expect(drawCalls.length).toBe(3) for (const call of drawCalls) { expect(call.options.blendMode).toBe('additive') expect(call.options.atlas).toBe(mockArt.handle) } }) it('renders electric blue, cyan, and white glowing quads when art is absent', () => { const manager = new LightningTrailManager() manager.stepMissile('lightningbolt', 100, 100, 130, 100) const solidCalls: { x: number; y: number; w: number; h: number; color: any }[] = [] const mockRenderer = { drawSolid: (x: number, y: number, w: number, h: number, color: any) => { solidCalls.push({ x, y, w, h, color }) }, } manager.render(mockRenderer as any, undefined) // 3 slices * 3 layers (blue aura, cyan bolt, white core) = 9 solid draws expect(solidCalls.length).toBe(9) // First slice layers: // Outer electric blue aura: [0.2, 0.5, 1.0, ...] expect(solidCalls[0]!.color[0]).toBeCloseTo(0.2, 2) expect(solidCalls[0]!.color[1]).toBeCloseTo(0.5, 2) expect(solidCalls[0]!.color[2]).toBeCloseTo(1.0, 2) // Cyan mid bolt: [0.4, 0.9, 1.0, ...] expect(solidCalls[1]!.color[0]).toBeCloseTo(0.4, 2) expect(solidCalls[1]!.color[1]).toBeCloseTo(0.9, 2) expect(solidCalls[1]!.color[2]).toBeCloseTo(1.0, 2) // Incandescent white hot core: [1.0, 1.0, 1.0, ...] expect(solidCalls[2]!.color[0]).toBeCloseTo(1.0, 2) expect(solidCalls[2]!.color[1]).toBeCloseTo(1.0, 2) expect(solidCalls[2]!.color[2]).toBeCloseTo(1.0, 2) }) it('drawElectricBoltFallback draws electric palette without orange fireball colors', () => { const solidCalls: any[] = [] const mockRenderer = { drawSolid: (x: number, y: number, w: number, h: number, color: any) => { solidCalls.push({ x, y, w, h, color }) }, } drawElectricBoltFallback(mockRenderer, 200, 200) expect(solidCalls.length).toBe(3) // Must not use reddish orange colors for (const call of solidCalls) { expect(call.color[2]).toBeGreaterThanOrEqual(call.color[0]) // Blue >= Red } }) }) })