294 lines
11 KiB
TypeScript
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)
|
|
})
|
|
})
|
|
})
|