/** * Adversarial Empirical Stress Testing Suite for Milestone 3 (Geometry, Collision & Tick Mechanics) * * Target Skills: * - Skill 51: Fire Wall (15-patch perpendicular geometry, obstacle collision clipping) * - Skill 46: Blaze (footstep trail >= 24px, self-hit immunity under sharp turns & stationary standing) * - Skill 41: Inferno & Ground Fire (continuous tick hitTargetIds.clear() resets) * - 2:1 Isometric Aspect Ratio (ISO_GROUND_ASPECT_RATIO = 0.5 ground collision ellipse) */ import { describe, expect, it } from 'vitest' import { getSharedDataRegistry } from '../../../src/baker/mpq-archive-source.ts' import { UnitStatList } from '../../../src/server/engine/stat-list.ts' import { StateBus } from '../../../src/server/engine/state-bus.ts' import { MissileEngine, calculateFireWallPatches, } from '../../../src/server/engine/missile-engine.ts' import type { CombatUnitContext } from '../../../src/server/engine/combat-pipeline.ts' import { tickProjectiles, ISO_GROUND_ASPECT_RATIO, type Projectile, type ProjectileTarget, } from '../../../src/server/skills/skills.ts' import { GameEngine } from '../../../src/server/engine/engine.ts' import { COLLIDE_WALL, COLLIDE_NONE } from '../../../src/common/world/d2map.ts' function createMockCombatUnit( id: string, options?: { hp?: number fireResist?: number isPlayer?: boolean }, ): CombatUnitContext { const statList = new UnitStatList() const hp = options?.hp ?? 1000 statList.setBaseStat('hp', hp * 256) statList.setBaseStat('maxhp', hp * 256) statList.setBaseStat('hitpoints', hp * 256) if (options?.fireResist !== undefined) { statList.setBaseStat('fireresist', options.fireResist) } const stateBus = new StateBus(statList) return { id, name: id, statList, stateBus, } } describe('Adversarial Stress Suite — Milestone 3 Geometry, Collision & Tick Mechanics', () => { // ========================================================================== // Dimension 1: Fire Wall 15-Patch Perpendicular Geometry at Arbitrary Cast Angles // ========================================================================== describe('Dimension 1: Fire Wall 15-Patch Perpendicular Geometry', () => { const testAngles = [ { name: '0° (East)', deg: 0, rad: 0 }, { name: '30° (East-South-East)', deg: 30, rad: Math.PI / 6 }, { name: '45° (South-East)', deg: 45, rad: Math.PI / 4 }, { name: '90° (South)', deg: 90, rad: Math.PI / 2 }, { name: '135° (South-West)', deg: 135, rad: (3 * Math.PI) / 4 }, { name: '180° (West)', deg: 180, rad: Math.PI }, { name: '270° (North)', deg: 270, rad: (3 * Math.PI) / 2 }, { name: '315° (North-East)', deg: 315, rad: (7 * Math.PI) / 4 }, // Arbitrary non-cardinal angles { name: '17° (Arbitrary acute)', deg: 17, rad: (17 * Math.PI) / 180 }, { name: '63.43° (atan2(2, 1))', deg: 63.43, rad: Math.atan2(2, 1) }, { name: '112.5° (Obtuse)', deg: 112.5, rad: (112.5 * Math.PI) / 180 }, { name: '200° (Arbitrary 3rd quadrant)', deg: 200, rad: (200 * Math.PI) / 180 }, { name: '285° (Arbitrary 4th quadrant)', deg: 285, rad: (285 * Math.PI) / 180 }, ] it.each(testAngles)('verifies exact 15-patch orthogonality and 24px spacing at angle $name', ({ rad }) => { const casterX = 150 const casterY = 150 const distance = 200 const targetX = Math.round(casterX + distance * Math.cos(rad)) const targetY = Math.round(casterY + distance * Math.sin(rad)) const patches = calculateFireWallPatches(casterX, casterY, targetX, targetY, { count: 15, spacing: 24, }) // 1. Exactly 15 patches expect(patches).toHaveLength(15) // 2. Strict index sequence from -7 to +7 const indices = patches.map(p => p.index) expect(indices).toEqual([-7, -6, -5, -4, -3, -2, -1, 0, 1, 2, 3, 4, 5, 6, 7]) // 3. Center patch (index 0) must be exactly at target const center = patches.find(p => p.index === 0)! expect(center.x).toBe(targetX) expect(center.y).toBe(targetY) // 4. Perpendicularity test: // In 2:1 isometric projection, travel vector is (dx, dy). // The unscaled normal vector is (nx, ny). // Dot product of cast vector and patch displacement in unscaled space must be 0: const travelDx = targetX - casterX const travelDy = targetY - casterY const travelLen = Math.hypot(travelDx, travelDy) for (const p of patches) { if (p.index === 0) continue const dispX = p.x - targetX const unscaledDispY = (p.y - targetY) / ISO_GROUND_ASPECT_RATIO // Dot product: travelDx * dispX + travelDy * unscaledDispY const dot = travelDx * dispX + travelDy * unscaledDispY const dispLen = Math.hypot(dispX, unscaledDispY) // Normalized dot product should be close to 0 (tolerance for Math.round integer coords) const normalizedDot = Math.abs(dot) / (travelLen * dispLen) expect(normalizedDot).toBeLessThan(0.08) // < 4.5 degrees angular error caused by integer rounding // Distance from center in unscaled ground coordinates: |i| * 24 px const expectedDist = Math.abs(p.index) * 24 expect(dispLen).toBeGreaterThanOrEqual(expectedDist - 1.5) expect(dispLen).toBeLessThanOrEqual(expectedDist + 1.5) } // 5. Inter-patch spacing: adjacent patches must be ~24px apart in unscaled coordinates for (let i = 0; i < patches.length - 1; i++) { const pCurrent = patches[i]! const pNext = patches[i + 1]! const stepDist = Math.hypot( pNext.x - pCurrent.x, (pNext.y - pCurrent.y) / ISO_GROUND_ASPECT_RATIO, ) expect(stepDist).toBeGreaterThanOrEqual(22.5) expect(stepDist).toBeLessThanOrEqual(25.5) } }) it('verifies degenerate zero-distance cast (caster === target) produces fallback 15 vertical patches', () => { const x = 300 const y = 200 const patches = calculateFireWallPatches(x, y, x, y, { count: 15, spacing: 24, }) expect(patches).toHaveLength(15) const center = patches.find(p => p.index === 0)! expect(center.x).toBe(x) expect(center.y).toBe(y) // Fallback normal is (0, 1): patches align vertically with 12px screen spacing for (const p of patches) { expect(p.x).toBe(x) expect(p.y).toBe(y + p.index * 24 * ISO_GROUND_ASPECT_RATIO) } }) it('handles negative and extreme world coordinates robustly', () => { const casterX = -5000 const casterY = -12000 const targetX = -4800 const targetY = -11900 const patches = calculateFireWallPatches(casterX, casterY, targetX, targetY, { count: 15, spacing: 24, }) expect(patches).toHaveLength(15) expect(patches.find(p => p.index === 0)!.x).toBe(targetX) expect(patches.find(p => p.index === 0)!.y).toBe(targetY) for (const p of patches) { expect(Number.isFinite(p.x)).toBe(true) expect(Number.isFinite(p.y)).toBe(true) } }) }) // ========================================================================== // Dimension 2: Obstacle Collision Clipping // ========================================================================== describe('Dimension 2: Obstacle Collision Clipping', () => { it('omits all patches landing inside solid COLLIDE_WALL obstacles and retains valid open ground patches', () => { const casterX = 100 const casterY = 100 const targetX = 200 const targetY = 100 // Travel is horizontal (+X). Normal is vertical (+Y). // Wall boundary at y >= 130 // Normal line has y = 100 + i * 12. // i = 0 -> y = 100 (open) // i = 1 -> y = 112 (open) // i = 2 -> y = 124 (open) // i = 3 -> y = 136 (BLOCKED: y >= 130) // i = 4..7 -> y >= 148 (BLOCKED: y >= 130) const isBlockedWall = (x: number, y: number) => y >= 130 const patches = calculateFireWallPatches(casterX, casterY, targetX, targetY, { count: 15, spacing: 24, isBlocked: isBlockedWall, }) // Indices 3, 4, 5, 6, 7 are inside solid obstacle -> 5 patches omitted expect(patches.length).toBe(10) for (const p of patches) { expect(p.y).toBeLessThan(130) expect(isBlockedWall(p.x, p.y)).toBe(false) } expect(patches.some(p => p.index === 3)).toBe(false) expect(patches.some(p => p.index === 7)).toBe(false) expect(patches.some(p => p.index === 0)).toBe(true) expect(patches.some(p => p.index === -7)).toBe(true) }) it('clips patches when target (center) or both wings intersect obstacle geometry', () => { // Complete obstacle: entire room is blocked const allBlocked = calculateFireWallPatches(0, 0, 100, 100, { count: 15, spacing: 24, isBlocked: () => true, }) expect(allBlocked).toHaveLength(0) // Target itself blocked: index 0 omitted, non-blocked wings remain const centerBlocked = calculateFireWallPatches(100, 100, 200, 100, { count: 15, spacing: 24, isBlocked: (x, y) => x === 200 && y === 100, }) expect(centerBlocked).toHaveLength(14) expect(centerBlocked.some(p => p.index === 0)).toBe(false) }) it('kills ground fire patch immediately on tick 1 when intersecting COLLIDE_WALL in tickProjectiles', () => { const solidPatch: Projectile = { skillId: '51', x: 150, y: 150, vx: 0, vy: 0, damage: 100, ttl: 90, fromPlayer: true, missileType: 'firewall', pierce: true, aoeRadius: 24, } const enemy = { index: 1, alive: true, x: 150, y: 150, radius: 16, } // Terrain obstacle returning COLLIDE_WALL const wallTerrain = { overlap: (x: number, y: number) => (x >= 140 && y >= 140 ? COLLIDE_WALL : COLLIDE_NONE), } const outcome = tickProjectiles([solidPatch], [enemy], wallTerrain) // Projectile collided with wall: wallHits incremented, patch dies, 0 damage dealt to enemy expect(outcome.wallHits).toBe(1) expect(outcome.alive).toHaveLength(0) expect(outcome.hits).toHaveLength(0) }) it('kills Blaze patch when placed on COLLIDE_WALL obstacle and inflicts zero self-damage', () => { const blazePatch: Projectile = { skillId: '46', x: 220, y: 220, vx: 0, vy: 0, damage: 50, ttl: 115, fromPlayer: true, missileType: 'blaze', pierce: true, aoeRadius: 24, } const wallTerrain = { overlap: (x: number, y: number) => (x >= 200 ? COLLIDE_WALL : COLLIDE_NONE), } const outcome = tickProjectiles([blazePatch], [], wallTerrain) expect(outcome.wallHits).toBe(1) expect(outcome.alive).toHaveLength(0) }) }) // ========================================================================== // Dimension 3: Blaze Footstep Trail & Self-Hit Immunity // ========================================================================== describe('Dimension 3: Blaze Footstep Trail & Self-Hit Immunity', () => { it('spawns footstep patches strictly when player displacement >= 24px and suppresses sub-24px movement', () => { const terrain = { widthPx: 2000, heightPx: 2000, overlap: () => 0 } const engineOpts = { spawn: { x: 100, y: 100 }, stats: [], xpTable: [], itemBases: [], prefixAffixes: [], suffixAffixes: [], skills: [], npcDefs: [], questDefs: [], combatOptions: { playerSpeed: 100, playerReach: 40, playerCooldownTicks: 12, playerDamage: 5, playerManaPerAttack: 1, respawnTicks: 50, disableMonsterAggro: false, }, lootSeed: 1, talkRadius: 50, pickupRadius: 30, inventoryCols: 10, inventoryRows: 4, } const engine = new GameEngine(terrain as any, engineOpts as any) const idleInput = { movement: { x: 0, y: 0 }, attacking: false, digits: [], pickingUp: false, talking: false, saving: false, loading: false, } engine.world.player.x = 100 engine.world.player.y = 100 engine.world.player.blazeTicks = 100 engine.world.player.blazeDamage = 40 engine.world.player.blazePatchDuration = 115 // Tick 1: Initial activation (lastBlazeX is undefined) -> spawns 1st patch at (100, 100) engine.tick(idleInput) expect(engine.projectiles).toHaveLength(1) expect(engine.projectiles[0]!.missileType).toBe('blaze') expect(engine.projectiles[0]!.x).toBe(100) expect(engine.projectiles[0]!.y).toBe(100) expect(engine.world.player.lastBlazeX).toBe(100) expect(engine.world.player.lastBlazeY).toBe(100) // Tick 2: Player moves 10px to (110, 100) -> dist = 10px < 24px -> NO new patch engine.world.player.x = 110 engine.tick(idleInput) expect(engine.projectiles).toHaveLength(1) // Tick 3: Player moves to (123.9, 100) -> dist = 23.9px < 24px -> NO new patch engine.world.player.x = 123.9 engine.tick(idleInput) expect(engine.projectiles).toHaveLength(1) // Tick 4: Player moves to (124, 100) -> dist = 24.0px >= 24px -> SPAWNS 2nd patch! engine.world.player.x = 124 engine.tick(idleInput) expect(engine.projectiles).toHaveLength(2) expect(engine.projectiles[1]!.x).toBe(124) expect(engine.projectiles[1]!.y).toBe(100) expect(engine.world.player.lastBlazeX).toBe(124) // Tick 5..20: Player remains stationary at (124, 100) for 15 frames -> 0 new patches for (let t = 0; t < 15; t++) { engine.tick(idleInput) } expect(engine.projectiles).toHaveLength(2) // Tick 21: Player moves diagonally by (17, 17) -> dist = hypot(17, 17) = 24.04px >= 24px -> SPAWNS 3rd patch! engine.world.player.x = 124 + 17 engine.world.player.y = 100 + 17 engine.tick(idleInput) expect(engine.projectiles).toHaveLength(3) expect(engine.projectiles[2]!.x).toBe(141) expect(engine.projectiles[2]!.y).toBe(117) }) it('enforces absolute self-hit immunity: player standing stationary on Blaze patches takes 0 damage', () => { const blazePatch: Projectile = { skillId: '46', x: 300, y: 300, vx: 0, vy: 0, damage: 50, ttl: 100, fromPlayer: true, missileType: 'blaze', pierce: true, aoeRadius: 24, } const playerTarget = { index: 0, alive: true, x: 300, y: 300, radius: 16, isPlayer: true, } const enemyTarget = { index: 1, alive: true, x: 300, y: 300, radius: 16, isPlayer: false, } const terrain = { overlap: () => 0 } let activeProjectiles: readonly Projectile[] = [blazePatch] // Tick 20 frames continuously for (let f = 1; f <= 20; f++) { const outcome = tickProjectiles(activeProjectiles, [playerTarget, enemyTarget], terrain) activeProjectiles = outcome.alive // Player must NEVER be hit const playerHits = outcome.hits.filter(h => h.targetIndex === 0) expect(playerHits).toHaveLength(0) // Enemy must be hit every single frame const enemyHits = outcome.hits.filter(h => h.targetIndex === 1) expect(enemyHits).toHaveLength(1) expect(enemyHits[0]!.damage).toBe(50) } }) it('enforces self-hit immunity under sharp 180° hairpin turns through active Blaze patches', () => { // Player lays 3 Blaze patches in a line: (100, 100), (124, 100), (148, 100) const patches: Projectile[] = [ { skillId: '46', x: 100, y: 100, vx: 0, vy: 0, damage: 60, ttl: 100, fromPlayer: true, missileType: 'blaze', pierce: true, aoeRadius: 24, }, { skillId: '46', x: 124, y: 100, vx: 0, vy: 0, damage: 60, ttl: 100, fromPlayer: true, missileType: 'blaze', pierce: true, aoeRadius: 24, }, { skillId: '46', x: 148, y: 100, vx: 0, vy: 0, damage: 60, ttl: 100, fromPlayer: true, missileType: 'blaze', pierce: true, aoeRadius: 24, }, ] // Player turns 180° and runs back directly on top of patch 2 (124, 100) then patch 1 (100, 100) const playerAtPatch2 = { index: 0, alive: true, x: 124, y: 100, radius: 16, isPlayer: true } const terrain = { overlap: () => 0 } const outcome1 = tickProjectiles(patches, [playerAtPatch2], terrain) expect(outcome1.hits.filter(h => h.targetIndex === 0)).toHaveLength(0) const playerAtPatch1 = { index: 0, alive: true, x: 100, y: 100, radius: 16, isPlayer: true } const outcome2 = tickProjectiles(outcome1.alive, [playerAtPatch1], terrain) expect(outcome2.hits.filter(h => h.targetIndex === 0)).toHaveLength(0) }) it('enforces that enemy-cast Blaze patches DO damage the player', () => { const enemyBlazePatch: Projectile = { skillId: '46', x: 200, y: 200, vx: 0, vy: 0, damage: 45, ttl: 90, fromPlayer: false, // Cast by an enemy monster! missileType: 'blaze', pierce: true, aoeRadius: 24, } const playerTarget = { index: 0, alive: true, x: 200, y: 200, radius: 16, isPlayer: true, } const terrain = { overlap: () => 0 } const outcome = tickProjectiles([enemyBlazePatch], [playerTarget], terrain) // Player takes damage from hostile Blaze! expect(outcome.hits).toHaveLength(1) expect(outcome.hits[0]!.targetIndex).toBe(0) expect(outcome.hits[0]!.damage).toBe(45) }) }) // ========================================================================== // Dimension 4: Continuous Tick Reset (m.hitTargetIds.clear()) // ========================================================================== describe('Dimension 4: Continuous Tick Reset (m.hitTargetIds.clear())', () => { it('verifies Fire Wall deals damage on 10 successive frames without single-hit blocking', () => { const firewallPatch: Projectile = { skillId: '51', x: 100, y: 100, vx: 0, vy: 0, damage: 85, ttl: 90, fromPlayer: true, missileType: 'firewall', pierce: true, aoeRadius: 24, } const target = { index: 10, alive: true, x: 100, y: 100, radius: 16, } const terrain = { overlap: () => 0 } let current: readonly Projectile[] = [firewallPatch] let totalDamage = 0 for (let tick = 1; tick <= 10; tick++) { const outcome = tickProjectiles(current, [target], terrain) expect(outcome.hits).toHaveLength(1) expect(outcome.hits[0]!.targetIndex).toBe(10) expect(outcome.hits[0]!.damage).toBe(85) totalDamage += outcome.hits[0]!.damage current = outcome.alive } expect(totalDamage).toBe(850) expect(current[0]!.ttl).toBe(80) }) it('verifies Inferno stream flames (infernoflame1 & infernoflame2) damage continuously every tick', () => { const flame1: Projectile = { skillId: '41', x: 100, y: 100, vx: 12, vy: 0, damage: 30, ttl: 15, fromPlayer: true, missileType: 'infernoflame1', pierce: true, aoeRadius: 20, } const target = { index: 5, alive: true, x: 108, y: 100, radius: 16, } const terrain = { overlap: () => 0 } // Tick 1 const out1 = tickProjectiles([flame1], [target], terrain) expect(out1.hits).toHaveLength(1) expect(out1.hits[0]!.damage).toBe(30) // Tick 2: flame moves to x=120, target at 108 is still within hitRadius (16+8=24) const out2 = tickProjectiles(out1.alive, [target], terrain) expect(out2.hits).toHaveLength(1) expect(out2.hits[0]!.damage).toBe(30) // Same test with infernoflame2 const flame2: Projectile = { ...flame1, missileType: 'infernoflame2', } const outFlame2 = tickProjectiles([flame2], [target], terrain) expect(outFlame2.hits).toHaveLength(1) expect(outFlame2.hits[0]!.damage).toBe(30) }) it('verifies groundfirebig and meteorfire tick mechanics', () => { const gfbPatch: Projectile = { skillId: '51', x: 200, y: 200, vx: 0, vy: 0, damage: 75, ttl: 50, fromPlayer: true, missileType: 'groundfirebig', pierce: true, aoeRadius: 24, } const target = { index: 2, alive: true, x: 200, y: 200, radius: 16 } const terrain = { overlap: () => 0 } const out1 = tickProjectiles([gfbPatch], [target], terrain) expect(out1.hits).toHaveLength(1) const out2 = tickProjectiles(out1.alive, [target], terrain) expect(out2.hits).toHaveLength(1) }) it('contrasts with non-continuous piercing projectile which hits each target only once', () => { const normalPiercingArrow: Projectile = { skillId: '1', x: 100, y: 100, vx: 0, // stationary for test isolation vy: 0, damage: 100, ttl: 10, fromPlayer: true, missileType: 'firebolt', // not a ground fire or stream flame! pierce: true, aoeRadius: 16, } const target = { index: 7, alive: true, x: 100, y: 100, radius: 16 } const terrain = { overlap: () => 0 } // Tick 1: hits target const out1 = tickProjectiles([normalPiercingArrow], [target], terrain) expect(out1.hits).toHaveLength(1) // Tick 2: hitTargets was NOT cleared -> does NOT hit target again! const out2 = tickProjectiles(out1.alive, [target], terrain) expect(out2.hits).toHaveLength(0) }) it('verifies continuous tick reset in MissileEngine simulation', async () => { const registry = await getSharedDataRegistry() const engine = new MissileEngine(registry) const attacker = createMockCombatUnit('sorceress') const enemy = createMockCombatUnit('enemy_firewall', { hp: 5000 }) const targets = [enemy] const targetPositions = new Map([ ['enemy_firewall', { x: 200, y: 200 }], ]) // Spawn Fire Wall patch directly on top of enemy engine.spawnMissile({ missileNameOrId: 'firewall', sourceSkillId: 51, slvl: 20, owner: attacker, startX: 200, startY: 200, targetX: 200, targetY: 200, dmgPacket: { skillId: 51, elemType: 'fire', elemMin256: 100 * 256, elemMax256: 100 * 256, autoHit: true, }, }) // Tick 5 consecutive frames let hitsCount = 0 for (let f = 1; f <= 5; f++) { const out = engine.tick(1, targets, targetPositions) const hit = out.hits.find(h => h.targetId === 'enemy_firewall') if (hit) hitsCount++ } expect(hitsCount).toBe(5) }) }) // ========================================================================== // Dimension 5: 2:1 Isometric Aspect Ratio (ISO_GROUND_ASPECT_RATIO = 0.5) // ========================================================================== describe('Dimension 5: 2:1 Isometric Aspect Ratio (ISO_GROUND_ASPECT_RATIO = 0.5)', () => { it('confirms ISO_GROUND_ASPECT_RATIO is strictly 0.5', () => { expect(ISO_GROUND_ASPECT_RATIO).toBe(0.5) }) it('verifies horizontal reach (24px) vs vertical reach (12px) ellipse in tickProjectiles', () => { const patchX = 200 const patchY = 200 const effectiveRadius = 24 // aoeRadius 24 with radius 0 const patch: Projectile = { skillId: '51', x: patchX, y: patchY, vx: 0, vy: 0, damage: 100, ttl: 90, fromPlayer: true, missileType: 'firewall', pierce: true, aoeRadius: 24, } const terrain = { overlap: () => 0 } // 1. Horizontal reach: // dx = 23px, dy = 0px -> dist = 23 <= 24 -> HIT const tH1 = { index: 1, alive: true, x: patchX + 23, y: patchY, radius: 16 } const outH1 = tickProjectiles([patch], [tH1], terrain) expect(outH1.hits).toHaveLength(1) // dx = 25px, dy = 0px -> dist = 25 > 24 -> MISS const tH2 = { index: 2, alive: true, x: patchX + 25, y: patchY, radius: 16 } const outH2 = tickProjectiles([patch], [tH2], terrain) expect(outH2.hits).toHaveLength(0) // 2. Vertical reach (scaled by 0.5): // dy = 11.5px -> unscaled dist = 11.5 / 0.5 = 23 <= 24 -> HIT const tV1 = { index: 3, alive: true, x: patchX, y: patchY + 11.5, radius: 16 } const outV1 = tickProjectiles([patch], [tV1], terrain) expect(outV1.hits).toHaveLength(1) // dy = 12.5px -> unscaled dist = 12.5 / 0.5 = 25 > 24 -> MISS // In Cartesian 1:1, dy = 12.5 < 24 would hit, but in 2:1 isometric ground it MUST miss! const tV2 = { index: 4, alive: true, x: patchX, y: patchY + 12.5, radius: 16 } const outV2 = tickProjectiles([patch], [tV2], terrain) expect(outV2.hits).toHaveLength(0) // 3. Isometric Diagonal Ellipse Boundary (dx / 24)^2 + (dy / 12)^2 <= 1: // At theta = 45°: // Inside: dx = 24 * cos(45°) * 0.95 = 16.12, dy = 12 * sin(45°) * 0.95 = 8.06 -> HIT const tDiagIn = { index: 5, alive: true, x: patchX + 16.12, y: patchY + 8.06, radius: 16 } const outDiagIn = tickProjectiles([patch], [tDiagIn], terrain) expect(outDiagIn.hits).toHaveLength(1) // Outside: dx = 24 * cos(45°) * 1.05 = 17.82, dy = 12 * sin(45°) * 1.05 = 8.91 -> MISS const tDiagOut = { index: 6, alive: true, x: patchX + 17.82, y: patchY + 8.91, radius: 16 } const outDiagOut = tickProjectiles([patch], [tDiagOut], terrain) expect(outDiagOut.hits).toHaveLength(0) }) it('verifies 2:1 isometric aspect ratio in MissileEngine collision pipeline', async () => { const registry = await getSharedDataRegistry() const engine = new MissileEngine(registry) const attacker = createMockCombatUnit('sorceress') // Ground fire patch at (100, 100), hitRadius = Math.max(24, size * 14) = 24px const uInH = createMockCombatUnit('unit_in_h') // dx = 22px, dy = 0 -> dist = 22 <= 24 -> HIT const uOutH = createMockCombatUnit('unit_out_h') // dx = 26px, dy = 0 -> dist = 26 > 24 -> MISS const uInV = createMockCombatUnit('unit_in_v') // dx = 0, dy = 11px -> dist = 11/0.5 = 22 <= 24 -> HIT const uOutV = createMockCombatUnit('unit_out_v') // dx = 0, dy = 13px -> dist = 13/0.5 = 26 > 24 -> MISS const targets = [uInH, uOutH, uInV, uOutV] const targetPositions = new Map([ ['unit_in_h', { x: 122, y: 100 }], ['unit_out_h', { x: 126, y: 100 }], ['unit_in_v', { x: 100, y: 111 }], ['unit_out_v', { x: 100, y: 113 }], ]) engine.spawnMissile({ missileNameOrId: 'firewall', sourceSkillId: 51, slvl: 1, owner: attacker, startX: 100, startY: 100, targetX: 100, targetY: 100, dmgPacket: { skillId: 51, elemType: 'fire', elemMin256: 100 * 256, elemMax256: 100 * 256, autoHit: true, }, }) const outcome = engine.tick(1, targets, targetPositions) expect(outcome.hits.find(h => h.targetId === 'unit_in_h')).toBeDefined() expect(outcome.hits.find(h => h.targetId === 'unit_out_h')).toBeUndefined() expect(outcome.hits.find(h => h.targetId === 'unit_in_v')).toBeDefined() expect(outcome.hits.find(h => h.targetId === 'unit_out_v')).toBeUndefined() }) }) })