diablo2-web/tests/adv_probe_35b9267.test.ts

294 lines
11 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import {
calculateStaticFieldDamage,
calculateStaticFieldRadiusPx,
hasLineOfSight,
MissileEngine,
} from '../src/game/engine/missile-engine.ts'
import { getSharedDataRegistry } from '../src/game/engine/data-registry.ts'
import { UnitStatList, FIXED_ONE } 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'
import { monsterStatsOf, applyEliteModifiers, type MonsterKind } from '../src/game/monsters.ts'
import { Rng } from '../src/game/rng.ts'
import { GameEngine } from '../src/game/engine.ts'
function makeUnit(registry: any, id: string, name: string, hp = 10000, lightRes = 0): CombatUnitContext {
const statList = new UnitStatList(registry, {
level: 80,
maxhp: hp * FIXED_ONE,
hitpoints: hp * FIXED_ONE,
lightresist: lightRes,
})
const stateBus = new StateBus(statList, registry)
return {
id,
name,
statList,
stateBus,
isDualClaw: false,
hasShield: false,
weaponMinPhys: 10,
weaponMaxPhys: 20,
}
}
describe('Adversarial Verification Suite (Commit 35b9267 Challenge Hardening)', () => {
// -------------------------------------------------------------------------
// 1. Difficulty Floor Underflow Protection & Assembly Invariants
// -------------------------------------------------------------------------
describe('1. Difficulty Floor Underflow Protection & Low Max HP', () => {
it('protects 1 HP targets from underflow in Nightmare difficulty', () => {
// For maxHp = 1 in Nightmare, floor(1 * 33 / 100) = 0, but clamped to Math.max(1, ...)
const res = calculateStaticFieldDamage(1, 1, 'nightmare', 0)
expect(res.damage).toBe(0)
expect(res.newHp).toBe(1)
expect(res.blockedByFloor).toBe(true)
})
it('protects 1 HP targets from underflow in Hell difficulty', () => {
// For maxHp = 1 in Hell, floor(1 * 50 / 100) = 0, but clamped to Math.max(1, ...)
const res = calculateStaticFieldDamage(1, 1, 'hell', 0)
expect(res.damage).toBe(0)
expect(res.newHp).toBe(1)
expect(res.blockedByFloor).toBe(true)
})
it('protects 2 HP targets in Hell difficulty where floor is at least 1', () => {
const res = calculateStaticFieldDamage(1, 2, 'hell', 0)
expect(res.damage).toBe(0)
expect(res.newHp).toBe(1)
expect(res.blockedByFloor).toBe(true)
})
it('faithfully reproduces 1.13c assembly pre-check when above floor', () => {
// In Hell: maxHp = 10000, floor = 5000. Unit has 5001 HP.
// D2Game.dll 0x6FC62F13: currentHp > hpFloor -> proceeds with 25% cut (1250) -> 3751 HP
const res = calculateStaticFieldDamage(5001, 10000, 'hell', 0)
expect(res.damage).toBe(1250)
expect(res.newHp).toBe(3751)
expect(res.blockedByFloor).toBe(false)
// Subsequent cast on 3751 HP is now blocked by floor (3751 <= 5000)
const resNext = calculateStaticFieldDamage(res.newHp, 10000, 'hell', 0)
expect(resNext.damage).toBe(0)
expect(resNext.newHp).toBe(3751)
expect(resNext.blockedByFloor).toBe(true)
})
})
// -------------------------------------------------------------------------
// 2. Negative Resistance Amplification & Non-Lethal Invariant
// -------------------------------------------------------------------------
describe('2. Negative Resistance Amplification & Non-Lethal Invariant', () => {
it('scales damage up with negative lightning resistance (Lower Resist / Conviction)', () => {
// Baseline 0% res: 250 damage
const base = calculateStaticFieldDamage(1000, 1000, 'normal', 0)
expect(base.damage).toBe(250)
// -50% res: floor(250 * 150 / 100) = 375 damage
const neg50 = calculateStaticFieldDamage(1000, 1000, 'normal', -50)
expect(neg50.damage).toBe(375)
expect(neg50.newHp).toBe(625)
// -100% res (D2 lower cap): floor(250 * 200 / 100) = 500 damage (doubled!)
const neg100 = calculateStaticFieldDamage(1000, 1000, 'normal', -100)
expect(neg100.damage).toBe(500)
expect(neg100.newHp).toBe(500)
})
it('CRITICAL SAFETY INVARIANT: Static Field NEVER kills, even under extreme negative resistance', () => {
// Current HP = 4: base damage = 1.
// With -300% resistance, damage without clamp would be 1 * 400 / 100 = 4 (killing target 4 - 4 = 0).
// Post-scaling non-lethal clamp Math.min(damage, currentHp - 1) forces damage <= 3, newHp >= 1.
const res = calculateStaticFieldDamage(4, 100, 'normal', -300)
expect(res.damage).toBeLessThanOrEqual(3)
expect(res.newHp).toBeGreaterThanOrEqual(1)
// Current HP = 2: base damage = 0.
const res2 = calculateStaticFieldDamage(2, 100, 'normal', -100)
expect(res2.damage).toBe(0)
expect(res2.newHp).toBe(2)
// Current HP = 1: blocked by floor.
const res1 = calculateStaticFieldDamage(1, 100, 'normal', -100)
expect(res1.damage).toBe(0)
expect(res1.newHp).toBe(1)
expect(res1.blockedByFloor).toBe(true)
})
})
// -------------------------------------------------------------------------
// 3. Monster Lightning Resistance & Immunity Wiring
// -------------------------------------------------------------------------
describe('3. Monster Lightning Resistance & Immunity Wiring', () => {
it('monsterStatsOf correctly populates resistances and lightningResist', () => {
const dummyKind = {
id: 'stormcaster',
nameKey: 'Storm Caster',
minHp: 100,
maxHp: 100,
velocity: 8,
aiDistance: 10,
meleeRange: 1,
experience: 50,
level: [15, 45, 75],
attack1: { minDamage: 5, maxDamage: 10, toHit: 50 },
attack2: { minDamage: 0, maxDamage: 0, toHit: 0 },
resistances: {
physical: 0,
magic: 0,
fire: 25,
lightning: 100, // Immune!
cold: 0,
poison: 0,
},
treasureClasses: [],
minions: [],
setBoss: false,
isSpawn: true,
enabled: true,
rarity: 1,
sparsePopulate: 1,
}
const stats = monsterStatsOf(dummyKind as unknown as MonsterKind, new Rng(1234), 160)
expect(stats.lightningResist).toBe(100)
expect(stats.resistances?.lightning).toBe(100)
expect(stats.resists?.lightning).toBe(100)
// Static Field against this immune monster deals 0 damage
const sfRes = calculateStaticFieldDamage(stats.hp, stats.hp, 'normal', stats.lightningResist)
expect(sfRes.damage).toBe(0)
expect(sfRes.immune).toBe(true)
})
it('applyEliteModifiers scales lightning resistance for LightEnchant and MagicResistant', () => {
const baseStats = {
id: 'fallen',
name: 'Fallen',
hp: 100,
damage: 10,
cooldownTicks: 25,
reach: 20,
aggroRadius: 100,
speed: 100,
xp: 20,
lightningResist: 20,
resistances: { physical: 0, magic: 0, fire: 0, lightning: 20, cold: 0, poison: 0 },
}
// Light Enchant adds +75 lightning resist: 20 + 75 = 95
const eliteLight = applyEliteModifiers(baseStats, 'unique', ['lightenchant'])
expect(eliteLight.lightningResist).toBe(95)
expect(eliteLight.resistances?.lightning).toBe(95)
// Light Enchant + Magic Resistant (+20): 20 + 75 + 20 = 115 (Immune!)
const eliteBoth = applyEliteModifiers(baseStats, 'unique', ['lightenchant', 'magicresistant'])
expect(eliteBoth.lightningResist).toBe(115)
expect(eliteBoth.resistances?.lightning).toBe(115)
const sfRes = calculateStaticFieldDamage(eliteBoth.hp, eliteBoth.hp, 'normal', eliteBoth.lightningResist)
expect(sfRes.damage).toBe(0)
expect(sfRes.immune).toBe(true)
})
})
// -------------------------------------------------------------------------
// 4. Continuous Terrain Collision & Tunneling Prevention
// -------------------------------------------------------------------------
describe('4. Continuous Terrain Collision & Tunneling Prevention', () => {
it('MissileEngine.tick does not tunnel through thin 10px walls at speed 24', async () => {
const registry = await getSharedDataRegistry()
const engine = new MissileEngine(registry)
const owner = makeUnit(registry, 'player', 'Sorceress')
// Wall between x=10 and x=20
const terrain = {
overlap: (x: number, _y: number) => (x >= 10 && x <= 20 ? 1 : 0),
isMissileBlocked: (x: number, _y: number) => x >= 10 && x <= 20,
}
// Spawn a missile at x=0 with vx=24 (jumping over 10..20 in one frame if point-sampled)
const m = engine.spawnMissile({
missileNameOrId: 'nova',
sourceSkillId: 48,
slvl: 20,
owner,
startX: 0,
startY: 0,
targetX: 100,
targetY: 0,
vx: 24,
vy: 0,
dmgPacket: {
skillId: 48,
attackKind: 'spell',
elemType: 'lightning',
elemMin256: 100 * FIXED_ONE,
elemMax256: 100 * FIXED_ONE,
autoHit: true,
},
})
expect(m).toBeDefined()
// Tick 1: Continuous sub-stepping must detect collision between x=0 and x=24
const step = engine.tick(1, [], new Map(), terrain)
expect(m!.expired).toBe(true)
})
it('hasLineOfSight detects thin walls and correctly passes previous step coordinates', () => {
// Wall between x=20 and x=28 (8px wide)
const isBlocked = (x: number, _y: number) => x >= 20 && x <= 28
const hasLos = hasLineOfSight(0, 0, 40, 0, isBlocked)
expect(hasLos).toBe(false)
const openLos = hasLineOfSight(0, 0, 18, 0, isBlocked)
expect(openLos).toBe(true)
})
})
// -------------------------------------------------------------------------
// 5. Persistent NextDelay & Memory Leak Pruning in GameEngine
// -------------------------------------------------------------------------
describe('5. Persistent NextDelay & Memory Leak Pruning in GameEngine', () => {
it('GameEngine maintains persistent nextDelayByTarget across ticks and prunes expired entries', () => {
const terrain = {
widthPx: 1000,
heightPx: 1000,
overlap: () => 0,
}
const engine = new GameEngine(terrain as any, {
spawn: { x: 100, y: 100 },
stats: [],
monsterCount: 0,
skills: [],
npcDefs: [],
combatOptions: {
playerSpeed: 100,
playerReach: 50,
playerCooldownTicks: 10,
playerDamage: 5,
playerManaPerAttack: 0,
respawnTicks: 100,
},
} as any)
expect(engine.nextDelayByTarget).toBeDefined()
expect(engine.nextDelayByTarget.size).toBe(0)
// Simulate a NextDelay entry set for target 1 expiring at tick 4
engine.nextDelayByTarget.set(1, 4)
engine.nextDelayByTarget.set(2, 50) // Expires at tick 50
// Advance engine to tick 25
;(engine.world as any).tick = 25
engine.tick({ movement: { x: 0, y: 0 }, attacking: false, digits: [] } as any)
// At tick 25, entry 1 (expired at tick 4) must be pruned! Entry 2 (expires tick 50) remains!
expect(engine.nextDelayByTarget.has(1)).toBe(false)
expect(engine.nextDelayByTarget.has(2)).toBe(true)
})
})
})