826 lines
28 KiB
TypeScript
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()
|
|
})
|
|
})
|
|
})
|