diablo2-web/tests/skills/ama/adv-ama-passive-magic-stres...

560 lines
22 KiB
TypeScript

/**
* Diablo II: Lord of Destruction v1.13c — Amazon Passive & Magic Stress & Adversarial Suite
*
* Comprehensive adversarial and E2E kinematics integration verification:
* 1. Defensive Evasion Triad:
* - Dodge: melee avoidance while standing still / attacking; triggers GH animation lock.
* - Avoid: missile avoidance while standing still / attacking; triggers GH animation lock.
* - Evade: avoidance while moving against both melee and missile; zero animation lock (uninterrupted).
* 2. Inner Sight:
* - Flat defense reduction applied directly before percentage modifiers.
* - 200px radius, 700 + 150*(slvl-1) duration, 5 mana.
* - Monster defense reduced, to-hit chance dramatically increased.
* 3. Slow Missiles:
* - 200px radius, 300 + 60*(slvl-1) duration, 5 mana.
* - Hostile missile projectile velocity reduced to 33% (67% speed damper) in 2:1 isometric space.
* 4. Critical Strike & Deadly Strike:
* - Sequential evaluation: CS roll first, DS roll second if CS fails; never quadrupling (max 2x physical).
* - Does not double elemental/magic damage.
* 5. Penetrate:
* - Linear +35% + 10%*(slvl-1) Attack Rating bonus correctly accrued to statList.
* 6. Decoy (Dopplezon):
* - Stationary posture (NU mode), cannot move or attack.
* - Draws threat, scales HP from Amazon life (50% + 10%*slvl), up to 85% elemental resistance.
* - Inherits Avoid and Evade passives from Amazon.
* 7. Valkyrie:
* - 150-frame (6.0s) casting delay.
* - Base HP 440 * (1 + 0.2*(slvl-1)), +20% life per Decoy hard point synergy.
* - Equipment progression tiers across slvl 1, 7, 11, 17, 27+.
* - Inherits Critical Strike, Dodge, Avoid, Evade, Penetrate at Amazon's slvl.
* 8. Pierce & Guided Arrow Invariant:
* - 1..4 pierces (up to 5 targets hit).
* - Guided Arrow (skillId === 22) strict 0% pierce override invariant.
* 9. System Invariant:
* - BATCH1_SKILLS length strictly equals 38; zero mocks (vi.mock strictly forbidden).
*
* Ground Truth:
* - Blizzard v1.13c: D2Common.dll, D2Game.dll, Skills.txt, Missiles.txt
*/
import { describe, expect, it } from 'vitest'
import {
BATCH1_SKILLS,
getSkillManaCost,
getSkillCooldownTicks,
calculateSkillDamage,
} from '../../../src/game/skills.ts'
import {
calculateInnerSightStats,
calculateCriticalStrikeStats,
calculateDodgeStats,
calculateSlowMissilesStats,
calculateAvoidStats,
calculatePenetrateStats,
calculateDecoyStats,
calculateEvadeStats,
calculateValkyrieStats,
calculatePierceStats,
} from '../../../src/game/skills/amazon-passive.ts'
import { getSharedDataRegistry } from '../../../src/game/engine/data-registry.ts'
import { MissileEngine, ISO_GROUND_ASPECT_RATIO } from '../../../src/game/engine/missile-engine.ts'
import { AuraScanner } from '../../../src/game/engine/aura-scanner.ts'
import { SummonManager } from '../../../src/game/engine/summon-manager.ts'
import { WorldArena } from '../../../src/game/engine/world-arena.ts'
import { UnitStatList, FIXED_ONE } from '../../../src/game/engine/stat-list.ts'
import { StateBus } from '../../../src/game/engine/state-bus.ts'
import {
executeSUnitDmg,
evaluateAvoidanceAndBlock,
computeToHitChance,
type CombatUnitContext,
type SUnitDmgPacket,
} from '../../../src/game/engine/combat-pipeline.ts'
import { evaluateCriticalAndDeadlyStrike, calculateCriticalChance } from '../../../src/game/formulas.ts'
function createTestCombatUnit(
id: string,
pos: { x: number; y: number },
registry: any,
opts?: {
hp?: number
isMoving?: boolean
def?: number
ar?: number
level?: number
isBoss?: boolean
}
): CombatUnitContext {
const statList = new UnitStatList(registry, {
level: opts?.level ?? 80,
hitpoints: (opts?.hp ?? 10000) * FIXED_ONE,
maxhp: (opts?.hp ?? 10000) * FIXED_ONE,
armorclass: opts?.def ?? 400,
tohit: opts?.ar ?? 1000,
fireresist: 0,
coldresist: 0,
lightresist: 0,
poisonresist: 0,
damageresist: 0,
magicresist: 0,
x: pos.x,
y: pos.y,
})
const stateBus = new StateBus(statList, registry)
return {
id,
name: id,
statList,
stateBus,
isMoving: opts?.isMoving ?? false,
isBoss: opts?.isBoss ?? false,
x: pos.x,
y: pos.y,
weaponMinPhys: 50,
weaponMaxPhys: 100,
}
}
describe('Amazon Passive & Magic Skills Adversarial Stress Suite (1.13c Ground Truth)', () => {
it('enforces System Invariants: BATCH1_SKILLS length strictly equals 38 and zero mocks utilized', () => {
expect(Object.keys(BATCH1_SKILLS).length).toBe(38)
expect(BATCH1_SKILLS[6]).toBeDefined() // Magic Arrow
expect(BATCH1_SKILLS[7]).toBeDefined() // Fire Arrow
expect(BATCH1_SKILLS[10]).toBeDefined() // Jab
expect(BATCH1_SKILLS[11]).toBeDefined() // Cold Arrow
expect(BATCH1_SKILLS[14]).toBeDefined() // Power Strike
})
describe('Defensive Evasion Triad: Dodge, Avoid, Evade & Animation Lock Invariants', () => {
it('verifies Dodge: triggers only against melee while stationary/attacking and applies GH animation lock', async () => {
const registry = await getSharedDataRegistry()
// Amazon standing still with slvl 20 Dodge (56% chance)
const amazonStationary = createTestCombatUnit('amazon-still', { x: 0, y: 0 }, registry, { isMoving: false })
const dodgeStats20 = calculateDodgeStats(20)
amazonStationary.statList.setBaseSkillLevel(13, 20)
amazonStationary.statList.addStat('passive_dodge', dodgeStats20.chancePct)
// 1. Melee attack while stationary: roll 30 < 56% -> avoided via dodge
const resMeleeStill = evaluateAvoidanceAndBlock({
defender: amazonStationary,
attackKind: 'melee',
roll100: 30,
})
expect(resMeleeStill.avoided).toBe(true)
expect(resMeleeStill.reason).toBe('dodge')
expect(dodgeStats20.triggersAnimationLock).toBe(true)
expect(dodgeStats20.animationCode).toBe('GH')
// 2. Melee attack while stationary: roll 70 >= 56% -> not avoided
const resMeleeStillFail = evaluateAvoidanceAndBlock({
defender: amazonStationary,
attackKind: 'melee',
roll100: 70,
})
expect(resMeleeStillFail.avoided).toBe(false)
expect(resMeleeStillFail.reason).toBe('none')
// 3. Melee attack while moving: Dodge must NOT trigger when moving
const amazonMoving = createTestCombatUnit('amazon-run', { x: 0, y: 0 }, registry, { isMoving: true })
amazonMoving.statList.addStat('passive_dodge', dodgeStats20.chancePct)
const resMeleeMoving = evaluateAvoidanceAndBlock({
defender: amazonMoving,
attackKind: 'melee',
roll100: 30,
})
// Dodge does not activate while moving!
expect(resMeleeMoving.reason).not.toBe('dodge')
})
it('verifies Avoid: triggers only against missiles while stationary/attacking and applies GH animation lock', async () => {
const registry = await getSharedDataRegistry()
// Amazon standing still with slvl 20 Avoid (65% chance)
const amazonStill = createTestCombatUnit('amazon-still', { x: 0, y: 0 }, registry, { isMoving: false })
const avoidStats20 = calculateAvoidStats(20)
amazonStill.statList.setBaseSkillLevel(18, 20)
amazonStill.statList.addStat('passive_avoid', avoidStats20.chancePct)
// 1. Ranged missile while stationary: roll 40 < 65% -> avoided via avoid
const resMissileStill = evaluateAvoidanceAndBlock({
defender: amazonStill,
attackKind: 'missile',
roll100: 40,
})
expect(resMissileStill.avoided).toBe(true)
expect(resMissileStill.reason).toBe('avoid')
expect(avoidStats20.triggersAnimationLock).toBe(true)
expect(avoidStats20.animationCode).toBe('GH')
// 2. Melee attack: Avoid must NOT trigger against melee attacks
const resMelee = evaluateAvoidanceAndBlock({
defender: amazonStill,
attackKind: 'melee',
roll100: 40,
})
expect(resMelee.reason).not.toBe('avoid')
// 3. Missile attack while moving: Avoid must NOT trigger when moving
const amazonMoving = createTestCombatUnit('amazon-run', { x: 0, y: 0 }, registry, { isMoving: true })
amazonMoving.statList.addStat('passive_avoid', avoidStats20.chancePct)
const resMissileMoving = evaluateAvoidanceAndBlock({
defender: amazonMoving,
attackKind: 'missile',
roll100: 40,
})
expect(resMissileMoving.reason).not.toBe('avoid')
})
it('verifies Evade: triggers while moving against BOTH melee and missiles with ZERO animation lock', async () => {
const registry = await getSharedDataRegistry()
// Amazon moving with slvl 20 Evade (56% chance)
const amazonMoving = createTestCombatUnit('amazon-moving', { x: 0, y: 0 }, registry, { isMoving: true })
const evadeStats20 = calculateEvadeStats(20)
amazonMoving.statList.setBaseSkillLevel(29, 20)
amazonMoving.statList.addStat('passive_evade', evadeStats20.chancePct)
// 1. Evade against melee while moving: roll 30 < 56% -> avoided via evade
const resMelee = evaluateAvoidanceAndBlock({
defender: amazonMoving,
attackKind: 'melee',
roll100: 30,
})
expect(resMelee.avoided).toBe(true)
expect(resMelee.reason).toBe('evade')
// Zero animation lock: uninterrupted motion
expect(evadeStats20.triggersAnimationLock).toBe(false)
expect(evadeStats20.uninterrupted).toBe(true)
// 2. Evade against missile while moving: roll 30 < 56% -> avoided via evade
const resMissile = evaluateAvoidanceAndBlock({
defender: amazonMoving,
attackKind: 'missile',
roll100: 30,
})
expect(resMissile.avoided).toBe(true)
expect(resMissile.reason).toBe('evade')
// 3. Evade while stationary: must NOT trigger when stationary
const amazonStill = createTestCombatUnit('amazon-still', { x: 0, y: 0 }, registry, { isMoving: false })
amazonStill.statList.addStat('passive_evade', evadeStats20.chancePct)
const resStill = evaluateAvoidanceAndBlock({
defender: amazonStill,
attackKind: 'melee',
roll100: 30,
})
expect(resStill.reason).not.toBe('evade')
})
})
describe('Inner Sight: Flat Defense Reduction & Illumination Radius', () => {
it('applies flat defense reduction directly before percentage defense modifiers', async () => {
const isStats1 = calculateInnerSightStats(1)
expect(isStats1.flatDefenseReduction).toBe(-40)
expect(isStats1.durationFrames).toBe(700) // 28.0s
expect(isStats1.radiusPx).toBe(200)
expect(isStats1.manaCost).toBe(5)
const isStats20 = calculateInnerSightStats(20)
expect(isStats20.flatDefenseReduction).toBe(-515) // -40 - 25*19 = -515
expect(isStats20.durationFrames).toBe(3550) // 700 + 150*19 = 3550 frames (142.0s)
// Verification of defense reduction impact on hit chance:
// Base Monster: 600 Defense, Attacker: 800 AR, both level 80
const baseHitChance = computeToHitChance({
attackerAr: 800,
defenderDef: 600,
attackerLvl: 80,
defenderLvl: 80,
})
// Raw: (2 * 800 * 100 * 80) / ((800 + 600) * 160) = 12800000 / 224000 = 57%
expect(baseHitChance).toBe(57)
// Afflicted by slvl 20 Inner Sight: 600 - 515 = 85 defense remaining
const debuffedDef = Math.max(0, 600 + isStats20.flatDefenseReduction)
expect(debuffedDef).toBe(85)
const afflictedHitChance = computeToHitChance({
attackerAr: 800,
defenderDef: debuffedDef,
attackerLvl: 80,
defenderLvl: 80,
})
// Raw: (2 * 800 * 100 * 80) / ((800 + 85) * 160) = 12800000 / 141600 = 90%
expect(afflictedHitChance).toBe(90)
expect(afflictedHitChance).toBeGreaterThan(baseHitChance)
})
})
describe('Slow Missiles: Projectile Velocity Kinematics Dampening', () => {
it('scales hostile projectile velocity by 33% (67% speed reduction) in 2:1 isometric space', async () => {
const registry = await getSharedDataRegistry()
const missileEngine = new MissileEngine(registry)
const enemy = createTestCombatUnit('monster-caster', { x: 0, y: 0 }, registry)
const dmgPacket: SUnitDmgPacket = {
skillId: 0,
attackKind: 'missile',
flatPhysMin256: 50 * FIXED_ONE,
flatPhysMax256: 50 * FIXED_ONE,
}
// 1. Baseline normal projectile
const normalMsl = missileEngine.spawnMissile({
missileNameOrId: 'arrow',
sourceSkillId: 0,
slvl: 1,
owner: enemy,
startX: 0,
startY: 0,
targetX: 300,
targetY: 0,
dmgPacket,
})!
expect(normalMsl).not.toBeNull()
const baseVx = normalMsl.vx
const baseVy = normalMsl.vy
// 2. Slowed projectile under Slow Missiles aura
const slowedMsl = missileEngine.spawnMissile({
missileNameOrId: 'arrow',
sourceSkillId: 0,
slvl: 1,
owner: enemy,
startX: 0,
startY: 0,
targetX: 300,
targetY: 0,
dmgPacket,
isSlowed: true,
slowMultiplier: 0.33,
})!
expect(slowedMsl).not.toBeNull()
// Step both missiles for 5 ticks
for (let t = 0; t < 5; t++) {
missileEngine.tick(t, [], new Map())
}
// Under Slow Missiles, distance traveled must be ~33% of baseline
expect(slowedMsl.x).toBeCloseTo(normalMsl.x * 0.33, 0)
})
})
describe('Critical Strike & Deadly Strike: Sequential Doubling Invariant', () => {
it('rolls Critical Strike before Deadly Strike and strictly never quadruples physical damage', () => {
const csStats20 = calculateCriticalStrikeStats(20)
expect(csStats20.chancePct).toBe(68)
expect(csStats20.damageMultiplier).toBe(2.0)
// Test sequential doubling with mock PRNG rolls:
// Case 1: CS succeeds (roll 10 < 68%) -> critical_strike, DS skipped
const res1 = evaluateCriticalAndDeadlyStrike(68, 50, () => 0.10)
expect(res1.isCritical).toBe(true)
expect(res1.triggeredBy).toBe('critical_strike')
// Case 2: CS fails (roll 80 >= 68%), DS succeeds (roll 20 < 50%) -> deadly_strike
let rollCount = 0
const rolls = [0.80, 0.20]
const res2 = evaluateCriticalAndDeadlyStrike(68, 50, () => rolls[rollCount++] ?? 0.5)
expect(res2.isCritical).toBe(true)
expect(res2.triggeredBy).toBe('deadly_strike')
// Case 3: Combined chance formula check: P = CS + DS * (1 - CS)
// CS=68%, DS=50% -> 0.68 + 0.50 * 0.32 = 0.68 + 0.16 = 0.84 (84%)
const combined = calculateCriticalChance(68, 50)
expect(combined).toBe(84)
// Invariant: In combat pipeline, physical damage is doubled ONCE, never multiplied by 4
const baseDamage = 100
const finalCritDamage = baseDamage * csStats20.damageMultiplier
expect(finalCritDamage).toBe(200)
expect(finalCritDamage).not.toBe(400)
})
})
describe('Penetrate: Attack Rating Scaling & Stat Accrual', () => {
it('linearly accrues +35% + 10%*(slvl-1) Attack Rating to UnitStatList', () => {
const p1 = calculatePenetrateStats(1)
expect(p1.attackRatingBonusPct).toBe(35)
expect(p1.manaCost).toBe(0)
const p10 = calculatePenetrateStats(10)
expect(p10.attackRatingBonusPct).toBe(125)
const p20 = calculatePenetrateStats(20)
expect(p20.attackRatingBonusPct).toBe(225)
// StatList verification: base AR 1000 with +225% Penetrate -> 1000 * 3.25 = 3250
const statList = new UnitStatList(undefined, { tohit: 1000 })
statList.addStat('item_tohit_percent', p20.attackRatingBonusPct)
expect(statList.getAccruedStat('tohit')).toBe(3250)
})
})
describe('Decoy (Dopplezon): Stationary Posture, Threat Drawing & Passive Inheritance', () => {
it('creates stationary companion with life scaling, resistance cap, and inherited passives', async () => {
const registry = await getSharedDataRegistry()
const summonManager = new SummonManager(registry)
const amazon = createTestCombatUnit('amazon-caster', { x: 100, y: 100 }, registry, {
hp: 1000,
def: 500,
})
// Allocate Avoid (slvl 10) and Evade (slvl 8) hard points on Amazon
amazon.statList.setBaseSkillLevel(18, 10)
amazon.statList.setBaseSkillLevel(29, 8)
const outcome = summonManager.createPet({
owner: amazon,
skillId: 28,
slvl: 10,
x: 150,
y: 120,
})
expect(outcome.created).toBe(true)
const decoy = outcome.pet!
expect(decoy).toBeDefined()
expect(decoy.kind).toBe('dopplezon')
expect(decoy.isStationary).toBe(true)
// Life scaling: 1000 * (0.5 + 0.1 * 10) = 1500 HP
expect(decoy.hp).toBe(1500)
expect(decoy.maxHp).toBe(1500)
// Defense matches Amazon's defense
expect(decoy.statList.getAccruedStat('armorclass')).toBe(500)
// Resistances: min(85, 4 * 10) = 40%
expect(decoy.statList.getAccruedStat('fireresist')).toBe(40)
expect(decoy.statList.getAccruedStat('lightresist')).toBe(40)
// Inherited Avoid & Evade passives
expect(decoy.statList.getAccruedStat('passive_avoid')).toBe(calculateAvoidStats(10).chancePct)
expect(decoy.statList.getAccruedStat('passive_evade')).toBe(calculateEvadeStats(8).chancePct)
// Ticking Decoy in combat: stationary Decoy does NOT attack
const enemy = createTestCombatUnit('enemy-target', { x: 150, y: 120 }, registry)
const missileEngine = new MissileEngine(registry)
const auraScanner = new AuraScanner(registry)
const tickRes = summonManager.tickPets({
currentTick: 10,
owner: amazon,
enemies: [enemy],
enemyPositions: new Map([['enemy-target', { x: 150, y: 120 }]]),
missileEngine,
auraScanner,
})
expect(tickRes.petAttacks).toBe(0)
})
})
describe('Valkyrie: 150-Frame Cooldown, Decoy Synergies & Equipment Tier Progression', () => {
it('enforces 150-frame casting delay, +20% life per Decoy point, and 5 equipment generation tiers', async () => {
const registry = await getSharedDataRegistry()
const summonManager = new SummonManager(registry)
// 1. Casting Delay Invariant: 150 frames (6.0s)
expect(getSkillCooldownTicks(32)).toBe(150)
expect(calculateValkyrieStats(1).cooldownFrames).toBe(150)
expect(calculateValkyrieStats(1).cooldownSeconds).toBe(6.0)
// 2. Decoy Synergy: +20% HP per hard point in Decoy
const amazon = createTestCombatUnit('amazon-valk-caster', { x: 0, y: 0 }, registry)
amazon.statList.setBaseSkillLevel(28, 5) // 5 Decoy hard points = +100% life
amazon.statList.setBaseSkillLevel(9, 10) // CS slvl 10
amazon.statList.setBaseSkillLevel(13, 10) // Dodge slvl 10
amazon.statList.setBaseSkillLevel(18, 10) // Avoid slvl 10
amazon.statList.setBaseSkillLevel(29, 10) // Evade slvl 10
amazon.statList.setBaseSkillLevel(23, 10) // Penetrate slvl 10
const outcome = summonManager.createPet({
owner: amazon,
skillId: 32,
slvl: 10,
})
expect(outcome.created).toBe(true)
const valk = outcome.pet!
expect(valk.kind).toBe('valkyrie')
// Base HP at slvl 10: 440 * (1 + 0.2*9) = 440 * 2.8 = 1232
// Decoy Synergy (+100%): 1232 * 2.0 = 2464 HP
expect(valk.hp).toBe(2464)
// Resistances: min(75, 2 * 10) = 20%
expect(valk.statList.getAccruedStat('fireresist')).toBe(20)
// Inherited Amazon passives
expect(valk.statList.getAccruedStat('passive_critical_strike')).toBe(calculateCriticalStrikeStats(10).chancePct)
expect(valk.statList.getAccruedStat('passive_dodge')).toBe(calculateDodgeStats(10).chancePct)
expect(valk.statList.getAccruedStat('passive_avoid')).toBe(calculateAvoidStats(10).chancePct)
expect(valk.statList.getAccruedStat('passive_evade')).toBe(calculateEvadeStats(10).chancePct)
expect(valk.statList.getAccruedStat('item_tohit_percent')).toBe(calculatePenetrateStats(10).attackRatingBonusPct)
// 3. Equipment generation tiers
expect(calculateValkyrieStats(1).equipmentTier).toBe('tier1_basic')
expect(calculateValkyrieStats(7).equipmentTier).toBe('tier2_boots')
expect(calculateValkyrieStats(11).equipmentTier).toBe('tier3_gloves')
expect(calculateValkyrieStats(17).equipmentTier).toBe('tier4_war_pike')
expect(calculateValkyrieStats(27).equipmentTier).toBe('tier5_rare_tiara')
})
})
describe('Pierce: Penetration Limit & Guided Arrow Zero-Pierce Invariant', () => {
it('caps pierce at 4 iterations (5 targets hit max) and strictly clamps Guided Arrow to 0%', async () => {
const registry = await getSharedDataRegistry()
const missileEngine = new MissileEngine(registry)
// 1. Pierce stats check
const pierceStats20 = calculatePierceStats(20)
expect(pierceStats20.chancePct).toBe(86)
expect(pierceStats20.maxPierceCount).toBe(4)
expect(pierceStats20.maxHitsPerMissile).toBe(5)
// 2. Guided Arrow strict 0% pierce override
const gaStats = calculatePierceStats(20, 22)
expect(gaStats.isGuidedArrow).toBe(true)
expect(gaStats.chancePct).toBe(0)
// 3. Runtime verification in MissileEngine: Guided Arrow NEVER pierces
const owner = createTestCombatUnit('amazon-ga', { x: 0, y: 0 }, registry)
const dmgPacket: SUnitDmgPacket = {
skillId: 22,
attackKind: 'missile',
srcDam: 128,
}
const gaMissile = missileEngine.spawnMissile({
missileNameOrId: 'guidedarrow',
sourceSkillId: 22,
slvl: 20,
owner,
startX: 0,
startY: 0,
targetX: 100,
targetY: 0,
dmgPacket,
pierceChancePct: 100, // Adversarial input: attempt to force 100% pierce
})
expect(gaMissile!.canPierce).toBe(false)
expect(gaMissile!.pierceChancePct).toBe(0)
// 4. Normal arrow with Pierce skill penetrates enemies
const normalArrow = missileEngine.spawnMissile({
missileNameOrId: 'arrow',
sourceSkillId: 0,
slvl: 1,
owner,
startX: 0,
startY: 0,
targetX: 200,
targetY: 0,
dmgPacket,
pierceChancePct: 86,
})
expect(normalArrow!.canPierce).toBe(true)
expect(normalArrow!.pierceChancePct).toBe(86)
})
})
})