401 lines
16 KiB
TypeScript
401 lines
16 KiB
TypeScript
/**
|
|
* Diablo II: Lord of Destruction v1.13c — Assassin Shadow Disciplines Adversarial & Stress Suite
|
|
*
|
|
* Verifies 1.13c ground truth parity for all 10 Assassin Shadow Disciplines skills:
|
|
* 1. Claw Mastery: AR%, ED%, and diminishing returns Critical Strike%
|
|
* 2. Burst of Speed vs Fade: Mutual exclusivity, duration, FRW/IAS, Resists, hidden physical DR
|
|
* 3. Weapon Block: Dual-claw requirement, standing/attacking/casting vs running (0% block)
|
|
* 4. Cloak of Shadows: Active duration, defense debuff/buff, recast lockout invariant
|
|
* 5. Psychic Hammer: 50/50 phys/magic split, monster type knockback scaling
|
|
* 6. Mind Blast: AOE stun duration, conversion chance, champion/unique/boss immunity
|
|
* 7. Venom: 10-frame bitrate clamp engine and combined poison scaling
|
|
* 8. Shadow Minion Orchestrator: PetType limit = 1, mutual replacement, Warrior mirroring, Master autonomous slvl
|
|
*/
|
|
|
|
import { describe, expect, it } from 'vitest'
|
|
import {
|
|
ASSASSIN_SHADOW_SKILL_IDS,
|
|
computeClawMasteryAR,
|
|
computeClawMasteryCriticalStrike,
|
|
computeClawMasteryED,
|
|
computeBurstOfSpeedDuration,
|
|
computeBurstOfSpeedFRW,
|
|
computeBurstOfSpeedIAS,
|
|
computeCloakOfShadowsDefenseBonus,
|
|
computeCloakOfShadowsDefenseReduction,
|
|
computeCloakOfShadowsDuration,
|
|
computeFadeCurseReduction,
|
|
computeFadeDuration,
|
|
computeFadePhysicalDamageReduction,
|
|
computeFadeResistances,
|
|
computeMindBlastConversionChance,
|
|
computeMindBlastDamage,
|
|
computeMindBlastStunDuration,
|
|
computePsychicHammerDamage,
|
|
computePsychicHammerKnockbackChance,
|
|
computeShadowMasterStats,
|
|
computeShadowWarriorStats,
|
|
computeVenomDuration,
|
|
computeVenomPoisonDamage,
|
|
computeWeaponBlockChance,
|
|
resolveMindBlastTarget,
|
|
resolveVenomPoisonClamp,
|
|
resolveWeaponBlock,
|
|
ShadowBuffTracker,
|
|
ShadowMinionManager,
|
|
} from '../../../src/game/skills/assassin-shadow.ts'
|
|
|
|
describe('Assassin Shadow Disciplines Ground Truth & Adversarial Suite (Milestone M28)', () => {
|
|
describe('1. Claw Mastery Scaling & Critical Strike Mechanics', () => {
|
|
it('evaluates exact Attack Rating and Enhanced Damage progression', () => {
|
|
// AR: 30 + 10 * (slvl - 1)%
|
|
expect(computeClawMasteryAR(1)).toBe(30)
|
|
expect(computeClawMasteryAR(10)).toBe(120)
|
|
expect(computeClawMasteryAR(20)).toBe(220)
|
|
|
|
// ED: 35 + 4 * (slvl - 1)%
|
|
expect(computeClawMasteryED(1)).toBe(35)
|
|
expect(computeClawMasteryED(10)).toBe(71)
|
|
expect(computeClawMasteryED(20)).toBe(111)
|
|
})
|
|
|
|
it('evaluates diminishing returns Critical Strike percentage', () => {
|
|
// dm(0, 25, slvl)
|
|
expect(computeClawMasteryCriticalStrike(1)).toBe(3)
|
|
expect(computeClawMasteryCriticalStrike(10)).toBe(17)
|
|
expect(computeClawMasteryCriticalStrike(20)).toBe(21)
|
|
expect(computeClawMasteryCriticalStrike(30)).toBe(22)
|
|
})
|
|
})
|
|
|
|
describe('2. Burst of Speed vs Fade Mutual Exclusivity Engine', () => {
|
|
it('evaluates Burst of Speed duration, FRW, and IAS scaling', () => {
|
|
// Duration: 3000 + 300*(slvl-1) frames
|
|
expect(computeBurstOfSpeedDuration(1)).toBe(3000)
|
|
expect(computeBurstOfSpeedDuration(10)).toBe(5700)
|
|
expect(computeBurstOfSpeedDuration(20)).toBe(8700)
|
|
|
|
// FRW: dm(15, 70, slvl)
|
|
expect(computeBurstOfSpeedFRW(1)).toBe(23)
|
|
expect(computeBurstOfSpeedFRW(10)).toBe(52)
|
|
expect(computeBurstOfSpeedFRW(20)).toBe(61)
|
|
|
|
// IAS: dm(15, 60, slvl)
|
|
expect(computeBurstOfSpeedIAS(1)).toBe(22)
|
|
expect(computeBurstOfSpeedIAS(10)).toBe(45)
|
|
expect(computeBurstOfSpeedIAS(20)).toBe(53)
|
|
})
|
|
|
|
it('evaluates Fade duration, All Resists, Curse Reduction, and hidden Physical DR', () => {
|
|
// Duration: 3000 + 300*(slvl-1) frames
|
|
expect(computeFadeDuration(1)).toBe(3000)
|
|
expect(computeFadeDuration(10)).toBe(5700)
|
|
expect(computeFadeDuration(20)).toBe(8700)
|
|
|
|
// All Resists: dm(10, 75, slvl)
|
|
expect(computeFadeResistances(1)).toBe(20)
|
|
expect(computeFadeResistances(10)).toBe(54)
|
|
expect(computeFadeResistances(20)).toBe(65)
|
|
|
|
// Curse Length Reduction: dm(40, 90, slvl)
|
|
expect(computeFadeCurseReduction(1)).toBe(47)
|
|
expect(computeFadeCurseReduction(10)).toBe(74)
|
|
expect(computeFadeCurseReduction(20)).toBe(82)
|
|
|
|
// Hidden Physical Damage Reduction: 1% per slvl
|
|
expect(computeFadePhysicalDamageReduction(1)).toBe(1)
|
|
expect(computeFadePhysicalDamageReduction(10)).toBe(10)
|
|
expect(computeFadePhysicalDamageReduction(20)).toBe(20)
|
|
})
|
|
|
|
it('enforces strict mutual cancellation between Burst of Speed and Fade', () => {
|
|
const tracker = new ShadowBuffTracker()
|
|
|
|
// Activate Burst of Speed
|
|
const r1 = tracker.applyBurstOfSpeed(10)
|
|
expect(r1.applied).toBe(true)
|
|
expect(r1.cancelledFade).toBe(false)
|
|
expect(tracker.getBurstOfSpeed()).not.toBeNull()
|
|
expect(tracker.getFade()).toBeNull()
|
|
|
|
// Activating Fade cancels Burst of Speed
|
|
const r2 = tracker.applyFade(15)
|
|
expect(r2.applied).toBe(true)
|
|
expect(r2.cancelledBoS).toBe(true)
|
|
expect(tracker.getBurstOfSpeed()).toBeNull()
|
|
expect(tracker.getFade()).not.toBeNull()
|
|
expect(tracker.getFade()?.level).toBe(15)
|
|
|
|
// Activating Burst of Speed again cancels Fade
|
|
const r3 = tracker.applyBurstOfSpeed(20)
|
|
expect(r3.applied).toBe(true)
|
|
expect(r3.cancelledFade).toBe(true)
|
|
expect(tracker.getBurstOfSpeed()).not.toBeNull()
|
|
expect(tracker.getFade()).toBeNull()
|
|
})
|
|
|
|
it('permits Venom to coexist with Burst of Speed or Fade', () => {
|
|
const tracker = new ShadowBuffTracker()
|
|
|
|
tracker.applyBurstOfSpeed(10)
|
|
tracker.applyVenom(20)
|
|
expect(tracker.getBurstOfSpeed()).not.toBeNull()
|
|
expect(tracker.getVenom()).not.toBeNull()
|
|
|
|
// Switch to Fade; Venom remains active!
|
|
tracker.applyFade(10)
|
|
expect(tracker.getBurstOfSpeed()).toBeNull()
|
|
expect(tracker.getFade()).not.toBeNull()
|
|
expect(tracker.getVenom()).not.toBeNull()
|
|
})
|
|
})
|
|
|
|
describe('3. Weapon Block Dual-Claw Mechanics & Movement Sensitivity', () => {
|
|
it('evaluates diminishing returns block chance scaling', () => {
|
|
// dm(20, 65, slvl)
|
|
expect(computeWeaponBlockChance(1)).toBe(27)
|
|
expect(computeWeaponBlockChance(10)).toBe(50)
|
|
expect(computeWeaponBlockChance(20)).toBe(58)
|
|
expect(computeWeaponBlockChance(30)).toBe(61)
|
|
})
|
|
|
|
it('strictly requires dual claws to activate block', () => {
|
|
// Single claw or non-claw weapon
|
|
const noDual = resolveWeaponBlock(20, false, 'standing')
|
|
expect(noDual.canBlock).toBe(false)
|
|
expect(noDual.effectiveChancePct).toBe(0)
|
|
|
|
// Dual claws equipped
|
|
const dual = resolveWeaponBlock(20, true, 'standing')
|
|
expect(dual.canBlock).toBe(true)
|
|
expect(dual.effectiveChancePct).toBe(58)
|
|
})
|
|
|
|
it('enforces 0% block chance while running and walking (1.13c ground truth invariant)', () => {
|
|
expect(resolveWeaponBlock(20, true, 'standing').effectiveChancePct).toBe(58)
|
|
expect(resolveWeaponBlock(20, true, 'walking').effectiveChancePct).toBe(0)
|
|
expect(resolveWeaponBlock(20, true, 'attacking').effectiveChancePct).toBe(58)
|
|
expect(resolveWeaponBlock(20, true, 'casting').effectiveChancePct).toBe(58)
|
|
|
|
// Running drops block chance to exactly 0%!
|
|
const runningBlock = resolveWeaponBlock(20, true, 'running')
|
|
expect(runningBlock.canBlock).toBe(false)
|
|
expect(runningBlock.effectiveChancePct).toBe(0)
|
|
|
|
// Walking also drops block chance to exactly 0%!
|
|
const walkingBlock = resolveWeaponBlock(20, true, 'walking')
|
|
expect(walkingBlock.canBlock).toBe(false)
|
|
expect(walkingBlock.effectiveChancePct).toBe(0)
|
|
})
|
|
})
|
|
|
|
describe('4. Cloak of Shadows Cooldown & Combat Suppression', () => {
|
|
it('evaluates duration and defense formulas', () => {
|
|
// Duration: 200 + 25 * (slvl - 1) frames
|
|
expect(computeCloakOfShadowsDuration(1)).toBe(200)
|
|
expect(computeCloakOfShadowsDuration(10)).toBe(425)
|
|
expect(computeCloakOfShadowsDuration(20)).toBe(675)
|
|
|
|
// Monster Defense Reduction: min(15 + 3 * (slvl - 1), 95)%
|
|
expect(computeCloakOfShadowsDefenseReduction(1)).toBe(15)
|
|
expect(computeCloakOfShadowsDefenseReduction(10)).toBe(42)
|
|
expect(computeCloakOfShadowsDefenseReduction(20)).toBe(72)
|
|
expect(computeCloakOfShadowsDefenseReduction(30)).toBe(95)
|
|
|
|
// Assassin Defense Bonus: 10 + 3 * (slvl - 1)%
|
|
expect(computeCloakOfShadowsDefenseBonus(1)).toBe(10)
|
|
expect(computeCloakOfShadowsDefenseBonus(10)).toBe(37)
|
|
expect(computeCloakOfShadowsDefenseBonus(20)).toBe(67)
|
|
})
|
|
|
|
it('enforces recast lockout while Cloak of Shadows is active', () => {
|
|
const tracker = new ShadowBuffTracker()
|
|
|
|
const firstCast = tracker.applyCloakOfShadows(1)
|
|
expect(firstCast.success).toBe(true)
|
|
expect(tracker.getCloakOfShadows()).not.toBeNull()
|
|
|
|
// Immediate recast must fail
|
|
const secondCast = tracker.applyCloakOfShadows(1)
|
|
expect(secondCast.success).toBe(false)
|
|
expect(secondCast.reason).toContain('recast locked')
|
|
|
|
// Advance frames until duration expires (200 frames)
|
|
tracker.updateFrames(199)
|
|
expect(tracker.applyCloakOfShadows(1).success).toBe(false)
|
|
|
|
tracker.updateFrames(1)
|
|
expect(tracker.getCloakOfShadows()).toBeNull()
|
|
|
|
// Now recast succeeds
|
|
expect(tracker.applyCloakOfShadows(1).success).toBe(true)
|
|
})
|
|
})
|
|
|
|
describe('5. Psychic Hammer & Mind Blast Combat & Crowd Control', () => {
|
|
it('evaluates Psychic Hammer 50/50 physical and magic damage split', () => {
|
|
const d1 = computePsychicHammerDamage(1)
|
|
expect(d1.minPhys).toBe(2)
|
|
expect(d1.maxPhys).toBe(6)
|
|
expect(d1.minMagic).toBe(2)
|
|
expect(d1.maxMagic).toBe(6)
|
|
|
|
const d10 = computePsychicHammerDamage(10)
|
|
expect(d10.minPhys).toBe(22)
|
|
expect(d10.maxPhys).toBe(35)
|
|
expect(d10.minMagic).toBe(22)
|
|
expect(d10.maxMagic).toBe(35)
|
|
|
|
const d20 = computePsychicHammerDamage(20)
|
|
expect(d20.minPhys).toBe(56)
|
|
expect(d20.maxPhys).toBe(79)
|
|
expect(d20.minMagic).toBe(56)
|
|
expect(d20.maxMagic).toBe(79)
|
|
})
|
|
|
|
it('evaluates Psychic Hammer knockback chance per monster rank', () => {
|
|
expect(computePsychicHammerKnockbackChance(10, 'normal')).toBe(100)
|
|
expect(computePsychicHammerKnockbackChance(1, 'unique')).toBe(57)
|
|
expect(computePsychicHammerKnockbackChance(20, 'unique')).toBe(92)
|
|
expect(computePsychicHammerKnockbackChance(1, 'boss')).toBe(36)
|
|
expect(computePsychicHammerKnockbackChance(20, 'boss')).toBe(87)
|
|
})
|
|
|
|
it('evaluates Mind Blast physical damage and stun duration', () => {
|
|
const d1 = computeMindBlastDamage(1)
|
|
expect(d1.minPhys).toBe(10)
|
|
expect(d1.maxPhys).toBe(20)
|
|
|
|
const d20 = computeMindBlastDamage(20)
|
|
expect(d20.minPhys).toBe(96)
|
|
expect(d20.maxPhys).toBe(106)
|
|
|
|
// Stun duration: 50 + 5 * (slvl - 1) frames
|
|
expect(computeMindBlastStunDuration(1)).toBe(50)
|
|
expect(computeMindBlastStunDuration(10)).toBe(95)
|
|
expect(computeMindBlastStunDuration(20)).toBe(145)
|
|
})
|
|
|
|
it('evaluates Mind Blast conversion chance and strictly enforces boss/unique immunities', () => {
|
|
// Conversion chance: dm(15, 40, slvl)
|
|
expect(computeMindBlastConversionChance(1)).toBe(18)
|
|
expect(computeMindBlastConversionChance(10)).toBe(32)
|
|
expect(computeMindBlastConversionChance(20)).toBe(36)
|
|
|
|
// Normal monster with roll < chance converts
|
|
const resNormal = resolveMindBlastTarget(20, { roll: 20 })
|
|
expect(resNormal.stunApplied).toBe(true)
|
|
expect(resNormal.converted).toBe(true)
|
|
expect(resNormal.conversionDurationFrames).toBeGreaterThanOrEqual(150)
|
|
|
|
// Boss, Unique, Champion, and Player are IMMUNE to conversion even with roll = 0!
|
|
const resBoss = resolveMindBlastTarget(20, { isBoss: true, roll: 0 })
|
|
expect(resBoss.stunApplied).toBe(true)
|
|
expect(resBoss.converted).toBe(false)
|
|
expect(resBoss.conversionDurationFrames).toBe(0)
|
|
|
|
const resUnique = resolveMindBlastTarget(20, { isUnique: true, roll: 0 })
|
|
expect(resUnique.stunApplied).toBe(true)
|
|
expect(resUnique.converted).toBe(false)
|
|
|
|
const resPlayer = resolveMindBlastTarget(20, { isPlayer: true, roll: 0 })
|
|
expect(resPlayer.stunApplied).toBe(true)
|
|
expect(resPlayer.converted).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('6. Venom 10-Frame Bitrate Clamp Engine', () => {
|
|
it('evaluates Venom buff duration and base poison damage over 10 frames', () => {
|
|
// Duration: 3000 + 100 * (slvl - 1) frames
|
|
expect(computeVenomDuration(1)).toBe(3000)
|
|
expect(computeVenomDuration(10)).toBe(3900)
|
|
expect(computeVenomDuration(20)).toBe(4900)
|
|
|
|
// Damage values over 10 frames:
|
|
const v1 = computeVenomPoisonDamage(1)
|
|
expect(v1.durationFrames).toBe(10)
|
|
expect(v1.minDamage).toBe(24)
|
|
expect(v1.maxDamage).toBe(32)
|
|
|
|
const v10 = computeVenomPoisonDamage(10)
|
|
expect(v10.durationFrames).toBe(10)
|
|
expect(v10.minDamage).toBe(82)
|
|
expect(v10.maxDamage).toBe(90)
|
|
|
|
const v20 = computeVenomPoisonDamage(20)
|
|
expect(v20.durationFrames).toBe(10)
|
|
expect(v20.minDamage).toBe(170)
|
|
expect(v20.maxDamage).toBe(178)
|
|
})
|
|
|
|
it('clamps external poison duration to 10 frames and aggregates bitrates', () => {
|
|
// E.g. Venom slvl 20 (170 dmg / 10 frames = 17 dmg/frame)
|
|
// + Charm with 100 poison damage over 100 frames (1 dmg/frame)
|
|
const clamped = resolveVenomPoisonClamp(20, [
|
|
{ damage: 100, durationFrames: 100 },
|
|
])
|
|
|
|
expect(clamped.finalDurationFrames).toBe(10)
|
|
expect(clamped.sourcesCombinedCount).toBe(2)
|
|
// Total damage over 10 frames should be:
|
|
// 170 + (1 dmg/frame * 10 frames) = 180 dmg!
|
|
expect(clamped.totalDamageDealt).toBe(180)
|
|
})
|
|
})
|
|
|
|
describe('7. Shadow Minion (Warrior & Master) Orchestration', () => {
|
|
it('evaluates Shadow Warrior stats scaling', () => {
|
|
const w10 = computeShadowWarriorStats(10)
|
|
expect(w10.hpBonusPct).toBe(150) // +15% per lvl
|
|
expect(w10.toHitBonus).toBe(400) // +40 AR per lvl
|
|
expect(w10.strBonus).toBe(100) // +10 str per lvl
|
|
expect(w10.dexBonus).toBe(100) // +10 dex per lvl
|
|
expect(w10.defenseBonusPct).toBe(120) // +12% def per lvl
|
|
expect(w10.allResistPct).toBe(40) // min(slvl*4, 75)%
|
|
expect(w10.itemQualityLevel).toBe(36) // 18 + 2 * (10 - 1)
|
|
})
|
|
|
|
it('evaluates Shadow Master stats scaling and diminishing returns resists', () => {
|
|
const m20 = computeShadowMasterStats(20)
|
|
expect(m20.hpBonusPct).toBe(300)
|
|
expect(m20.toHitBonus).toBe(800)
|
|
expect(m20.strBonus).toBe(200)
|
|
expect(m20.dexBonus).toBe(200)
|
|
// dm(5, 90, 20)
|
|
expect(m20.allResistPct).toBe(76)
|
|
expect(m20.itemQualityLevel).toBe(81) // 24 + 3 * (20 - 1)
|
|
})
|
|
|
|
it('enforces PetType limit = 1 and mutual replacement between Warrior and Master', () => {
|
|
const manager = new ShadowMinionManager()
|
|
|
|
// Summon Shadow Warrior
|
|
const s1 = manager.summonShadow('warrior', 10, 1000)
|
|
expect(s1.despawnedPrevious).toBe(false)
|
|
expect(manager.getActiveShadow()?.type).toBe('warrior')
|
|
|
|
// Summon Shadow Master: automatically despawns Shadow Warrior!
|
|
const s2 = manager.summonShadow('master', 20, 1050)
|
|
expect(s2.despawnedPrevious).toBe(true)
|
|
expect(manager.getActiveShadow()?.type).toBe('master')
|
|
expect(manager.getActiveShadow()?.slvl).toBe(20)
|
|
})
|
|
|
|
it('evaluates Shadow Warrior skill-mirroring and Shadow Master autonomous casting slvls', () => {
|
|
const manager = new ShadowMinionManager()
|
|
|
|
// Shadow Warrior mirroring: floor(warriorLevel / 3) + floor(playerSkillLevel / 2)
|
|
// Warrior slvl 15, Player Lightning Sentry slvl 20 -> floor(15/3) + floor(20/2) = 5 + 10 = 15
|
|
expect(manager.getShadowWarriorCastingLevel(15, 20)).toBe(15)
|
|
// Warrior slvl 20, Player Fists of Fire slvl 1 -> floor(20/3) + floor(1/2) = 6 + 0 = 6
|
|
expect(manager.getShadowWarriorCastingLevel(20, 1)).toBe(6)
|
|
|
|
// Shadow Master autonomous casting: floor(masterLevel / 2)
|
|
expect(manager.getShadowMasterCastingLevel(1)).toBe(0)
|
|
expect(manager.getShadowMasterCastingLevel(10)).toBe(5)
|
|
expect(manager.getShadowMasterCastingLevel(20)).toBe(10)
|
|
expect(manager.getShadowMasterCastingLevel(30)).toBe(15)
|
|
})
|
|
})
|
|
})
|