diablo2-web/tests/skills/sor/issue-434-lightning-trail.t...

368 lines
14 KiB
TypeScript

/**
* 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/render/missile-renderer.ts'
import {
CANONICAL_113C_MISSILES,
getMissileTxtData,
tickProjectiles,
type Projectile,
} from '../../../src/game/skills.ts'
import {
LIGHTNINGBOLT_META,
LIGHTNING_META,
LIGHTNINGHIT_META,
CHAINLIGHTNING_META,
LIGHTNINGSTRIKE_META,
MISSILE_METAS,
} from '../../../src/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<number>()
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<string, LoadedMissileArt>([['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
}
})
})
})