diff --git a/src/game/engine/missile-engine.ts b/src/game/engine/missile-engine.ts index 177d5c9..a76c48f 100644 --- a/src/game/engine/missile-engine.ts +++ b/src/game/engine/missile-engine.ts @@ -1123,25 +1123,35 @@ export class MissileEngine { const prevX = m.x const prevY = m.y - m.x += m.vx - m.y += m.vy - // Authentic 1.13c Charged Bolt (Skill 38 / missile 56 / pDoFunc 11): // Discrete orthogonal jitter table: 32-entry lookup table gatChargedBoltOffsets // applied perpendicularly to main propagation vector per step (2 subtiles/step) with individual low-seed PRNG. if (m.name === 'chargedbolt' || m.sourceSkillId === 38 || m.record.pSrvDoFunc === 11 || m.record.pCltDoFunc === 11) { - const speed = Math.hypot(m.vx, m.vy) - if (speed > 0) { - const perpX = -m.vy / speed - const perpY = m.vx / speed + let flatVx = m.vx + let flatVy = m.vy / ISO_GROUND_ASPECT_RATIO + const currentSpeed = Math.hypot(flatVx, flatVy) + if (currentSpeed > 0) { + const perpX = -flatVy / currentSpeed + const perpY = flatVx / currentSpeed const idx = Math.abs((m.jitterIndex ?? m.ageTicks) % 32) const offset = GAT_CHARGED_BOLT_OFFSETS[idx]! * 3 - m.x += perpX * offset - m.y += perpY * (offset * ISO_GROUND_ASPECT_RATIO) + flatVx += perpX * offset + flatVy += perpY * offset + const newSpeed = Math.hypot(flatVx, flatVy) + if (newSpeed > 0) { + const scaleDown = currentSpeed / newSpeed + flatVx *= scaleDown + flatVy *= scaleDown + } + m.vx = flatVx + m.vy = flatVy * ISO_GROUND_ASPECT_RATIO m.jitterIndex = (idx + 1) % 32 } } + m.x += m.vx + m.y += m.vy + const effectiveTerrain = collision ?? this.terrain if (effectiveTerrain !== undefined && !isTurretEmitter && (m.altitude === undefined || m.altitude <= 0)) { // Continuous collision stepping between (prevX, prevY) and (m.x, m.y) to prevent tunneling through thin (<24px) walls diff --git a/src/game/skills.ts b/src/game/skills.ts index 8f15fc3..9e0b423 100644 --- a/src/game/skills.ts +++ b/src/game/skills.ts @@ -4876,14 +4876,26 @@ export function tickProjectiles( // 3. Jitter trajectory (e.g. Charged Bolt) - discrete orthogonal jitter lookup table // Authentic D2 1.13c PATH_ComputePathChargedBolt (0x6FD85470): // 32-entry lookup table gatChargedBoltOffsets applied perpendicularly to main propagation vector per step. - const speed = Math.hypot(vx, vy) - if (speed > 0) { - const perpX = -vy / speed - const perpY = vx / speed + let flatVx = vx + let flatVy = vy / ISO_GROUND_ASPECT_RATIO + const currentSpeed = Math.hypot(flatVx, flatVy) + if (currentSpeed > 0) { + const perpX = -flatVy / currentSpeed + const perpY = flatVx / currentSpeed const idx = Math.abs((jitterIndex ?? (projectile.seed !== undefined ? projectile.seed : projectile.ttl)) % 32) const offset = GAT_CHARGED_BOLT_OFFSETS[idx]! * 3 - x += perpX * offset - y += perpY * (offset * ISO_GROUND_ASPECT_RATIO) + flatVx += perpX * offset + flatVy += perpY * offset + const newSpeed = Math.hypot(flatVx, flatVy) + if (newSpeed > 0) { + const scaleDown = currentSpeed / newSpeed + flatVx *= scaleDown + flatVy *= scaleDown + } + vx = flatVx + vy = flatVy * ISO_GROUND_ASPECT_RATIO + x = projectile.x + vx + y = projectile.y + vy jitterIndex = (idx + 1) % 32 } } diff --git a/tests/skills/sor/issue-435-charged-bolt-kinematics.test.ts b/tests/skills/sor/issue-435-charged-bolt-kinematics.test.ts new file mode 100644 index 0000000..93f4636 --- /dev/null +++ b/tests/skills/sor/issue-435-charged-bolt-kinematics.test.ts @@ -0,0 +1,101 @@ +/** + * Issue #435: Charged Bolt (#38) Kinematics & Random Walk + * + * 1.13c Ground Truth Invariants Verification Suite: + * 1. Lateral Wander & Heading Drift: Charged Bolt accumulates directional changes + * across steps, resulting in snake-like serpentine wandering across the ground plane. + * 2. Elimination of ±3px Rigid Bound: Maximum lateral excursion significantly exceeds 3px + * over extended flight distance, proving genuine path divergence. + * 3. Speed Preservation: Throughout Cartesian angular drift and isometric mapping, + * the velocity magnitude remains calibrated and normalized. + * 4. Multi-Bolt Divergence: Multiple bolts fired with different seeds or ticks diverge + * into independent wandering paths. + */ +import { describe, expect, it } from 'vitest' +import { tickProjectiles, type Projectile } from '../../../src/game/skills.ts' + +describe('Issue #435: Charged Bolt (#38) Kinematics & Random Walk', () => { + it('demonstrates serpentine lateral wander exceeding ±3px bound in skills.ts tickProjectiles', () => { + // Launch a charged bolt moving strictly East (vx = 12, vy = 0) + let p: Projectile = { + id: 1, + name: 'chargedbolt', + sourceSkillId: 38, + x: 500, + y: 500, + vx: 12, + vy: 0, + ttl: 50, + damage: 10, + radius: 8, + jitter: true, + jitterIndex: 0, + seed: 7, + } + + const startY = p.y + let maxLateralDisplacement = 0 + + for (let t = 0; t < 35; t++) { + const state = tickProjectiles([p], [], { overlap: () => 0 }) + if (state.alive.length === 0) break + p = state.alive[0]! + const dy = Math.abs(p.y - startY) + if (dy > maxLateralDisplacement) { + maxLateralDisplacement = dy + } + } + + // Over 35 ticks, a genuine wandering path MUST exceed the obsolete 3px jitter bound + expect(maxLateralDisplacement).toBeGreaterThan(10) + // The bolt traveled forward along X + expect(p.x).toBeGreaterThan(700) + }) + + it('ensures distinct random seeds yield divergent trajectories', () => { + let p1: Projectile = { + id: 1, + name: 'chargedbolt', + sourceSkillId: 38, + x: 0, + y: 0, + vx: 10, + vy: 5, + ttl: 30, + damage: 10, + radius: 8, + jitter: true, + jitterIndex: 0, + seed: 1, + } + + let p2: Projectile = { + id: 2, + name: 'chargedbolt', + sourceSkillId: 38, + x: 0, + y: 0, + vx: 10, + vy: 5, + ttl: 30, + damage: 10, + radius: 8, + jitter: true, + jitterIndex: 5, + seed: 29, + } + + for (let t = 0; t < 25; t++) { + const s1 = tickProjectiles([p1], [], { overlap: () => 0 }) + const s2 = tickProjectiles([p2], [], { overlap: () => 0 }) + if (s1.alive.length === 0 || s2.alive.length === 0) break + p1 = s1.alive[0]! + p2 = s2.alive[0]! + } + + // Both started at identical coordinates and base velocity, but different jitter state + // Their final positions must diverge + const distanceBetween = Math.hypot(p1.x - p2.x, p1.y - p2.y) + expect(distanceBetween).toBeGreaterThan(15) + }) +}) diff --git a/tests/skills/sor/skill-038-charged-bolt.test.ts b/tests/skills/sor/skill-038-charged-bolt.test.ts index 44976c9..c8fb4d2 100644 --- a/tests/skills/sor/skill-038-charged-bolt.test.ts +++ b/tests/skills/sor/skill-038-charged-bolt.test.ts @@ -35,6 +35,7 @@ import { calculateChargedBoltDamage, calculateSkillDamage, tickProjectiles, + ISO_GROUND_ASPECT_RATIO, type Projectile, } from '../../../src/game/skills.ts' import { @@ -218,9 +219,10 @@ describe('[Skill #038] Charged Bolt (SOR) — 1.13c 7-Dimensional Verification S const tick1 = tickProjectiles(projectiles, [], openTerrain) expect(tick1.alive.length).toBe(1) const p1 = tick1.alive[0]! - // Forward advancement - expect(p1.x).toBe(12) - // Orthogonal displacement: perp vector (-vy/speed, vx/speed) = (0, 1). Offset = GAT[0] * 3 = -1 * 3 = -3 * ISO_RATIO + // Forward advancement with heading angle deflection and normalized speed (12 px/tick) + expect(p1.x).toBeCloseTo(11.64, 1) + expect(Math.hypot(p1.vx, p1.vy / ISO_GROUND_ASPECT_RATIO)).toBeCloseTo(12, 1) + // Orthogonal displacement: perp vector deflects trajectory expect(p1.y).not.toBe(0) expect(p1.jitterIndex).toBe(1) })