diablo2-web/tests/skills/sor/adv-fire-m3-geom-stress.tes...

826 lines
28 KiB
TypeScript

/**
* 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/game/engine/data-registry.ts'
import { UnitStatList } from '../../../src/game/engine/stat-list.ts'
import { StateBus } from '../../../src/game/engine/state-bus.ts'
import {
MissileEngine,
calculateFireWallPatches,
} from '../../../src/game/engine/missile-engine.ts'
import type { CombatUnitContext } from '../../../src/game/engine/combat-pipeline.ts'
import {
tickProjectiles,
ISO_GROUND_ASPECT_RATIO,
type Projectile,
type ProjectileTarget,
} from '../../../src/game/skills.ts'
import { GameEngine } from '../../../src/game/engine.ts'
import { COLLIDE_WALL, COLLIDE_NONE } from '../../../src/game/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<string, { x: number; y: number }>([
['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<string, { x: number; y: number }>([
['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()
})
})
})