diff --git a/src/game/engine.ts b/src/game/engine.ts index bae23d0..954a034 100644 --- a/src/game/engine.ts +++ b/src/game/engine.ts @@ -428,6 +428,9 @@ export class GameEngine { const outcome = tickProjectiles(this.projectiles, targets, this.terrain) this.projectiles.length = 0 this.projectiles.push(...outcome.alive) + if (outcome.spawnedProjectiles && outcome.spawnedProjectiles.length > 0) { + this.projectiles.push(...outcome.spawnedProjectiles) + } for (const hit of outcome.hits) { const targetMonster = this.world.monsters.find(m => m.index === hit.targetIndex) if (hit.isUndeadOnly) { diff --git a/src/game/engine/missile-engine.ts b/src/game/engine/missile-engine.ts index bf25288..c0c0385 100644 --- a/src/game/engine/missile-engine.ts +++ b/src/game/engine/missile-engine.ts @@ -71,6 +71,12 @@ export interface ActiveMissile { isTurret?: boolean | undefined fireCadenceTicks?: number | undefined spawnMissileType?: string | undefined + altitude?: number | undefined +} + +export interface MissileTerrainCollision { + readonly overlap?: ((x: number, y: number) => number) | undefined + readonly isMissileBlocked?: ((x: number, y: number, fromX?: number, fromY?: number) => boolean) | undefined } export interface MissileStepResult { @@ -88,9 +94,15 @@ export class MissileEngine { private readonly missiles: ActiveMissile[] = [] private readonly nextDelayExpireByTarget = new Map() private uidCounter = 1 + private terrain?: MissileTerrainCollision | undefined - constructor(registry: D2DataRegistry) { + constructor(registry: D2DataRegistry, terrain?: MissileTerrainCollision) { this.registry = registry + this.terrain = terrain + } + + setTerrain(terrain?: MissileTerrainCollision): void { + this.terrain = terrain } spawnMissile(params: { @@ -109,6 +121,7 @@ export class MissileEngine { fireCadenceTicks?: number spawnMissileType?: string maxRangeTicks?: number + altitude?: number }): ActiveMissile | null { const rec = typeof params.missileNameOrId === 'number' @@ -292,6 +305,7 @@ export class MissileEngine { chainRemaining: params.chainCount ?? (effectiveRec.pSrvHitFunc === 12 ? 5 + Math.trunc(params.slvl / 5) : 0), expired: false, isTurret, + ...(params.altitude !== undefined ? { altitude: params.altitude } : {}), ...(params.fireCadenceTicks !== undefined ? { fireCadenceTicks: params.fireCadenceTicks } : isTurret @@ -320,7 +334,12 @@ export class MissileEngine { * Step all active missiles by 1 tick (`25 Hz`), resolving movement (`pSrvDoFunc`), * collisions (`pSrvHitFunc`), `NextDelay` gating, `Pierce`, and sub-missiles. */ - tick(currentTick: number, targets: readonly CombatUnitContext[], targetPositions: ReadonlyMap): MissileStepResult { + tick( + currentTick: number, + targets: readonly CombatUnitContext[], + targetPositions: ReadonlyMap, + collision?: MissileTerrainCollision, + ): MissileStepResult { const hits: { missileName: string; targetId: string; damage: number; immune: boolean }[] = [] const subMissilesSpawned: string[] = [] @@ -328,6 +347,11 @@ export class MissileEngine { if (m.expired) continue m.ageTicks += 1 + if (m.name === 'meteor' || m.name === 'meteortail') { + if (m.altitude === undefined) m.altitude = 60 * 25 + m.altitude = Math.max(0, m.altitude - 25) + } + // `pSrvDoFunc = 15` (`frozenorb`): emits radial `frozenorbbolt` sub-missiles while flying // Authentic D2Game.0x6FC581F0 (MISSMODE_SrvDo15_FrozenOrb): // - 64-entry discrete circle lookup table (radius 30) @@ -481,6 +505,8 @@ export class MissileEngine { m.name === 'blizzardcenter' || m.record.pSrvHitFunc === 14 || m.name === 'meteorcenter' || + m.name === 'meteor' || + m.name === 'meteortail' || isTurretEmitter ) { continue @@ -679,9 +705,19 @@ export class MissileEngine { elemMin256: fireDmg.min * 8, // HitShift 3: << 3 = * 8 elemMax256: fireDmg.max * 8, } + const effectiveTerrain = collision ?? this.terrain for (const off of METEOR_FIRE_SUBTILE_OFFSETS) { const fx = m.x + off.x * 20 const fy = m.y + off.y * 10 + if (effectiveTerrain !== undefined) { + const blocked = + effectiveTerrain.isMissileBlocked !== undefined + ? effectiveTerrain.isMissileBlocked(fx, fy, m.x, m.y) + : effectiveTerrain.overlap !== undefined && effectiveTerrain.overlap(fx, fy) > 0 + if (blocked) { + continue + } + } this.spawnMissile({ missileNameOrId: subFireName, sourceSkillId: m.sourceSkillId, diff --git a/src/game/skills.ts b/src/game/skills.ts index 3c26c04..2ff16ec 100644 --- a/src/game/skills.ts +++ b/src/game/skills.ts @@ -3606,7 +3606,8 @@ export function tickProjectiles( let wallHits = 0 for (const projectile of projectiles) { - if (projectile.ttl <= 0) { + const isExpired = projectile.missileType === 'meteorcenter' ? projectile.ttl <= 1 : projectile.ttl <= 0 + if (isExpired) { expired += 1 if (projectile.missileType === 'frozenorb') { // Authentic D2 1.13c MISSMODE_SrvHit29_FrozenOrb (0x6FC5CE10): @@ -3920,6 +3921,9 @@ export function tickProjectiles( if (isBlizzardShard(projectile.missileType)) { if (altitude === undefined) altitude = 120 altitude = Math.max(0, altitude - (120 / 9)) + } else if (projectile.missileType === 'meteor' || projectile.missileType === 'meteortail') { + if (altitude === undefined) altitude = 60 * 25 + altitude = Math.max(0, altitude - 25) } const hitTargets = projectile.hitTargets ?? new Set() @@ -3942,10 +3946,12 @@ export function tickProjectiles( } // Walls first: a projectile dies against missile barriers (solid walls, closed doors) + const isAirborne = altitude !== undefined && altitude > 0 const blockedByTerrain = - options.isMissileBlocked !== undefined + !isAirborne && + (options.isMissileBlocked !== undefined ? options.isMissileBlocked(x, y, projectile.x, projectile.y) - : options.overlap(x, y) > 0 + : options.overlap(x, y) > 0) if (blockedByTerrain) { wallHits += 1 wallHitEvents.push({ @@ -4003,6 +4009,8 @@ export function tickProjectiles( projectile.missileType === 'frozenorb' || projectile.missileType === 'blizzardcenter' || projectile.missileType === 'meteorcenter' || + projectile.missileType === 'meteor' || + projectile.missileType === 'meteortail' || isTurretEmitter if (!isHarmlessEmitter) { for (const target of targets) { diff --git a/src/scene/act-scene.ts b/src/scene/act-scene.ts index cd8fb22..a264cf8 100644 --- a/src/scene/act-scene.ts +++ b/src/scene/act-scene.ts @@ -3725,6 +3725,7 @@ export function drawMissileProjectile( renderer: SpriteRenderer, shot: Projectile, missileArt?: LoadedMissileArt, + missileArtMap?: Map, ): void { if (shot.missileType === 'blizzardcenter') { return // Invisible controller missile @@ -3735,7 +3736,24 @@ export function drawMissileProjectile( if (missileArt !== undefined) { const dccDir = velocityToDccDirection(shot.vx, shot.vy, missileArt.meta.directions) const frameCount = Math.max(1, missileArt.meta.framesPerDirection) - const frameIndex = Math.abs(Math.floor(((100 - shot.ttl) * missileArt.meta.animSpeed) / 16)) % frameCount + + let frameIndex: number + if (missileArt.meta.name === 'meteorcenter') { + // For meteorcenter: frame indexing must start at frame 0 and advance forward: (60 - shot.ttl) % numFrames + const elapsed = shot.ageTicks ?? Math.max(0, 60 - shot.ttl) + frameIndex = Math.floor((elapsed * missileArt.meta.animSpeed) / 16) % frameCount + } else if (missileArt.meta.name === 'meteorfire' && frameCount === 37) { + // For meteorfire: use elapsed ticks (shot.ageTicks or duration - shot.ttl). + // Frames 0..11 play emergence once, and frames >= 12 loop infinitely within 12..36 (12 + ((elapsed - 12) % 25)). + const duration = shot.fireDuration ?? (30 + (Math.max(1, shot.slvl ?? 1) - 1) * 15) + const elapsed = shot.ageTicks ?? Math.max(0, duration - shot.ttl) + const animStep = Math.floor((elapsed * missileArt.meta.animSpeed) / 16) + frameIndex = animStep < 12 ? animStep : 12 + ((animStep - 12) % 25) + } else { + const elapsed = shot.ageTicks ?? Math.max(0, 100 - shot.ttl) + frameIndex = Math.floor((elapsed * missileArt.meta.animSpeed) / 16) % frameCount + } + const group = missileArt.frames[dccDir] ?? missileArt.frames[0] const frame = group?.[frameIndex] ?? group?.[0] if (frame !== undefined) { @@ -3746,6 +3764,53 @@ export function drawMissileProjectile( atlas: missileArt.handle, ...(isAdditive ? { blendMode: 'additive' } : {}), }) + + // Dual Sprite Falling Trajectory & Rendering: + // During the 60-tick countdown of meteorcenter, render the falling meteor fireball (meteor, ID 100) + // and atmospheric flame tail (meteortail, ID 102) descending diagonally from sky altitude to the ground indicator. + if (shot.missileType === 'meteorcenter' && missileArtMap !== undefined && shot.ttl > 0) { + const remainingTicks = shot.ttl + const fallAltitude = remainingTicks * 25 + const slideX = -remainingTicks * 15 + const meteorX = shot.x + slideX + const meteorY = shot.y + const elapsed = shot.ageTicks ?? Math.max(0, 60 - shot.ttl) + + // 1. Atmospheric flame tail (`meteortail`, ID 102) + const tailArt = missileArtMap.get('meteortail') + if (tailArt !== undefined) { + const tailGroup = tailArt.frames[0] + const tailFrameCount = Math.max(1, tailArt.meta.framesPerDirection) + const tailFrameIndex = Math.floor((elapsed * tailArt.meta.animSpeed) / 16) % tailFrameCount + const tailFrame = tailGroup?.[tailFrameIndex] ?? tailGroup?.[0] + if (tailFrame !== undefined) { + const tailDrawX = tailFrame.anchorX !== undefined ? meteorX + tailFrame.anchorX : meteorX - tailFrame.width / 2 + const tailDrawY = (tailFrame.anchorY !== undefined ? meteorY + tailFrame.anchorY : meteorY - tailFrame.height / 2) - fallAltitude + renderer.draw(tailFrame, tailDrawX, tailDrawY, { + atlas: tailArt.handle, + blendMode: 'additive', + }) + } + } + + // 2. Falling meteor core fireball (`meteor`, ID 100) + const meteorArt = missileArtMap.get('meteor') + if (meteorArt !== undefined) { + const meteorGroup = meteorArt.frames[0] + const meteorFrameCount = Math.max(1, meteorArt.meta.framesPerDirection) + const meteorFrameIndex = Math.floor((elapsed * meteorArt.meta.animSpeed) / 16) % meteorFrameCount + const meteorFrame = meteorGroup?.[meteorFrameIndex] ?? meteorGroup?.[0] + if (meteorFrame !== undefined) { + const meteorDrawX = meteorFrame.anchorX !== undefined ? meteorX + meteorFrame.anchorX : meteorX - meteorFrame.width / 2 + const meteorDrawY = (meteorFrame.anchorY !== undefined ? meteorY + meteorFrame.anchorY : meteorY - meteorFrame.height / 2) - fallAltitude + renderer.draw(meteorFrame, meteorDrawX, meteorDrawY, { + atlas: meteorArt.handle, + blendMode: 'additive', + }) + } + } + } + return } } @@ -3753,6 +3818,25 @@ export function drawMissileProjectile( const x = shot.x const y = shot.y - altitude + if (shot.missileType === 'meteorcenter') { + // Procedural ground reticle (2:1 isometric ground ellipse) + renderer.drawSolid(shot.x - 20, shot.y - 10, 40, 20, [1.0, 0.4, 0.1, 0.4]) + renderer.drawSolid(shot.x - 10, shot.y - 5, 20, 10, [1.0, 0.7, 0.2, 0.6]) + if (shot.ttl > 0) { + const remainingTicks = shot.ttl + const fallAltitude = remainingTicks * 25 + const slideX = -remainingTicks * 15 + const mx = shot.x + slideX + const my = shot.y - fallAltitude + // Falling fireball core + renderer.drawSolid(mx - 8, my - 8, 16, 16, [1.0, 0.5, 0.1, 0.8]) + renderer.drawSolid(mx - 4, my - 4, 8, 8, [1.0, 0.9, 0.3, 0.95]) + // Trailing flame tail + renderer.drawSolid(mx - 12, my - 12 + fallAltitude * 0.05, 24, 24, [0.9, 0.3, 0.05, 0.5]) + } + return + } + if (shot.isTurret || shot.missileType === 'hydra_turret') { // Authentic stationary hydra head turret plume const wobble = Math.sin((shot.ageTicks ?? 0) * 0.3 + (shot.headIndex ?? 0) * 1.5) * 2.5 @@ -5321,7 +5405,7 @@ if (typeof window !== 'undefined') { mType === 'blizzardexplode3' ? missileArtMap.get('blizzardexplode2') : undefined)) : undefined if (art !== undefined) { - drawMissileProjectile(renderer, shot, art) + drawMissileProjectile(renderer, shot, art, missileArtMap) } else { renderer.drawSolid(shot.x - 4, shot.y - 4, 8, 8, [0.9, 0.9, 0.2, 1]) } diff --git a/tests/meteor-adversarial-stress.test.ts b/tests/meteor-adversarial-stress.test.ts new file mode 100644 index 0000000..d5d8565 --- /dev/null +++ b/tests/meteor-adversarial-stress.test.ts @@ -0,0 +1,521 @@ +/** + * Independent Adversarial Verification Harness — Skill #056: Meteor (1.13c Parity) + * + * Authored by: teamwork_preview_challenger_meteor_1 (Empirical Challenger) + * Verification Scope: + * 1. 5-band level scaling: slvl 1, 5, 10, 20, 25, 30 against 1.13c ground truth formulas. + * Deep investigation of slvl 25 exact formula (1270-1338) vs explorer handoff arithmetic shortcut (1274-1342). + * 2. Synergy multipliers: Fire Bolt (+5%/blvl), Fire Ball (+5%/blvl), Fire Mastery multiplicative bonus, + * and Inferno (+3%/blvl) for residual ground fire. + * 3. Soft points edge cases: verifying hard points (blvl) strictly govern synergy, while soft points (+skills/charges) + * grant ZERO synergy bonuses. + * 4. Residual fire patch mechanics: HitShift 3 (Raw * 8 in 256ths), duration scaling 30 + (slvl - 1) * 15 ticks. + * 5. NextDelay verification: NextDelay = 0 in data tables and runtime simulation (overlapping patches simultaneously damage targets). + * 6. Adversarial boundaries: NaN, negative, 0, extreme levels (slvl 99), and wall clipping. + */ +import { describe, expect, it } from 'vitest' +import fs from 'node:fs' +import path from 'node:path' +import { getSharedDataRegistry } from '../src/game/engine/data-registry.ts' +import { UnitStatList } from '../src/game/engine/stat-list.ts' +import { compute5BandScaling } from '../src/game/engine/calc-ast.ts' +import { + calculateMeteorDamage, + calculateMeteorSynergyMultiplier, + calculateMeteorResidualFireDamage, + calculateMeteorDetailedDamage, + calculateSkillDamage, + getSkillManaCost, + getSkillCooldownTicks, + METEOR_FIRE_SUBTILE_OFFSETS, + ISO_GROUND_ASPECT_RATIO, + parseMissilesTxt, + tickProjectiles, + type Projectile, +} from '../src/game/skills.ts' +import { skillModule } from '../src/game/skills/impl/sor/skill-056-meteor.ts' +import { MissileEngine } from '../src/game/engine/missile-engine.ts' +import type { CombatUnitContext, SUnitDmgPacket } from '../src/game/engine/combat-pipeline.ts' + +describe('Adversarial Stress Test: Sorceress Skill 56 (Meteor) 1.13c Math & Mechanics', () => { + + // ========================================================================= + // Challenge Dimension 1: 5-Band Level Scaling & Slvl 25 Ground Truth Analysis + // ========================================================================= + describe('Dimension 1: 5-Band Level Scaling & Slvl 25 Analysis', () => { + it('verifies exact 5-band scaling across benchmark levels: 1, 5, 10, 20, 30', () => { + // 1.13c Skills.txt Row 56 parameters: + // Base: EMin = 80, EMax = 100 + // Band 1 (slvl 2..8, 7 levels): EMinLev1 = 23, EMaxLev1 = 25 + // Band 2 (slvl 9..16, 8 levels): EMinLev2 = 39, EMaxLev2 = 41 + // Band 3 (slvl 17..22, 6 levels): EMinLev3 = 79, EMaxLev3 = 81 + // Band 4 (slvl 23..28, 6 levels): EMinLev4 = 81, EMaxLev4 = 83 + // Band 5 (slvl 29+, beyond): EMinLev5 = 83, EMaxLev5 = 85 + + // slvl 1: 80 - 100 + const d1 = calculateMeteorDamage(1) + expect(d1).toEqual({ min: 80, max: 100 }) + + // slvl 5: 80 + 4*23 = 172, 100 + 4*25 = 200 + const d5 = calculateMeteorDamage(5) + expect(d5).toEqual({ min: 172, max: 200 }) + + // slvl 10: 80 + 7*23 + 2*39 = 319, 100 + 7*25 + 2*41 = 357 + const d10 = calculateMeteorDamage(10) + expect(d10).toEqual({ min: 319, max: 357 }) + + // slvl 20: 80 + 7*23 + 8*39 + 4*79 = 869, 100 + 7*25 + 8*41 + 4*81 = 927 + const d20 = calculateMeteorDamage(20) + expect(d20).toEqual({ min: 869, max: 927 }) + + // slvl 30: 869 + 2*79 (slvl 21-22) + 6*81 (slvl 23-28) + 2*83 (slvl 29-30) = 1679 + // max: 927 + 2*81 + 6*83 + 2*85 = 1757 + const d30 = calculateMeteorDamage(30) + expect(d30).toEqual({ min: 1679, max: 1757 }) + }) + + it('proves slvl 25 mathematical ground truth: 1270-1338 vs explorer report 1274-1342', () => { + // Deep Mathematical Proof: + // Band 3 comprises slvl 17..22 (total 6 levels: 17, 18, 19, 20, 21, 22) with rate 79/81. + // At slvl 20, only 4 levels of Band 3 have elapsed (17..20). + // Slvl 21 and 22 STILL belong to Band 3! + // Therefore: + // slvl 22 = slvl 20 + 2 * 79 = 869 + 158 = 1027 min (max = 927 + 2*81 = 1089) + // Band 4 begins at slvl 23! + // At slvl 25, exactly 3 levels of Band 4 have elapsed (slvl 23, 24, 25) with rate 81/83. + // slvl 25 = 1027 + 3 * 81 = 1027 + 243 = 1270 min + // slvl 25 max = 1089 + 3 * 83 = 1089 + 249 = 1338 max + // + // Origin of Explorer's 1274-1342: + // The explorer calculated: slvl 20 (869) + 5 * 81 = 869 + 405 = 1274 + // and max: 927 + 5 * 83 = 927 + 415 = 1342. + // This erroneously treated slvl 21 and 22 as Band 4 (+81) rather than Band 3 (+79), + // introducing an off-by-4 error (+2 per level for 2 levels = +4). + // If 1274 were correct, slvl 30 would be 1274 + 3*81 + 2*83 = 1274 + 243 + 166 = 1683 (NOT 1679). + // Only 1270 correctly reconciles with the undisputed slvl 30 ground truth of 1679! + + const d25 = calculateMeteorDamage(25) + expect(d25.min).toBe(1270) + expect(d25.max).toBe(1338) + + // Verification via core compute5BandScaling function + const c5bMin = compute5BandScaling(25, 80, 23, 39, 79, 81, 83) + const c5bMax = compute5BandScaling(25, 100, 25, 41, 81, 83, 85) + expect(c5bMin).toBe(1270) + expect(c5bMax).toBe(1338) + + // Assert that 1270 + remainder of Band 4 (3 levels) + 2 levels of Band 5 = slvl 30 + expect(1270 + 3 * 81 + 2 * 83).toBe(1679) + expect(1338 + 3 * 83 + 2 * 85).toBe(1757) + }) + + it('exhaustively tests 5-band monotonicity across slvl 1 to 99', () => { + let prevMin = 0 + let prevMax = 0 + for (let lvl = 1; lvl <= 99; lvl++) { + const dmg = calculateMeteorDamage(lvl) + expect(dmg.min).toBeGreaterThan(prevMin) + expect(dmg.max).toBeGreaterThan(prevMax) + expect(dmg.max).toBeGreaterThan(dmg.min) + prevMin = dmg.min + prevMax = dmg.max + } + }) + }) + + // ========================================================================= + // Challenge Dimension 2: Synergy Multipliers & Multiplicative Fire Mastery + // ========================================================================= + describe('Dimension 2: Synergy Multipliers & Mastery Scaling', () => { + it('verifies +5% per hard point in Fire Bolt (36) and Fire Ball (47)', () => { + // 0 synergies: 1.0x + expect(calculateMeteorSynergyMultiplier({})).toBe(1.0) + + // Fire Bolt 1 pt: 1.05x + expect(calculateMeteorSynergyMultiplier({ fireBolt: 1 })).toBe(1.05) + + // Fire Bolt 20 pts: 2.00x (+100%) + expect(calculateMeteorSynergyMultiplier({ fireBolt: 20 })).toBe(2.00) + + // Fire Ball 1 pt: 1.05x + expect(calculateMeteorSynergyMultiplier({ fireBall: 1 })).toBe(1.05) + + // Fire Ball 20 pts: 2.00x (+100%) + expect(calculateMeteorSynergyMultiplier({ fireBall: 20 })).toBe(2.00) + + // Fire Bolt 20 + Fire Ball 20: 3.00x (+200%) + expect(calculateMeteorSynergyMultiplier({ fireBolt: 20, fireBall: 20 })).toBe(3.00) + + // Damage checks at slvl 20 (base 869-927) + const d20Bolt20 = calculateMeteorDamage(20, { fireBolt: 20 }) + expect(d20Bolt20.min).toBe(Math.floor(869 * 2.0)) // 1738 + expect(d20Bolt20.max).toBe(Math.floor(927 * 2.0)) // 1854 + + const d20Both20 = calculateMeteorDamage(20, { fireBolt: 20, fireBall: 20 }) + expect(d20Both20.min).toBe(Math.floor(869 * 3.0)) // 2607 + expect(d20Both20.max).toBe(Math.floor(927 * 3.0)) // 2781 + }) + + it('verifies Fire Mastery (61) multiplicative scaling with synergies', () => { + // In 1.13c, Fire Mastery: 30% base (lvl 1) + 7%/lvl + // Level 1: +30% -> (1 + 0.30) = 1.30 + // Level 20: 30 + 19 * 7 = 163% -> (1 + 1.63) = 2.63 + const synMastery1 = calculateMeteorSynergyMultiplier({ fireMastery: 1 }) + expect(synMastery1).toBeCloseTo(1.30, 4) + + const synMastery20 = calculateMeteorSynergyMultiplier({ fireMastery: 20 }) + expect(synMastery20).toBeCloseTo(2.63, 4) + + // Synergies + Mastery are multiplicative: + // (1 + (20+20)*0.05) * (1 + 1.63) = 3.0 * 2.63 = 7.89 + const synFull = calculateMeteorSynergyMultiplier({ fireBolt: 20, fireBall: 20, fireMastery: 20 }) + expect(synFull).toBeCloseTo(7.89, 4) + + const d20Full = calculateMeteorDamage(20, { fireBolt: 20, fireBall: 20, fireMastery: 20 }) + expect(d20Full.min).toBe(Math.floor(869 * 7.89)) // 6856 + expect(d20Full.max).toBe(Math.floor(927 * 7.89)) // 7314 + }) + + it('verifies residual fire synergy: Inferno (41) +3%/lvl and Fire Mastery', () => { + // Base residual fire at slvl 1: 15 - 25 + const resBase = calculateMeteorResidualFireDamage(1) + expect(resBase.min).toBe(15) + expect(resBase.max).toBe(25) + + // Inferno 1 pt: +3% -> 1.03x + const resInf1 = calculateMeteorResidualFireDamage(1, { inferno: 1 }) + expect(resInf1.min).toBe(Math.floor(15 * 1.03)) // 15 + expect(resInf1.max).toBe(Math.floor(25 * 1.03)) // 25 + + // Inferno 20 pts: +60% -> 1.60x + const resInf20 = calculateMeteorResidualFireDamage(1, { inferno: 20 }) + expect(resInf20.min).toBe(Math.floor(15 * 1.60)) // 24 + expect(resInf20.max).toBe(Math.floor(25 * 1.60)) // 40 + + // Inferno 20 pts + Fire Mastery 20 (+163%): 1.60 * 2.63 = 4.208 + const resBoth20 = calculateMeteorResidualFireDamage(1, { inferno: 20, fireMastery: 20 }) + expect(resBoth20.min).toBe(Math.floor(15 * 1.60 * 2.63)) // 63 + expect(resBoth20.max).toBe(Math.floor(25 * 1.60 * 2.63)) // 105 + }) + }) + + // ========================================================================= + // Challenge Dimension 3: Soft Points vs Hard Points (Marrowwalk Synergy Rule) + // ========================================================================= + describe('Dimension 3: Soft Points vs Hard Points Edge Cases', () => { + it('verifies that item soft points (+skills, +fireskills, charges) provide ZERO synergy bonus', async () => { + const registry = await getSharedDataRegistry() + const skillRec = registry.getSkillById(56)! + + // Case A: 20 Base Points in Meteor, 0 Base in Fire Bolt, but +20 allskills & +33 charged skill + const statsSoft = new UnitStatList() + statsSoft.setBaseSkillLevel(56, 20) + statsSoft.addStat('item_allskills', 20) + statsSoft.addStat('item_sorceress_skills', 10) + statsSoft.setChargedSkillLevel(36, 33) // Fire Bolt charges (Marrowwalk bug test) + + const evalSoft = skillModule.evaluate!({ + registry, + skill: skillRec, + slvl: 50, // 20 base + 30 soft + blvl: 20, + statList: statsSoft, + }) + + // Synergy bonus percentage MUST BE 0! + expect(evalSoft.synergyBonusPct).toBe(0) + + // Case B: 20 Base Points in Fire Bolt (hard points) without soft points + const statsHard = new UnitStatList() + statsHard.setBaseSkillLevel(56, 20) + statsHard.setBaseSkillLevel(36, 20) // 20 hard points in Fire Bolt + + const evalHard = skillModule.evaluate!({ + registry, + skill: skillRec, + slvl: 20, + blvl: 20, + statList: statsHard, + }) + + // Synergy bonus percentage MUST BE exactly 100% (+5% * 20) + expect(evalHard.synergyBonusPct).toBe(100) + + // Case C: 20 Base Points in Fire Bolt PLUS +20 soft points in allskills + const statsMixed = new UnitStatList() + statsMixed.setBaseSkillLevel(56, 20) + statsMixed.setBaseSkillLevel(36, 20) // 20 hard points + statsMixed.addStat('item_allskills', 20) // +20 soft points + + const evalMixed = skillModule.evaluate!({ + registry, + skill: skillRec, + slvl: 40, + blvl: 20, + statList: statsMixed, + }) + + // Synergy bonus percentage MUST STILL BE exactly 100%, NOT 200%! + expect(evalMixed.synergyBonusPct).toBe(100) + }) + }) + + // ========================================================================= + // Challenge Dimension 4: Residual Fire Patch Math & Duration Scaling + // ========================================================================= + describe('Dimension 4: Residual Fire Patch Math & Duration Scaling', () => { + it('verifies 5-band residual fire damage progression: [4, 5, 6, 6, 6]', () => { + // Base: 15-25 + // slvl 1: 15 - 25 + const r1 = calculateMeteorResidualFireDamage(1) + expect(r1.min).toBe(15) + expect(r1.max).toBe(25) + + // slvl 5: 15 + 4*4 = 31, 25 + 4*4 = 41 + const r5 = calculateMeteorResidualFireDamage(5) + expect(r5.min).toBe(31) + expect(r5.max).toBe(41) + + // slvl 10: 15 + 7*4 + 2*5 = 53, 25 + 7*4 + 2*5 = 63 + const r10 = calculateMeteorResidualFireDamage(10) + expect(r10.min).toBe(53) + expect(r10.max).toBe(63) + + // slvl 20: 15 + 7*4 + 8*5 + 4*6 = 107, 25 + 7*4 + 8*5 + 4*6 = 117 + const r20 = calculateMeteorResidualFireDamage(20) + expect(r20.min).toBe(107) + expect(r20.max).toBe(117) + + // slvl 25: 107 + 2*6 + 3*6 = 137, 117 + 2*6 + 3*6 = 147 + const r25 = calculateMeteorResidualFireDamage(25) + expect(r25.min).toBe(137) + expect(r25.max).toBe(147) + + // slvl 30: 107 + 2*6 + 6*6 + 2*6 = 167, 117 + 2*6 + 6*6 + 2*6 = 177 + const r30 = calculateMeteorResidualFireDamage(30) + expect(r30.min).toBe(167) + expect(r30.max).toBe(177) + }) + + it('verifies HitShift 3 precision (Raw * 8 in 256ths) and per-second conversion (Raw * 200 / 256)', () => { + // 1.13c HitShift = 3: Damage256 = Raw * 8 + for (const slvl of [1, 5, 10, 20, 25, 30]) { + const res = calculateMeteorResidualFireDamage(slvl) + const tickMin256 = res.min * 8 + const tickMax256 = res.max * 8 + expect(tickMin256).toBe(res.min << 3) + expect(tickMax256).toBe(res.max << 3) + + // 25 ticks/sec -> perSec = floor(Damage256 * 25 / 256) = floor(Raw * 200 / 256) + expect(res.perSecMin).toBe(Math.floor((res.min * 200) / 256)) + expect(res.perSecMax).toBe(Math.floor((res.max * 200) / 256)) + } + }) + + it('verifies exact residual fire duration scaling: 30 + (slvl - 1) * 15 ticks', () => { + const expectedDurations = [ + { slvl: 1, ticks: 30, sec: 1.20 }, + { slvl: 5, ticks: 90, sec: 3.60 }, + { slvl: 10, ticks: 165, sec: 6.60 }, + { slvl: 20, ticks: 315, sec: 12.60 }, + { slvl: 25, ticks: 390, sec: 15.60 }, + { slvl: 30, ticks: 465, sec: 18.60 }, + ] + + for (const exp of expectedDurations) { + const dur = 30 + (exp.slvl - 1) * 15 + expect(dur).toBe(exp.ticks) + expect(dur / 25).toBeCloseTo(exp.sec, 2) + } + }) + }) + + // ========================================================================= + // Challenge Dimension 5: NextDelay = 0 & Overlapping Fire Patch Multi-Hit + // ========================================================================= + describe('Dimension 5: NextDelay = 0 & Overlapping Fire Patches', () => { + it('verifies from authentic Missiles.txt that all Meteor missiles have NextDelay = 0', () => { + const missilesTxtPath = path.resolve(__dirname, '../samples/fixtures/data/global/excel/Missiles.txt') + const content = fs.readFileSync(missilesTxtPath, 'latin1') + const parsed = parseMissilesTxt(content) + + const meteorMissiles = ['meteorcenter', 'meteor', 'meteortail', 'meteorexplode', 'meteorfire'] + for (const name of meteorMissiles) { + const m = parsed.get(name) + expect(m, `Missile ${name} not found in Missiles.txt`).toBeDefined() + // In 1.13c Missiles.txt, NextDelay and NextHit are either 0 or undefined for Meteor suite + const rec = m as any + expect(rec.nextDelay ?? 0).toBe(0) + expect(rec.nextHit ?? 0).toBe(0) + } + }) + + it('simulates multiple overlapping meteorfire patches hitting the same target simultaneously without NextDelay lockout', () => { + // Create 3 overlapping meteorfire projectiles at the exact same location + const p1: Projectile = { + skillId: '56', + x: 200, + y: 200, + vx: 0, + vy: 0, + damage: 10, + ttl: 30, + fromPlayer: true, + missileType: 'meteorfire', + pierce: true, + } + const p2: Projectile = { + skillId: '56', + x: 200, + y: 200, + vx: 0, + vy: 0, + damage: 15, + ttl: 30, + fromPlayer: true, + missileType: 'meteorfire', + pierce: true, + } + const p3: Projectile = { + skillId: '56', + x: 200, + y: 200, + vx: 0, + vy: 0, + damage: 20, + ttl: 30, + fromPlayer: true, + missileType: 'meteorfire', + pierce: true, + } + + const monster = [{ index: 42, x: 200, y: 200, radius: 20, alive: true }] + const outcome = tickProjectiles([p1, p2, p3], monster, { overlap: () => 0 }) + + // Because NextDelay = 0, all 3 overlapping patches MUST deal damage in the exact same tick! + expect(outcome.hits.length).toBe(3) + const dmgDealt = outcome.hits.map(h => h.damage).sort((a, b) => a - b) + expect(dmgDealt).toEqual([10, 15, 20]) + expect(outcome.alive.length).toBe(3) + }) + + it('simulates periodic burn over 20 ticks: verifies burn cadence every 5 ticks', () => { + let p: Projectile = { + skillId: '56', + x: 150, + y: 150, + vx: 0, + vy: 0, + damage: 25, + ttl: 30, + fromPlayer: true, + missileType: 'meteorfire', + pierce: true, + } + const monster = [{ index: 99, x: 150, y: 150, radius: 20, alive: true }] + + let totalHits = 0 + for (let t = 1; t <= 20; t++) { + const out = tickProjectiles([p], monster, { overlap: () => 0 }) + p = out.alive[0]! + totalHits += out.hits.length + } + + // Cadence: hits on tick 1, 5, 10, 15, 20 = 5 total hits over 20 ticks + expect(totalHits).toBe(5) + expect(p.ttl).toBe(10) // 30 - 20 = 10 ticks remaining + }) + }) + + // ========================================================================= + // Challenge Dimension 6: Adversarial Boundary & Resilience Inputs + // ========================================================================= + describe('Dimension 6: Adversarial Boundary & Input Fuzzing', () => { + it('handles non-finite, zero, and negative skill levels gracefully (clamps to 1)', () => { + const dZero = calculateMeteorDamage(0) + const dNeg = calculateMeteorDamage(-10) + const dNaN = calculateMeteorDamage(NaN) + const dInf = calculateMeteorDamage(Infinity) + + expect(dZero).toEqual({ min: 80, max: 100 }) + expect(dNeg).toEqual({ min: 80, max: 100 }) + expect(dNaN).toEqual({ min: 80, max: 100 }) + + const mZero = getSkillManaCost(56, 0) + const mNeg = getSkillManaCost(56, -5) + const mNaN = getSkillManaCost(56, NaN) + expect(mZero).toBe(17) + expect(mNeg).toBe(17) + expect(mNaN).toBe(17) + }) + + it('handles negative or invalid synergy values safely', () => { + const synNeg = calculateMeteorSynergyMultiplier({ fireBolt: -5, fireBall: -10 }) + // Even with negative points, multiplier should not drop below 0 or break calculations + expect(Number.isFinite(synNeg)).toBe(true) + + const dNegSyn = calculateMeteorDamage(20, -1) + expect(dNegSyn).toEqual({ min: 869, max: 927 }) // invalid number reverts to 1.0x + }) + + it('verifies generic calculateSkillDamage router accepts all valid representations', () => { + const resNum = calculateSkillDamage(56, 10) + const resStr = calculateSkillDamage('56', 10) + const resName = calculateSkillDamage('meteor', 10) + expect(resNum).toEqual({ min: 319, max: 357 }) + expect(resStr).toEqual({ min: 319, max: 357 }) + expect(resName).toEqual({ min: 319, max: 357 }) + }) + + it('verifies 18-offset ground fire geometry matches D2Game.dll 0x6FD1C918 / 0x6FD1C8D0', () => { + expect(METEOR_FIRE_SUBTILE_OFFSETS.length).toBe(18) + const expected = [ + { x: 2, y: -2 }, + { x: -2, y: -2 }, + { x: 0, y: 2 }, + { x: 0, y: 5 }, + { x: -3, y: 3 }, + { x: 0, y: 3 }, + { x: 3, y: 3 }, + { x: -1, y: 2 }, + { x: 1, y: 1 }, + { x: -1, y: -1 }, + { x: 2, y: -1 }, + { x: -4, y: -2 }, + { x: -3, y: -2 }, + { x: -1, y: -3 }, + { x: 0, y: -4 }, + { x: 1, y: -3 }, + { x: 3, y: -3 }, + { x: 4, y: -2 }, + ] + expect(METEOR_FIRE_SUBTILE_OFFSETS).toEqual(expected) + }) + + it('verifies wall collision suppresses out-of-bounds fire patch generation', () => { + const center: Projectile = { + skillId: '56', + x: 100, + y: 100, + vx: 0, + vy: 0, + damage: 200, + ttl: 0, // detonates immediately + fromPlayer: true, + missileType: 'meteorcenter', + slvl: 1, + } + + // Block everywhere where x > 100 (half the circle blocked by wall) + const outcome = tickProjectiles([center], [], { + overlap: (x, y) => (x > 100 ? 1 : 0), + }) + const flames = outcome.spawnedProjectiles?.filter(p => p.missileType === 'meteorfire') ?? [] + expect(flames.length).toBeLessThan(18) + expect(flames.length).toBeGreaterThan(0) + for (const flame of flames) { + expect(flame.x).toBeLessThanOrEqual(100) + } + }) + }) +}) diff --git a/tests/meteor-stress.test.ts b/tests/meteor-stress.test.ts new file mode 100644 index 0000000..d9a59d6 --- /dev/null +++ b/tests/meteor-stress.test.ts @@ -0,0 +1,573 @@ +/** + * Diablo II: Lord of Destruction v1.13c — Skill #056: Meteor + * Empirical Stress Testing & Adversarial Verification Suite + * + * Requirements (User Request & Solution Stress Testing Playbook): + * 1. Countdown timing: verify meteor lands and detonates exactly at tick 60, not sooner or later. + * 2. Dual sprite trajectory: verify meteor core (ID 100) and tail (ID 102) maintain synchronized coordinate descent. + * 3. Exact 18 residual fire patch offsets spawned around impact point matching 0x6FD1C918 and 0x6FD1C8D0. + * 4. Wall collision drop: fire patches spawned in wall cells are rejected (COLLIDE_WALL | COLLIDE_BLANK). + * 5. 50,000-tick continuous rapid-casting stress test: verify zero unhandled exceptions, zero NaN coordinates, + * clean missile disposal, and bounded memory. + */ +import { describe, expect, it } from 'vitest' +import { + tickProjectiles, + ISO_GROUND_ASPECT_RATIO, + calculateMeteorDamage, + calculateMeteorResidualFireDamage, + calculateMeteorDetailedDamage, + METEOR_FIRE_SUBTILE_OFFSETS, + type Projectile, + type ProjectileTarget, +} from '../src/game/skills.ts' +import { MissileEngine } from '../src/game/engine/missile-engine.ts' +import { getSharedDataRegistry } from '../src/game/engine/data-registry.ts' +import { UnitStatList } from '../src/game/engine/stat-list.ts' +import { StateBus } from '../src/game/engine/state-bus.ts' +import type { CombatUnitContext } from '../src/game/engine/combat-pipeline.ts' + +describe('[Skill #056] Meteor — Empirical Stress & Physics Challenger Suite', () => { + const openTerrain = { overlap: () => 0 } + + // ========================================================================= + // Dimension 1: Countdown Timing Parity (Detonates Exactly at Tick 60) + // ========================================================================= + describe('Dimension 1: Countdown Timing Parity (Exact Tick 60 Detonation)', () => { + it('verifies in MissileEngine that meteorcenter detonates at tick 60 and not at tick 59 or 61', async () => { + const registry = await getSharedDataRegistry() + const engine = new MissileEngine(registry) + + const ownerStatList = new UnitStatList(registry, {}) + const owner: CombatUnitContext = { + id: 'sorc', + name: 'Sorceress', + isUndead: false, + isDemon: false, + statList: ownerStatList, + stateBus: new StateBus(ownerStatList, registry), + } + + const dummyStatList = new UnitStatList(registry, { maxhp: 50000 * 256, hitpoints: 50000 * 256 }) + const dummy: CombatUnitContext = { + id: 'target-dummy', + name: 'TargetDummy', + isUndead: false, + isDemon: false, + statList: dummyStatList, + stateBus: new StateBus(dummyStatList, registry), + } + + const targetPositions = new Map([['target-dummy', { x: 300, y: 300 }]]) + + // Spawn meteorcenter at (300, 300) + engine.spawnMissile({ + missileNameOrId: 'meteorcenter', + sourceSkillId: 56, + slvl: 1, + owner, + startX: 300, + startY: 300, + targetX: 300, + targetY: 300, + dmgPacket: { + skillId: 56, + attackKind: 'spell', + elemType: 'fire', + elemMin256: 80 * 256, + elemMax256: 100 * 256, + autoHit: true, + }, + }) + + let detonationTick: number | null = null + let spawnedSubmissilesOnDetonation: string[] = [] + + // Step simulation tick by tick from 1 to 65 + for (let tick = 1; tick <= 65; tick++) { + const step = engine.tick(tick, [dummy], targetPositions) + + if (step.hits.length > 0) { + if (detonationTick === null) { + detonationTick = tick + spawnedSubmissilesOnDetonation = [...step.subMissilesSpawned] + } + } + + if (tick < 60) { + // In ticks 1..59, meteorcenter must still be falling/waiting, 0 damage inflicted + expect(step.hits.length, `Premature hit at tick ${tick}`).toBe(0) + const center = engine.getActiveMissiles().find(m => m.name === 'meteorcenter') + expect(center, `meteorcenter disappeared before tick 60 at tick ${tick}`).toBeDefined() + expect(center!.ageTicks).toBe(tick) + } else if (tick === 60) { + // Exactly on tick 60: primary detonation must trigger! + expect(step.hits.length, `Expected detonation at tick 60, but got 0 hits`).toBe(1) + expect(step.hits[0]!.missileName).toBe('meteorexplode') + expect(step.hits[0]!.damage).toBeGreaterThan(0) + } else { + // Past tick 60: meteorcenter must be expired and removed from active missiles + const center = engine.getActiveMissiles().find(m => m.name === 'meteorcenter') + expect(center, `meteorcenter lingered past tick 60 at tick ${tick}`).toBeUndefined() + } + } + + expect(detonationTick, 'Detonation must happen exactly at tick 60').toBe(60) + expect(spawnedSubmissilesOnDetonation).toContain('meteorexplode') + expect(spawnedSubmissilesOnDetonation.filter(s => s === 'meteorfire').length).toBe(18) + }) + + it('verifies in tickProjectiles whether meteorcenter detonates at tick 60 or tick 61', () => { + let center: Projectile = { + skillId: '56', + x: 300, + y: 300, + vx: 0, + vy: 0, + damage: 500, + ttl: 60, + fromPlayer: true, + missileType: 'meteorcenter', + slvl: 1, + } + + const dummyMonster = [{ index: 0, x: 300, y: 300, radius: 20, alive: true }] + + let detonationTick: number | null = null + + for (let tick = 1; tick <= 65; tick++) { + const outcome = tickProjectiles([center], dummyMonster, openTerrain) + + if (outcome.hits.length > 0 && detonationTick === null) { + detonationTick = tick + } + + if (outcome.alive.length > 0) { + center = outcome.alive[0]! + } else { + center = { ...center, ttl: -1 } + } + } + + // 1.13c Ground Truth: Meteor has Range: 60 ticks. + // If tickProjectiles detonates at tick 61 instead of tick 60, this test detects the off-by-one tick delay. + expect(detonationTick, `tickProjectiles detonation tick should be 60, but was ${detonationTick}`).toBe(60) + }) + }) + + // ========================================================================= + // Dimension 2: Dual Sprite Trajectory Synchronization (Core & Tail) + // ========================================================================= + describe('Dimension 2: Dual Sprite Trajectory Synchronization (Core & Tail)', () => { + it('verifies meteor core (ID 100) and tail (ID 102) exist and maintain synchronized coordinate descent', () => { + // 1.13c Ground Truth (D2Client.dll 0x6FB68840 / Missiles.txt): + // When meteorcenter (101) is cast, client children meteor (100) and meteortail (102) + // are spawned and descend from the sky to the ground target over 60 ticks. + // Altitude: Z(t) = (60 - t) * 25 + // Coordinate descent: X(t) = targetX - (60 - t) * 15, Y(t) = targetY - (60 - t) * 15 + // Both core and tail must share synchronized trajectory. + + const targetX = 400 + const targetY = 300 + + // Create core and tail projectiles + const core: Projectile = { + skillId: '56', + x: targetX, + y: targetY, + vx: 0, + vy: 0, + damage: 0, + ttl: 60, + fromPlayer: true, + missileType: 'meteor', + altitude: 60 * 25, + } + + const tail: Projectile = { + skillId: '56', + x: targetX, + y: targetY, + vx: 0, + vy: 0, + damage: 0, + ttl: 60, + fromPlayer: true, + missileType: 'meteortail', + altitude: 60 * 25, + } + + let activeProjectiles: Projectile[] = [core, tail] + + for (let t = 1; t <= 60; t++) { + const step = tickProjectiles(activeProjectiles, [], openTerrain) + activeProjectiles = step.alive as Projectile[] + + const curCore = activeProjectiles.find(p => p.missileType === 'meteor') + const curTail = activeProjectiles.find(p => p.missileType === 'meteortail') + + // If core and tail are implemented in simulation: + if (curCore !== undefined && curTail !== undefined) { + // Verify synchronized coordinates + expect(curCore.x).toBe(curTail.x) + expect(curCore.y).toBe(curTail.y) + expect(curCore.altitude).toBe(curTail.altitude) + + // Verify monotonic descent + if (curCore.altitude !== undefined) { + expect(curCore.altitude).toBeLessThanOrEqual(60 * 25) + if (t === 60) { + expect(curCore.altitude).toBeLessThanOrEqual(0) + } + } + } else { + // If neither core nor tail is updated/supported, fail and report missing dual sprite trajectory + expect(curCore, `meteor (ID 100) core missile missing from simulation at tick ${t}`).toBeDefined() + expect(curTail, `meteortail (ID 102) tail missile missing from simulation at tick ${t}`).toBeDefined() + } + } + }) + }) + + // ========================================================================= + // Dimension 3: Exact 18 Residual Fire Patch Offsets Matching 0x6FD1C918/8D0 + // ========================================================================= + describe('Dimension 3: Exact 18 Residual Fire Patch Offsets (0x6FD1C918 / 0x6FD1C8D0)', () => { + it('verifies verbatim match of all 18 subtile offset pairs against D2Game.dll ground truth', () => { + const GROUND_TRUTH_OFFSETS = [ + { x: 2, y: -2 }, // [0] Inner Ring + { x: -2, y: -2 }, // [1] Inner Ring + { x: 0, y: 2 }, // [2] Inner Ring + { x: 0, y: 5 }, // [3] North Tip + { x: -3, y: 3 }, // [4] North-West Outer + { x: 0, y: 3 }, // [5] Center-North + { x: 3, y: 3 }, // [6] North-East Outer + { x: -1, y: 2 }, // [7] Center Cluster + { x: 1, y: 1 }, // [8] Center Cluster + { x: -1, y: -1 }, // [9] Center Cluster + { x: 2, y: -1 }, // [10] Center Cluster + { x: -4, y: -2 }, // [11] West Tip + { x: -3, y: -2 }, // [12] West Outer + { x: -1, y: -3 }, // [13] South-West Outer + { x: 0, y: -4 }, // [14] South Tip + { x: 1, y: -3 }, // [15] South-East Outer + { x: 3, y: -3 }, // [16] East Outer + { x: 4, y: -2 }, // [17] East Tip + ] + + expect(METEOR_FIRE_SUBTILE_OFFSETS.length).toBe(18) + for (let i = 0; i < 18; i++) { + expect(METEOR_FIRE_SUBTILE_OFFSETS[i]).toEqual(GROUND_TRUTH_OFFSETS[i]) + } + }) + + it('verifies that detonation spawns exactly 18 meteorfire submissiles at correctly scaled pixel coordinates', () => { + const centerX = 500 + const centerY = 500 + + const center: Projectile = { + skillId: '56', + x: centerX, + y: centerY, + vx: 0, + vy: 0, + damage: 300, + ttl: 0, // detonates immediately + fromPlayer: true, + missileType: 'meteorcenter', + slvl: 1, + } + + const outcome = tickProjectiles([center], [], openTerrain) + const spawnedFlames = outcome.spawnedProjectiles?.filter(p => p.missileType === 'meteorfire') ?? [] + + expect(spawnedFlames.length).toBe(18) + + for (let i = 0; i < 18; i++) { + const off = METEOR_FIRE_SUBTILE_OFFSETS[i]! + const expectedX = centerX + off.x * 20 + const expectedY = centerY + off.y * 10 + + const flame = spawnedFlames.find(f => f.x === expectedX && f.y === expectedY) + expect(flame, `Missing meteorfire at offset [${i}] (${expectedX}, ${expectedY})`).toBeDefined() + expect(flame!.ttl).toBe(30) // slvl 1: 30 ticks + expect(flame!.pierce).toBe(true) + } + }) + }) + + // ========================================================================= + // Dimension 4: Wall Collision Drop (COLLIDE_WALL | COLLIDE_BLANK) + // ========================================================================= + describe('Dimension 4: Wall Collision Drop (COLLIDE_WALL | COLLIDE_BLANK)', () => { + it('verifies fire patches falling inside solid wall cells are completely rejected in tickProjectiles', () => { + const centerX = 300 + const centerY = 300 + + const center: Projectile = { + skillId: '56', + x: centerX, + y: centerY, + vx: 0, + vy: 0, + damage: 300, + ttl: 0, + fromPlayer: true, + missileType: 'meteorcenter', + slvl: 1, + } + + // Case 1: East half is a wall (x > centerX = 300) + const halfWallTerrain = { + overlap: (x: number, _y: number) => (x > 300 ? 1 : 0), + } + + const resHalf = tickProjectiles([center], [], halfWallTerrain) + const flamesHalf = resHalf.spawnedProjectiles?.filter(p => p.missileType === 'meteorfire') ?? [] + + expect(flamesHalf.length).toBeGreaterThan(0) + expect(flamesHalf.length).toBeLessThan(18) + for (const f of flamesHalf) { + expect(f.x).toBeLessThanOrEqual(300) + } + + // Case 2: Entire area is a solid wall + const fullWallTerrain = { + overlap: () => 1, + } + + const resFull = tickProjectiles([center], [], fullWallTerrain) + const flamesFull = resFull.spawnedProjectiles?.filter(p => p.missileType === 'meteorfire') ?? [] + expect(flamesFull.length).toBe(0) // All 18 fire patches dropped! + + // Case 3: Selective pinpoint wall blocking North Tip [3] (0, 5) -> (300, 350) + const northTipX = centerX + 0 * 20 + const northTipY = centerY + 5 * 10 + const selectiveTerrain = { + overlap: (x: number, y: number) => (x === northTipX && y === northTipY ? 1 : 0), + } + + const resSelective = tickProjectiles([center], [], selectiveTerrain) + const flamesSelective = resSelective.spawnedProjectiles?.filter(p => p.missileType === 'meteorfire') ?? [] + expect(flamesSelective.length).toBe(17) + expect(flamesSelective.some(f => f.x === northTipX && f.y === northTipY)).toBe(false) + }) + + it('checks whether MissileEngine implements wall collision filtering for meteorfire spawning', async () => { + const registry = await getSharedDataRegistry() + const engine = new MissileEngine(registry) + + const ownerStatList = new UnitStatList(registry, {}) + const owner: CombatUnitContext = { + id: 'sorc', + name: 'Sorceress', + isUndead: false, + isDemon: false, + statList: ownerStatList, + stateBus: new StateBus(ownerStatList, registry), + } + + engine.spawnMissile({ + missileNameOrId: 'meteorcenter', + sourceSkillId: 56, + slvl: 1, + owner, + startX: 200, + startY: 200, + targetX: 200, + targetY: 200, + dmgPacket: { + skillId: 56, + attackKind: 'spell', + elemType: 'fire', + elemMin256: 80 * 256, + elemMax256: 100 * 256, + }, + }) + + // Tick 60 to trigger detonation + for (let t = 1; t <= 60; t++) { + engine.tick(t, [], new Map()) + } + + const activeFires = engine.getActiveMissiles().filter(m => m.name === 'meteorfire') + // When no collision terrain is provided, all 18 submissiles spawn + expect(activeFires.length).toBe(18) + }) + + it('verifies in MissileEngine that meteorfire submissiles landing in solid walls are dropped', async () => { + const registry = await getSharedDataRegistry() + // Terrain with eastern wall (x > 200) + const halfWallTerrain = { + overlap: (x: number, _y: number) => (x > 200 ? 1 : 0), + } + const engine = new MissileEngine(registry, halfWallTerrain) + + const ownerStatList = new UnitStatList(registry, {}) + const owner: CombatUnitContext = { + id: 'sorc', + name: 'Sorceress', + isUndead: false, + isDemon: false, + statList: ownerStatList, + stateBus: new StateBus(ownerStatList, registry), + } + + engine.spawnMissile({ + missileNameOrId: 'meteorcenter', + sourceSkillId: 56, + slvl: 1, + owner, + startX: 200, + startY: 200, + targetX: 200, + targetY: 200, + dmgPacket: { + skillId: 56, + attackKind: 'spell', + elemType: 'fire', + elemMin256: 80 * 256, + elemMax256: 100 * 256, + }, + }) + + // Tick 60 to trigger detonation + for (let t = 1; t <= 60; t++) { + engine.tick(t, [], new Map()) + } + + const activeFires = engine.getActiveMissiles().filter(m => m.name === 'meteorfire') + expect(activeFires.length).toBeGreaterThan(0) + expect(activeFires.length).toBeLessThan(18) + expect(activeFires.every(m => m.x <= 200)).toBe(true) + }) + }) + + // ========================================================================= + // Dimension 5: 50,000-Tick Continuous Rapid-Casting Stress Simulation + // ========================================================================= + describe('Dimension 5: 50,000-Tick Continuous Rapid-Casting Stress Simulation', () => { + it('runs 50,000 ticks of continuous Meteor casting without crashes, NaNs, or memory leaks', () => { + const simStartMs = Date.now() + const heapBefore = process.memoryUsage().heapUsed + + let activeProjectiles: Projectile[] = [] + let totalCasts = 0 + let totalHits = 0 + let totalWallHits = 0 + let totalExpired = 0 + let peakActiveCount = 0 + + // Dynamic moving targets in combat arena + const targets: ProjectileTarget[] = [ + { index: 0, x: 250, y: 250, radius: 25, alive: true }, + { index: 1, x: 270, y: 260, radius: 20, alive: true }, + { index: 2, x: 230, y: 240, radius: 20, alive: true }, + { index: 3, x: 260, y: 230, radius: 20, alive: true }, + { index: 4, x: 350, y: 350, radius: 30, alive: true }, + ] + + // Semi-solid map terrain with boundary walls and interior pillar + const hybridTerrain = { + overlap: (x: number, y: number) => { + if (x < 30 || x > 470 || y < 30 || y > 470) return 1 // perimeter walls + if (x >= 280 && x <= 320 && y >= 280 && y <= 320) return 1 // central obstacle pillar + return 0 + }, + } + + // 50,000 ticks = 2,000 seconds = 33.3 minutes of non-stop game simulation + for (let tick = 0; tick < 50000; tick++) { + // Cast Meteor on cooldown (every 30 ticks = 1.20s cast delay) + if (tick % 30 === 0) { + totalCasts++ + // Slight spatial variation across casts + const cx = 250 + ((tick / 30) % 7) * 8 - 24 + const cy = 250 + ((tick / 30) % 5) * 10 - 20 + + activeProjectiles.push({ + skillId: '56', + x: cx, + y: cy, + vx: 0, + vy: 0, + damage: 200, + ttl: 60, // 2.40s countdown + fromPlayer: true, + missileType: 'meteorcenter', + slvl: 1, + residualDamage: 25, + fireDuration: 30, + }) + } + + // Monsters move slightly every 8 ticks + if (tick % 8 === 0) { + for (let m = 0; m < targets.length; m++) { + const angle = (tick * 0.04) + m * 1.2 + targets[m] = { + ...targets[m]!, + x: 250 + Math.cos(angle) * 35, + y: 250 + Math.sin(angle) * 20 * ISO_GROUND_ASPECT_RATIO, + } + } + } + + // Advance simulation tick + const outcome = tickProjectiles(activeProjectiles, targets, hybridTerrain) + activeProjectiles = outcome.alive as Projectile[] + totalHits += outcome.hits.length + totalWallHits += outcome.wallHits + totalExpired += outcome.expired + + if (activeProjectiles.length > peakActiveCount) { + peakActiveCount = activeProjectiles.length + } + + // INVARIANT 1: Bounded missile count — no runaway projectile accumulation! + // At any tick: + // - At most 2 falling meteorcenters (ttl 60 / 30 = 2) + // - At most 1 meteorexplode (ttl 16) + // - At most 18-36 active residual fire patches (ttl 30) + // Peak active projectiles should never exceed 100. + if (activeProjectiles.length > 100) { + throw new Error(`Runaway projectile leak at tick ${tick}: active count reached ${activeProjectiles.length}`) + } + + // INVARIANT 2: Zero NaNs or non-finite numbers in any active missile + for (const p of activeProjectiles) { + if (!Number.isFinite(p.x) || !Number.isFinite(p.y)) { + throw new Error(`NaN detected in position at tick ${tick}: x=${p.x}, y=${p.y}`) + } + if (p.altitude !== undefined && (!Number.isFinite(p.altitude) || p.altitude < 0)) { + throw new Error(`Invalid altitude at tick ${tick}: altitude=${p.altitude}`) + } + if (!Number.isFinite(p.ttl) || p.ttl < 0) { + throw new Error(`Invalid ttl at tick ${tick}: ttl=${p.ttl}`) + } + } + } + + const simElapsedMs = Date.now() - simStartMs + const heapAfter = process.memoryUsage().heapUsed + const heapDiffMb = (heapAfter - heapBefore) / (1024 * 1024) + + // Assertions on 50,000 tick completion + expect(totalCasts).toBe(Math.floor(50000 / 30) + 1) // 1,667 Meteor casts + expect(totalHits).toBeGreaterThan(1000) // thousands of hits registered + expect(peakActiveCount).toBeLessThanOrEqual(65) // comfortably bounded + expect(activeProjectiles.length).toBeLessThanOrEqual(40) // final active count bounded + expect(heapDiffMb).toBeLessThan(25) // stable heap (< 25 MB delta) + + console.log(`[50,000-Tick Meteor Stress Sim Completed]`) + console.log(` Duration: ${simElapsedMs} ms (${(50000 / (simElapsedMs / 1000)).toFixed(0)} ticks/sec)`) + console.log(` Total Casts: ${totalCasts}`) + console.log(` Total Hits: ${totalHits}`) + console.log(` Total Wall Hits: ${totalWallHits}`) + console.log(` Total Expired Missiles: ${totalExpired}`) + console.log(` Peak Active Projectiles: ${peakActiveCount}`) + console.log(` Final Active Projectiles: ${activeProjectiles.length}`) + console.log(` Heap Diff: ${heapDiffMb.toFixed(2)} MB`) + }) + }) +}) diff --git a/tests/skills/sor/skill-056-meteor.test.ts b/tests/skills/sor/skill-056-meteor.test.ts index 71ebbf5..2499889 100644 --- a/tests/skills/sor/skill-056-meteor.test.ts +++ b/tests/skills/sor/skill-056-meteor.test.ts @@ -11,7 +11,7 @@ * Dimension 6: 18-offset ground fire geometry and duration scaling (30 + (slvl - 1) * 15 ticks). * Dimension 7: Residual ground fire periodic burn damage with NextDelay = 0. */ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import fs from 'node:fs' import path from 'node:path' import { getSharedDataRegistry } from '../../../src/game/engine/data-registry.ts' @@ -42,6 +42,8 @@ import { METEORFIRE_META, MISSILE_METAS, } from '../../../src/render/missiles-meta.ts' +import { drawMissileProjectile, type LoadedMissileArt } from '../../../src/scene/act-scene.ts' +import type { SpriteRenderer, AtlasHandle } from '../../../src/render/renderer.ts' describe('[Skill #056] Meteor (SOR) — 1.13c Parity (Issue #210 & #401)', () => { it('evaluates exact 1.13c Skills.txt / Missiles.txt formulas at slvl 1, 10, and 20', async () => { @@ -274,23 +276,17 @@ describe('[Skill #056] Meteor (SOR) — 1.13c Parity (Issue #210 & #401)', () => const dummyMonster = [{ index: 0, x: 300, y: 300, radius: 30, alive: true }] const openTerrain = { overlap: () => 0 } - // Over ticks 1 to 60, meteorcenter must not damage monster despite standing at exact same coordinates - for (let tick = 1; tick <= 60; tick += 1) { + // Over ticks 1 to 59, meteorcenter must not damage monster despite standing at exact same coordinates + for (let tick = 1; tick < 60; tick += 1) { const outcome = tickProjectiles([center], dummyMonster, openTerrain) expect(outcome.hits.length).toBe(0) - if (tick < 60) { - expect(outcome.alive.length).toBe(1) - center = outcome.alive[0]! - expect(center.missileType).toBe('meteorcenter') - expect(center.ttl).toBe(60 - tick) - } else { - // At tick 60, center.ttl reaches 0 - center = outcome.alive[0]! - expect(center.ttl).toBe(0) - } + expect(outcome.alive.length).toBe(1) + center = outcome.alive[0]! + expect(center.missileType).toBe('meteorcenter') + expect(center.ttl).toBe(60 - tick) } - // On tick 61, center with ttl 0 expires and detonates + // Exactly on tick 60, center expires and detonates const finalOutcome = tickProjectiles([center], dummyMonster, openTerrain) expect(finalOutcome.expired).toBe(1) expect(finalOutcome.hits.length).toBe(1) // monster takes detonation damage on expiry! @@ -478,4 +474,179 @@ describe('[Skill #056] Meteor (SOR) — 1.13c Parity (Issue #210 & #401)', () => expect(hitCount).toBe(3) expect(flame.ttl).toBe(20) // remains alive, doesn't vanish on hit }) + + it('Dimension 8: Dual Sprite Falling Trajectory & Trans: 1 Additive Blending in drawMissileProjectile', () => { + const drawCalls: { frame: any; x: number; y: number; options?: any }[] = [] + const mockRenderer = { + draw(frame: any, x: number, y: number, options?: any) { + drawCalls.push({ frame, x, y, options }) + }, + drawSolid: vi.fn(), + } as unknown as SpriteRenderer + + // Build mock art map for meteor, meteortail, meteorcenter + const makeMockArt = (meta: any): LoadedMissileArt => ({ + meta, + handle: 1 as unknown as AtlasHandle, + frames: [ + meta.groups[0].map(([x, y, w, h, ax, ay]: number[]) => ({ + x, + y, + width: w, + height: h, + anchorX: ax, + anchorY: ay, + })), + ], + }) + + const artMap = new Map([ + ['meteor', makeMockArt(METEOR_META)], + ['meteortail', makeMockArt(METEORTAIL_META)], + ['meteorcenter', makeMockArt(METEORCENTER_META)], + ]) + + const centerShot: Projectile = { + skillId: '56', + missileType: 'meteorcenter', + x: 400, + y: 300, + vx: 0, + vy: 0, + damage: 500, + ttl: 60, // At initial cast (60 remaining ticks) + fromPlayer: true, + slvl: 1, + } + + // Tick 0 (ttl = 60): Ground indicator + falling tail + fireball core at altitude 1500px, slideX -900px + drawMissileProjectile(mockRenderer, centerShot, artMap.get('meteorcenter'), artMap) + expect(drawCalls.length).toBe(3) // 1 ground indicator, 1 flame tail, 1 meteor core + + // 1. Ground target indicator (MeteorTargetIndicator) + expect(drawCalls[0]!.options?.blendMode).toBe('additive') + expect(drawCalls[0]!.x).toBe(400 - 56) // anchorX = -56 + expect(drawCalls[0]!.y).toBe(300 - 43) // anchorY = -43 + + // 2. Atmospheric flame tail (MeteorTail) + expect(drawCalls[1]!.options?.blendMode).toBe('additive') + // slideX = -60 * 15 = -900 -> meteorX = 400 - 900 = -500; anchorX = -49 -> drawX = -549 + expect(drawCalls[1]!.x).toBe(400 - 60 * 15 - 49) + // altitude = 60 * 25 = 1500 -> meteorY = 300 - 143 - 1500 = -1343 + expect(drawCalls[1]!.y).toBe(300 - 143 - 60 * 25) + + // 3. Falling meteor fireball core (Meteor) + expect(drawCalls[2]!.options?.blendMode).toBe('additive') + expect(drawCalls[2]!.x).toBe(400 - 60 * 15 - 7) // anchorX = -7 + expect(drawCalls[2]!.y).toBe(300 - 55 - 60 * 25) // anchorY = -55 + + // Tick 30 (ttl = 30): Mid-flight descent (altitude = 750px, slideX = -450px) + drawCalls.length = 0 + const midShot: Projectile = { ...centerShot, ttl: 30 } + drawMissileProjectile(mockRenderer, midShot, artMap.get('meteorcenter'), artMap) + expect(drawCalls.length).toBe(3) + expect(drawCalls[1]!.x).toBe(400 - 30 * 15 - 49) + expect(drawCalls[1]!.y).toBe(300 - 143 - 30 * 25) + expect(drawCalls[2]!.x).toBe(400 - 30 * 15 - 7) + expect(drawCalls[2]!.y).toBe(300 - 55 - 30 * 25) + + // Tick 60 (ttl = 0 / impact): Falling fireball hits ground, only ground indicator rendered + drawCalls.length = 0 + const impactShot: Projectile = { ...centerShot, ttl: 0 } + drawMissileProjectile(mockRenderer, impactShot, artMap.get('meteorcenter'), artMap) + expect(drawCalls.length).toBe(1) // Only ground indicator, zero sky fireball + }) + + it('Dimension 9: Animation frame indexing progression for meteorcenter and meteorfire sublooping', () => { + const drawnFrames: number[] = [] + const mockRenderer = { + draw(frame: any) { + drawnFrames.push(frame.frameIdx) + }, + drawSolid: vi.fn(), + } as unknown as SpriteRenderer + + // Build art with tracked frameIdx + 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 centerArt = makeIndexedArt(METEORCENTER_META) + const fireArt = makeIndexedArt(METEORFIRE_META) + + // 1. meteorcenter starts at frame 0 and advances forward + for (let elapsed = 0; elapsed < 17; elapsed++) { + drawnFrames.length = 0 + const shot: Projectile = { + skillId: '56', + missileType: 'meteorcenter', + x: 0, + y: 0, + vx: 0, + vy: 0, + damage: 0, + ttl: 60 - elapsed, + ageTicks: elapsed, + fromPlayer: true, + } + drawMissileProjectile(mockRenderer, shot, centerArt) + expect(drawnFrames[0]).toBe(elapsed) + } + + // 2. meteorfire plays emergence once (frames 0..11) then loops 12..36 indefinitely + // Emergence phase + for (let elapsed = 0; elapsed < 12; elapsed++) { + drawnFrames.length = 0 + const shot: Projectile = { + skillId: '56', + missileType: 'meteorfire', + x: 0, + y: 0, + vx: 0, + vy: 0, + damage: 10, + ttl: 315 - elapsed, + ageTicks: elapsed, + fromPlayer: true, + fireDuration: 315, + } + drawMissileProjectile(mockRenderer, shot, fireArt) + expect(drawnFrames[0]).toBe(elapsed) + } + + // Looping phase (frames 12..36, 25-frame cycle) + for (let elapsed = 12; elapsed <= 70; elapsed++) { + drawnFrames.length = 0 + const shot: Projectile = { + skillId: '56', + missileType: 'meteorfire', + x: 0, + y: 0, + vx: 0, + vy: 0, + damage: 10, + ttl: 315 - elapsed, + ageTicks: elapsed, + fromPlayer: true, + fireDuration: 315, + } + drawMissileProjectile(mockRenderer, shot, fireArt) + const expectedFrame = 12 + ((elapsed - 12) % 25) + expect(drawnFrames[0]).toBe(expectedFrame) + expect(drawnFrames[0]!).toBeGreaterThanOrEqual(12) + expect(drawnFrames[0]!).toBeLessThanOrEqual(36) + } + }) })