diablo2-web/tests/skills/pal/adv-pal-combat-stress.test.ts

349 lines
12 KiB
TypeScript

/**
* Milestone M7: Paladin Combat Skills Tree Adversarial Stress Verification Suite
*
* Covers 5 mission-critical stress scenarios:
* 1. Rapid Hammer Spam Spiral Geometry:
* Spawns 20 hammers simultaneously; asserts zero NaN coordinates, valid 2:1 aspect ratio across all frames,
* and correct spiral radius bounds (r = a * theta).
* 2. Holy Bolt Multi-Target Density Discrimination:
* Line of mixed targets: [Undead, Demon, Beast, Friendly Player, Undead].
* Asserts Holy Bolt damages first Undead, ignores Beast/Demon, and heals Player when friendly-targeted.
* 3. Sacrifice Extreme Overkill Self-Damage Safety:
* Paladin deals 50,000 damage with 10 HP remaining; asserts Paladin HP is clamped to 1, preventing death from self-damage.
* 4. Smite vs High-Defense Unblockable Invariant:
* Smite vs monster with 99,999 defense and 75% block rate; asserts 100% hit rate and 0% blocked attacks across 100 iterations.
* 5. Conversion Allegiance Cleanup:
* Converts 10 monsters; simulates 400 frames; asserts 100% of converted monsters cleanly return to non-converted state
* with zero state/memory leaks.
*/
import { describe, expect, it } from 'vitest'
import {
ISO_GROUND_ASPECT_RATIO,
calculateSacrificeStats,
calculateSmiteStats,
calculateConversionStats,
} from '../../../src/game/skills.ts'
import {
executeSUnitDmg,
type CombatUnitContext,
} from '../../../src/game/engine/combat-pipeline.ts'
import { FIXED_ONE, UnitStatList } from '../../../src/game/engine/stat-list.ts'
import { StateBus } from '../../../src/game/engine/state-bus.ts'
import { MissileEngine } from '../../../src/game/engine/missile-engine.ts'
import { getSharedDataRegistry } from '../../../src/game/engine/data-registry.ts'
describe('Milestone M7 — Paladin Combat Skills Tree Adversarial Stress Suite', () => {
it('1. Rapid Hammer Spam Spiral Geometry: 20 simultaneous hammers, no NaN coords, 2:1 iso ratio', async () => {
const registry = await getSharedDataRegistry()
const engine = new MissileEngine(registry)
const casterStats = new UnitStatList(registry, { hitpoints: 1000 * FIXED_ONE })
const caster: CombatUnitContext = {
id: 'hammer-spammer',
name: 'Paladin',
statList: casterStats,
stateBus: new StateBus(casterStats, registry),
}
const spawnedHammers = []
const originX = 100
const originY = 200
// Spawn 20 hammers simultaneously with staggered initial angles
for (let i = 0; i < 20; i++) {
const angle = (i / 20) * Math.PI * 2
const m = engine.spawnMissile({
missileNameOrId: 'blessedhammer',
sourceSkillId: 112,
slvl: 20,
owner: caster,
startX: originX,
startY: originY,
targetX: originX + Math.cos(angle) * 100,
targetY: originY + Math.sin(angle) * 50,
dmgPacket: {
skillId: 112,
elemType: 'mag',
elemMin256: 500 * FIXED_ONE,
elemMax256: 600 * FIXED_ONE,
},
})
expect(m).toBeDefined()
if (m) {
m.spiralTheta = angle
spawnedHammers.push(m)
}
}
expect(spawnedHammers.length).toBe(20)
// Simulate 120 frames (standard hammer lifetime)
for (let frame = 1; frame <= 120; frame++) {
engine.tick(frame, [], new Map())
for (const h of spawnedHammers) {
expect(Number.isFinite(h.x)).toBe(true)
expect(Number.isFinite(h.y)).toBe(true)
expect(Number.isNaN(h.x)).toBe(false)
expect(Number.isNaN(h.y)).toBe(false)
// Radial bounds test: radius expands monotonically as theta increases
const dx = h.x - originX
const dy = (h.y - originY) / ISO_GROUND_ASPECT_RATIO
const currentR = Math.hypot(dx, dy)
expect(currentR).toBeGreaterThanOrEqual(11.9)
expect(currentR).toBeLessThanOrEqual(Math.max(12, 13 * (h.spiralTheta ?? 0)) + 0.1)
}
}
})
it('2. Holy Bolt Multi-Target Density Discrimination: [Undead, Demon, Beast, Friendly Player, Undead]', async () => {
const registry = await getSharedDataRegistry()
const engine = new MissileEngine(registry)
const casterStats = new UnitStatList(registry, { hitpoints: 1000 * FIXED_ONE })
casterStats.setBaseSkillLevel(99, 10) // Prayer synergy
const caster: CombatUnitContext = {
id: 'caster-paladin',
name: 'Paladin',
statList: casterStats,
stateBus: new StateBus(casterStats, registry),
}
// Dense row of 5 units positioned along x axis: [Undead1, Demon, Beast, FriendlyAlly, Undead2]
const u1Stats = new UnitStatList(registry, { hitpoints: 1000 * FIXED_ONE, magicresist: 0 })
const u1: CombatUnitContext = {
id: 'undead-1',
name: 'Skeleton',
statList: u1Stats,
stateBus: new StateBus(u1Stats, registry),
isUndead: true,
unitType: 'monster',
}
const demonStats = new UnitStatList(registry, { hitpoints: 1000 * FIXED_ONE })
const demon: CombatUnitContext = {
id: 'demon-2',
name: 'Fallen',
statList: demonStats,
stateBus: new StateBus(demonStats, registry),
isUndead: false,
isDemon: true,
unitType: 'monster',
}
const beastStats = new UnitStatList(registry, { hitpoints: 1000 * FIXED_ONE })
const beast: CombatUnitContext = {
id: 'beast-3',
name: 'Quill Rat',
statList: beastStats,
stateBus: new StateBus(beastStats, registry),
isUndead: false,
unitType: 'monster',
}
const allyStats = new UnitStatList(registry, { hitpoints: 200 * FIXED_ONE, maxhp: 1000 * FIXED_ONE })
const ally: CombatUnitContext = {
id: 'ally-4',
name: 'Mercenary',
statList: allyStats,
stateBus: new StateBus(allyStats, registry),
unitType: 'mercenary',
}
const u2Stats = new UnitStatList(registry, { hitpoints: 1000 * FIXED_ONE, magicresist: 0 })
const u2: CombatUnitContext = {
id: 'undead-5',
name: 'Zombie',
statList: u2Stats,
stateBus: new StateBus(u2Stats, registry),
isUndead: true,
unitType: 'monster',
}
const positions = new Map<string, { x: number; y: number }>([
['undead-1', { x: 30, y: 0 }],
['demon-2', { x: 60, y: 0 }],
['beast-3', { x: 90, y: 0 }],
['ally-4', { x: 120, y: 0 }],
['undead-5', { x: 150, y: 0 }],
])
// Scenario A: Holy bolt fired into the pack towards Undead 1 -> hits Undead 1 and consumes missile
const m1 = engine.spawnMissile({
missileNameOrId: 'holybolt',
sourceSkillId: 101,
slvl: 10,
owner: caster,
startX: 0,
startY: 0,
targetX: 200,
targetY: 0,
dmgPacket: { skillId: 101, elemType: 'mag', elemMin256: 150 * FIXED_ONE, elemMax256: 150 * FIXED_ONE },
})!
engine.tick(1, [u1, demon, beast, ally, u2], positions)
expect(u1Stats.getHp256()).toBeLessThan(1000 * FIXED_ONE)
expect(m1.expired).toBe(true)
// Scenario B: Holy bolt fired starting AFTER Undead 1, through Demon and Beast, directly at Ally
const m2 = engine.spawnMissile({
missileNameOrId: 'holybolt',
sourceSkillId: 101,
slvl: 10,
owner: caster,
startX: 40,
startY: 0,
targetX: 200,
targetY: 0,
dmgPacket: { skillId: 101, elemType: 'mag', elemMin256: 150 * FIXED_ONE, elemMax256: 150 * FIXED_ONE },
})!
// Tick over demon and beast (positions 60 and 90)
engine.tick(2, [demon, beast], positions)
expect(demonStats.getHp256()).toBe(1000 * FIXED_ONE) // Passed through Demon!
expect(beastStats.getHp256()).toBe(1000 * FIXED_ONE) // Passed through Beast!
expect(m2.expired).toBe(false) // Not expired!
// Position missile onto ally (x = 120)
m2.x = 120
engine.tick(3, [ally, u2], positions)
expect(allyStats.getHp256()).toBeGreaterThan(200 * FIXED_ONE) // Healed ally!
expect(m2.expired).toBe(true) // Consumed by healing ally!
expect(u2Stats.getHp256()).toBe(1000 * FIXED_ONE) // Undead 2 behind ally untouched!
})
it('3. Sacrifice Extreme Overkill Self-Damage Safety: 50,000 damage with 10 HP clamped to 1 HP', async () => {
const registry = await getSharedDataRegistry()
const attackerStats = new UnitStatList(registry, {
hitpoints: 10 * FIXED_ONE,
maxhp: 1000 * FIXED_ONE,
})
const attacker: CombatUnitContext = {
id: 'low-hp-paladin',
name: 'Paladin',
statList: attackerStats,
stateBus: new StateBus(attackerStats, registry),
weaponMinPhys: 50000,
weaponMaxPhys: 50000,
}
const defenderStats = new UnitStatList(registry, {
hitpoints: 100000 * FIXED_ONE,
maxhp: 100000 * FIXED_ONE,
damageresist: 0,
})
const defender: CombatUnitContext = {
id: 'meat-sponge',
name: 'Gargantuan Beast',
statList: defenderStats,
stateBus: new StateBus(defenderStats, registry),
}
// 50,000 damage dealt -> 8% self damage = 4,000 damage
// Attacker only has 10 HP. Attacker HP MUST clamp to 1 HP (256 in FIXED_ONE), preventing suicide.
const out = executeSUnitDmg(attacker, defender, {
skillId: 96,
attackKind: 'melee',
srcDam: 128,
selfDamagePct: 8,
autoHit: true,
})
expect(out.hit).toBe(true)
expect(out.physDamage256).toBe(50000 * FIXED_ONE)
expect(out.selfDamageTaken256).toBe(4000 * FIXED_ONE)
expect(attackerStats.getHp256()).toBe(FIXED_ONE) // Exactly 1 HP remaining!
})
it('4. Smite vs High-Defense Unblockable Invariant: 99,999 def, 75% block -> 100% hits, 0% blocks', async () => {
const registry = await getSharedDataRegistry()
const attackerStats = new UnitStatList(registry, {
tohit: 100, // Very low attack rating
})
const attacker: CombatUnitContext = {
id: 'smiter',
name: 'Paladin',
statList: attackerStats,
stateBus: new StateBus(attackerStats, registry),
}
const defenderStats = new UnitStatList(registry, {
armorclass: 99999, // Extreme defense
toblock: 75, // Max block rate
hitpoints: 50000 * FIXED_ONE,
maxhp: 50000 * FIXED_ONE,
})
const defender: CombatUnitContext = {
id: 'impenetrable-guardian',
name: 'Iron Golem Boss',
statList: defenderStats,
stateBus: new StateBus(defenderStats, registry),
hasShield: true,
}
// Run 100 iterations of Smite strikes
let hits = 0
let blocks = 0
for (let i = 0; i < 100; i++) {
const out = executeSUnitDmg(attacker, defender, {
skillId: 97,
attackKind: 'melee',
srcDam: 0,
flatPhysMin256: 40 * FIXED_ONE,
flatPhysMax256: 50 * FIXED_ONE,
autoHit: true,
unblockable: true,
stunDurationFrames: 25,
roll100: i % 100, // Explicit roll
})
if (out.hit) hits++
if (out.avoidedReason === 'block') blocks++
}
expect(hits).toBe(100)
expect(blocks).toBe(0)
expect(defender.stateBus.hasState('stun')).toBe(true)
})
it('5. Conversion Allegiance Cleanup: converts 10 monsters; simulates 400 frames; asserts 100% reversion', async () => {
const registry = await getSharedDataRegistry()
const monsters: { id: string; stats: UnitStatList; bus: StateBus }[] = []
for (let i = 0; i < 10; i++) {
const stats = new UnitStatList(registry, { hitpoints: 1000 * FIXED_ONE })
const bus = new StateBus(stats, registry)
monsters.push({ id: `converted-${i}`, stats, bus })
}
// Apply conversion to all 10 monsters at frame 100 for 400 frames
for (const m of monsters) {
const res = m.bus.applyState({
stateNameOrId: 'conversion',
sourceUnitId: 'paladin-converter',
sourceSkillId: 116,
slvl: 10,
currentFrame: 100,
durationFrames: 400, // Expires at frame 500
})
expect(res.applied).toBe(true)
expect(m.bus.hasState('conversion')).toBe(true)
}
// Advance to frame 499: all 10 must still be converted
for (const m of monsters) {
m.bus.tick(499)
expect(m.bus.hasState('conversion')).toBe(true)
}
// Advance to frame 500: all 10 must expire and revert cleanly
for (const m of monsters) {
const expired = m.bus.tick(500)
expect(expired).toContain('conversion')
expect(m.bus.hasState('conversion')).toBe(false)
expect(m.bus.getActiveEntries().length).toBe(0) // Clean state bus, zero memory leaks!
}
})
})