/** * Diablo II: Lord of Destruction v1.13c — Assassin Milestone M29 Class Gate & End-to-End Suite * * Comprehensive cross-tree integration and architectural verification across all 30 Assassin skills: * * Skill Trees Covered: * 1. Traps (10 skills): * - Fire Blast (251), Shock Web (256), Blade Sentinel (257), Charged Bolt Sentry (261), * Wake of Fire (262), Blade Fury (266), Lightning Sentry (271), Wake of Inferno (272), * Death Sentry (276), Blade Shield (277). * 2. Martial Arts (10 skills): * - Tiger Strike (254), Dragon Talon (255), Fists of Fire (259), Dragon Claw (260), * Cobra Strike (265), Claws of Thunder (269), Dragon Tail (270), Blades of Ice (274), * Dragon Flight (275), Phoenix Strike (280). * 3. Shadow Disciplines (10 skills): * - Claw Mastery (252), Psychic Hammer (253), Burst of Speed (258), Weapon Block (263), * Cloak of Shadows (264), Fade (267), Shadow Warrior (268), Mind Blast (273), * Venom (278), Shadow Master (279). * * Core Verification Scenarios: * Scenario 1: Traps Tree Synergy Network & 5-Band Progression (Traps) * Scenario 2: Martial Arts Charge-Up & Finisher Discharge Pipeline * Scenario 3: Shadow Disciplines Buff Mechanics & Venom Bitrate Clamp * Scenario 4: Minion & Summon Caps (5 Traps + 1 Shadow = 6 Max) * Scenario 5: NextDelay Collision Avoidance Across Trees * Scenario 6: Cross-Tree Hybrid Build Integration (Trapper / Kicksin / Shadow) * Scenario 7: Crowd Control & Status Effects (Mind Blast Stun/Convert, Psychic Hammer KB, CoS Blind) * Scenario 8: Complete 30-Skill Assassin Tree Data Registry & Architectural Invariants * * ZERO MOCKS: vi.mock / vi.spyOn strictly forbidden. * ZERO CIRCULAR ORACLES: Precalculated independently from 1.13c ground truth constants. */ import { describe, expect, it } from 'vitest' import { BATCH1_SKILLS } from '../../../src/game/skills.ts' import { getSharedDataRegistry } from '../../../src/game/engine/data-registry.ts' // Traps imports import { ASSASSIN_TRAP_SKILL_IDS, BLADE_SRC_DAM, MAX_ACTIVE_TRAPS, NEXT_DELAY_FRAMES, NextDelayTracker, TrapManager, evaluateBladeFuryStats, evaluateBladeSentinelStats, evaluateDeathSentryStats, evaluateFireBlastStats, evaluateLightningSentryStats, evaluateWakeOfFireStats, } from '../../../src/game/skills/assassin-traps.ts' // Martial Arts imports import { ASSASSIN_MARTIAL_SKILL_IDS, ChargeOrbManager, canCastDragonClaw, evaluateCobraStrikeStats, evaluateDragonClawStats, evaluateDragonFlightStats, evaluateDragonTailStats, evaluateDragonTalonStats, evaluatePhoenixStrikeStats, evaluateTigerStrikeStats, resolveFinisherDischarge, } from '../../../src/game/skills/assassin-martial-arts.ts' // Shadow Disciplines imports import { ASSASSIN_SHADOW_SKILL_IDS, ShadowBuffTracker, ShadowMinionManager, computeBurstOfSpeedDuration, computeBurstOfSpeedFRW, computeBurstOfSpeedIAS, computeClawMasteryAR, computeClawMasteryCriticalStrike, computeClawMasteryED, computeCloakOfShadowsDefenseBonus, computeCloakOfShadowsDefenseReduction, computeCloakOfShadowsDuration, computeFadeCurseReduction, computeFadeDuration, computeFadePhysicalDamageReduction, computeFadeResistances, computeMindBlastConversionChance, computeMindBlastDamage, computeMindBlastStunDuration, computePsychicHammerDamage, computePsychicHammerKnockbackChance, computeShadowMasterStats, computeShadowWarriorStats, computeVenomDuration, computeVenomPoisonDamage, computeWeaponBlockChance, resolveMindBlastTarget, resolveVenomPoisonClamp, resolveWeaponBlock, } from '../../../src/game/skills/assassin-shadow.ts' describe('Milestone M29 — Assassin Class Gate & Comprehensive E2E Integration Suite', () => { // ========================================================================= // Scenario 1: Traps Tree Synergy Network & 5-Band Progression (Traps) // ========================================================================= describe('Scenario 1: Traps Tree Synergy Network & 5-Band Progression (Traps)', () => { it('verifies Lightning Sentry 5-band damage progression and 3-way synergy (+360%)', () => { // Base Lightning Sentry slvl 20 (0 synergies): // Min: 1 (constant) // Max: compute5BandScaling(20, 20, 10, 16, 24, 34, 44) = 20 + 7*10 + 8*16 + 4*24 = 314 const baseLS = evaluateLightningSentryStats(20, {}) expect(baseLS.minDamage).toBe(1) expect(baseLS.maxDamage).toBe(314) expect(baseLS.shots).toBe(10) // Fully synergized Lightning Sentry (20 Shock Web, 20 Charged Bolt Sentry, 20 Death Sentry): // In 1.13c: +12% per blvl of Shock Web, CBS, Death Sentry (total 3 * 20 * 12% = +720%) const synLS = evaluateLightningSentryStats(20, { shockWeb: 20, chargedBoltSentry: 20, deathSentry: 20, }) expect(synLS.minDamage).toBe(1) expect(synLS.maxDamage).toBe(Math.floor(314 * (1 + 720 / 100))) // 2574 }) it('verifies Death Sentry corpse explosion radius, shot count, and lightning synergy', () => { // Base Death Sentry slvl 20, 0 Fire Blast blvl, 0 LS blvl const baseDS = evaluateDeathSentryStats(20, {}) // Radius: 10 + (20 - 1) = 29 subtiles (9.66 yards) expect(baseDS.corpseExplosionRadiusSubtiles).toBe(29) // Shot count: 5 + floor(0 / 3) = 5 expect(baseDS.shots).toBe(5) // Corpse explosion damage range: 40% to 80% of corpse max HP expect(baseDS.corpseExplosionMinPct).toBe(40) expect(baseDS.corpseExplosionMaxPct).toBe(80) // Death Sentry with 15 Fire Blast blvl -> shot count = 5 + floor(15/3) = 10 shots! const synDS = evaluateDeathSentryStats(20, { fireBlast: 15, lightningSentry: 20, }) expect(synDS.shots).toBe(10) // Lightning damage: base max is 50 + 7*11 + 8*15 + 4*19 = 323, boosted by +240% (20 * 12%) -> floor(323 * 3.4) = 1098 expect(synDS.maxLtngDamage).toBe(Math.floor(323 * 3.4)) }) it('verifies Blade weapon damage scaling (Blade Sentinel 3/8, Blade Fury 3/4)', () => { const bSentinel = evaluateBladeSentinelStats(20, 128) // 128 base weapon damage * 48 / 128 = 48 scaled weapon damage expect(bSentinel.scaledWeaponPhys).toBe(48) expect(BLADE_SRC_DAM.BLADE_SENTINEL).toBe(48) // 48/128 = 3/8 const bFury = evaluateBladeFuryStats(20, 128) // 128 base weapon damage * 96 / 128 = 96 scaled weapon damage expect(bFury.scaledWeaponPhys).toBe(96) expect(BLADE_SRC_DAM.BLADE_FURY).toBe(96) // 96/128 = 3/4 }) }) // ========================================================================= // Scenario 2: Martial Arts Charge-Up & Finisher Discharge Pipeline // ========================================================================= describe('Scenario 2: Martial Arts Charge-Up & Finisher Discharge Pipeline', () => { it('manages multi-skill charge accumulation and resolves full discharge on finisher contact', () => { const orbMgr = new ChargeOrbManager() // Charge up Tiger Strike to tier 3 orbMgr.addCharge(ASSASSIN_MARTIAL_SKILL_IDS.TIGER_STRIKE, 20, 100) orbMgr.addCharge(ASSASSIN_MARTIAL_SKILL_IDS.TIGER_STRIKE, 20, 150) orbMgr.addCharge(ASSASSIN_MARTIAL_SKILL_IDS.TIGER_STRIKE, 20, 200) // Charge up Cobra Strike to tier 2 orbMgr.addCharge(ASSASSIN_MARTIAL_SKILL_IDS.COBRA_STRIKE, 20, 250) orbMgr.addCharge(ASSASSIN_MARTIAL_SKILL_IDS.COBRA_STRIKE, 20, 300) expect(orbMgr.getChargeLevel(ASSASSIN_MARTIAL_SKILL_IDS.TIGER_STRIKE, 300)).toBe(3) expect(orbMgr.getChargeLevel(ASSASSIN_MARTIAL_SKILL_IDS.COBRA_STRIKE, 300)).toBe(2) // Finisher impacts: resolveFinisherDischarge releases all active charges const activeCharges = orbMgr.dischargeAll(300) expect(activeCharges.length).toBe(2) const payloads = resolveFinisherDischarge(activeCharges) expect(payloads.length).toBe(2) const tigerPayload = payloads.find(p => p.skillId === ASSASSIN_MARTIAL_SKILL_IDS.TIGER_STRIKE) expect(tigerPayload?.enhancedDamagePct).toBe(1440) // Tier 3 slvl 20: +1440% ED const cobraPayload = payloads.find(p => p.skillId === ASSASSIN_MARTIAL_SKILL_IDS.COBRA_STRIKE) expect(cobraPayload?.lifeStealPct).toBe(135) // Tier 2 slvl 20 expect(cobraPayload?.manaStealPct).toBe(135) // Tier 2 slvl 20 // OrbManager is now completely clear expect(orbMgr.getActiveCharges(300).length).toBe(0) }) it('verifies Phoenix Strike strictly discharges the targeted single-tier payload', () => { const pStats = evaluatePhoenixStrikeStats(20) // Tier 1: Meteor expect(pStats.meteorMinDamage).toBeGreaterThan(0) expect(pStats.meteorMaxDamage).toBeGreaterThan(pStats.meteorMinDamage) // Tier 2: Chain Lightning expect(pStats.chainLightningMaxDamage).toBeGreaterThan(0) expect(pStats.chainLightningTargets).toBeGreaterThan(0) // Tier 3: Chaos Ice Orb expect(pStats.chaosIceMaxDamage).toBeGreaterThan(0) expect(pStats.freezeDurationFrames).toBeGreaterThan(0) // Discharge resolution for Tier 1 const p1 = resolveFinisherDischarge([{ skillId: ASSASSIN_MARTIAL_SKILL_IDS.PHOENIX_STRIKE, count: 1, slvl: 20, expiresAtFrame: 1000, }])[0] expect(p1?.phoenixPayload?.tier).toBe(1) expect(p1?.phoenixPayload?.type).toBe('meteor') // Discharge resolution for Tier 2 const p2 = resolveFinisherDischarge([{ skillId: ASSASSIN_MARTIAL_SKILL_IDS.PHOENIX_STRIKE, count: 2, slvl: 20, expiresAtFrame: 1000, }])[0] expect(p2?.phoenixPayload?.tier).toBe(2) expect(p2?.phoenixPayload?.type).toBe('chain_lightning') // Discharge resolution for Tier 3 const p3 = resolveFinisherDischarge([{ skillId: ASSASSIN_MARTIAL_SKILL_IDS.PHOENIX_STRIKE, count: 3, slvl: 20, expiresAtFrame: 1000, }])[0] expect(p3?.phoenixPayload?.tier).toBe(3) expect(p3?.phoenixPayload?.type).toBe('chaos_ice') }) it('verifies Dragon Talon multi-kicks and Dragon Claw dual-claw strike mechanics', () => { // Dragon Talon kick count: 1 + floor(slvl / 6) expect(evaluateDragonTalonStats(1).kickCount).toBe(1) expect(evaluateDragonTalonStats(6).kickCount).toBe(2) expect(evaluateDragonTalonStats(12).kickCount).toBe(3) expect(evaluateDragonTalonStats(18).kickCount).toBe(4) expect(evaluateDragonTalonStats(24).kickCount).toBe(5) expect(evaluateDragonTalonStats(30).kickCount).toBe(6) // Dragon Claw requirement and dual strikes expect(canCastDragonClaw(true, false)).toBe(false) expect(canCastDragonClaw(true, true)).toBe(true) const dcStats = evaluateDragonClawStats(20, 20) expect(dcStats.strikesCount).toBe(2) // Base ED: 50 + 5 * 19 = 145% + Claw Mastery synergy (20 * 4% = +80%) = 225% expect(dcStats.enhancedDamagePct).toBe(225) }) }) // ========================================================================= // Scenario 3: Shadow Disciplines Buff Mechanics & Venom Bitrate Clamp // ========================================================================= describe('Scenario 3: Shadow Disciplines Buff Mechanics & Venom Bitrate Clamp', () => { it('enforces strict mutual exclusivity between Burst of Speed and Fade', () => { const tracker = new ShadowBuffTracker() tracker.applyBurstOfSpeed(20) expect(tracker.getBurstOfSpeed()?.params['iasPct']).toBe(53) expect(tracker.getFade()).toBeNull() // Activating Fade cancels BoS immediately const fadeRes = tracker.applyFade(20) expect(fadeRes.cancelledBoS).toBe(true) expect(tracker.getBurstOfSpeed()).toBeNull() expect(tracker.getFade()?.params['allResistPct']).toBe(65) expect(tracker.getFade()?.params['damageResistPct']).toBe(20) }) it('clamps combined poison damage to 10 frames under active Venom buff', () => { // Venom slvl 20 deals 170..178 over 10 frames // Adding weapon poison: 50 damage over 50 frames (1 dmg/frame) const clamped = resolveVenomPoisonClamp(20, [{ damage: 50, durationFrames: 50 }]) expect(clamped.finalDurationFrames).toBe(10) // 170 (venom base min) + 10 (weapon 1 dmg/frame * 10 frames) = 180 total expect(clamped.totalDamageDealt).toBe(180) }) it('enforces Cloak of Shadows recast lockout and defense modifications', () => { const tracker = new ShadowBuffTracker() const c1 = tracker.applyCloakOfShadows(20) expect(c1.success).toBe(true) expect(tracker.getCloakOfShadows()?.params['monsterDefReductionPct']).toBe(72) expect(tracker.getCloakOfShadows()?.params['assassinDefBonusPct']).toBe(67) // Recast while active must fail const c2 = tracker.applyCloakOfShadows(20) expect(c2.success).toBe(false) expect(c2.reason).toContain('recast locked') }) }) // ========================================================================= // Scenario 4: Minion & Summon Caps (5 Traps + 1 Shadow = 6 Max) // ========================================================================= describe('Scenario 4: Minion & Summon Caps (5 Traps + 1 Shadow = 6 Max)', () => { it('enforces global 5-trap FIFO eviction cap on PetType assassintrap', () => { const trapMgr = new TrapManager() const ownerId = 'assassin_player' // Summon 5 traps for (let i = 1; i <= 5; i++) { const t = trapMgr.registerTrap({ ownerId, skillId: ASSASSIN_TRAP_SKILL_IDS.LIGHTNING_SENTRY, summonMon: 'lightningsentry', x: i * 10, y: i * 10, slvl: 20, shots: 10, currentFrame: i * 100, }) expect(t.evictedTrap).toBeUndefined() } expect(trapMgr.getActiveTrapCount(ownerId)).toBe(5) // 6th trap causes FIFO eviction of trap 1 const sixth = trapMgr.registerTrap({ ownerId, skillId: ASSASSIN_TRAP_SKILL_IDS.DEATH_SENTRY, summonMon: 'deathsentry', x: 60, y: 60, slvl: 20, shots: 5, currentFrame: 600, }) expect(sixth.evictedTrap?.id).toBe('trap_1') expect(trapMgr.getActiveTrapCount(ownerId)).toBe(5) }) it('enforces PetType shadowwarrior cap = 1 and coexists with 5 active traps', () => { const trapMgr = new TrapManager() const shadowMgr = new ShadowMinionManager() const ownerId = 'assassin_player' // Summon 5 traps for (let i = 1; i <= 5; i++) { trapMgr.registerTrap({ ownerId, skillId: ASSASSIN_TRAP_SKILL_IDS.LIGHTNING_SENTRY, summonMon: 'lightningsentry', x: i * 10, y: i * 10, slvl: 20, shots: 10, currentFrame: 100, }) } expect(trapMgr.getActiveTrapCount(ownerId)).toBe(5) // Summon Shadow Warrior const s1 = shadowMgr.summonShadow('warrior', 20, 200) expect(s1.despawnedPrevious).toBe(false) expect(shadowMgr.getActiveShadow()?.type).toBe('warrior') // Summon Shadow Master -> replaces Shadow Warrior const s2 = shadowMgr.summonShadow('master', 20, 250) expect(s2.despawnedPrevious).toBe(true) expect(shadowMgr.getActiveShadow()?.type).toBe('master') // Total pets active: 5 traps + 1 shadow = 6 pets simultaneously expect(trapMgr.getActiveTrapCount(ownerId)).toBe(5) expect(shadowMgr.getActiveShadow()).not.toBeNull() }) }) // ========================================================================= // Scenario 5: NextDelay Collision Avoidance Across Trees // ========================================================================= describe('Scenario 5: NextDelay Collision Avoidance Across Trees', () => { it('enforces NextDelay hit protection (Wake of Fire 4f, Shock Web 25f, Dragon Flight 4f)', () => { const nd = new NextDelayTracker() const targetId = 'target_boss' const channelWoF = 'wof_channel' const channelShockWeb = 'shockweb_channel' // Wake of Fire (4 frames NextDelay) expect(nd.canTakeDamage(targetId, channelWoF, 100)).toBe(true) nd.recordHit(targetId, channelWoF, 100, NEXT_DELAY_FRAMES.WAKE_OF_FIRE) // Hits on frames 101, 102, 103 are rejected expect(nd.canTakeDamage(targetId, channelWoF, 101)).toBe(false) expect(nd.canTakeDamage(targetId, channelWoF, 102)).toBe(false) expect(nd.canTakeDamage(targetId, channelWoF, 103)).toBe(false) // Hit on frame 104 is accepted! expect(nd.canTakeDamage(targetId, channelWoF, 104)).toBe(true) // Shock Web (25 frames NextDelay) nd.recordHit(targetId, channelShockWeb, 200, NEXT_DELAY_FRAMES.SHOCK_WEB) expect(nd.canTakeDamage(targetId, channelShockWeb, 224)).toBe(false) expect(nd.canTakeDamage(targetId, channelShockWeb, 225)).toBe(true) }) }) // ========================================================================= // Scenario 6: Cross-Tree Hybrid Build Integration (Trapper / Kicksin / Shadow) // ========================================================================= describe('Scenario 6: Cross-Tree Hybrid Build Integration (Trapper / Kicksin / Shadow)', () => { it('simulates a complete hybrid combat loop without state pollution', () => { const buffs = new ShadowBuffTracker() const traps = new TrapManager() const shadows = new ShadowMinionManager() const orbs = new ChargeOrbManager() const ownerId = 'hybrid_assassin' // 1. Buff Burst of Speed buffs.applyBurstOfSpeed(20) expect(buffs.getBurstOfSpeed()?.params['frwPct']).toBe(61) // 2. Summon Shadow Master shadows.summonShadow('master', 20, 100) expect(shadows.getActiveShadow()?.stats.allResistPct).toBe(76) // 3. Lay 4 Lightning Sentries + 1 Death Sentry traps.registerTrap({ ownerId, skillId: ASSASSIN_TRAP_SKILL_IDS.LIGHTNING_SENTRY, summonMon: 'ls', x: 0, y: 0, slvl: 20, shots: 10, currentFrame: 150 }) traps.registerTrap({ ownerId, skillId: ASSASSIN_TRAP_SKILL_IDS.LIGHTNING_SENTRY, summonMon: 'ls', x: 2, y: 0, slvl: 20, shots: 10, currentFrame: 160 }) traps.registerTrap({ ownerId, skillId: ASSASSIN_TRAP_SKILL_IDS.LIGHTNING_SENTRY, summonMon: 'ls', x: -2, y: 0, slvl: 20, shots: 10, currentFrame: 170 }) traps.registerTrap({ ownerId, skillId: ASSASSIN_TRAP_SKILL_IDS.LIGHTNING_SENTRY, summonMon: 'ls', x: 0, y: 2, slvl: 20, shots: 10, currentFrame: 180 }) traps.registerTrap({ ownerId, skillId: ASSASSIN_TRAP_SKILL_IDS.DEATH_SENTRY, summonMon: 'ds', x: 0, y: -2, slvl: 20, shots: 5, currentFrame: 190 }) expect(traps.getActiveTrapCount(ownerId)).toBe(5) // 4. Engage in melee: build Tiger Strike charges orbs.addCharge(ASSASSIN_MARTIAL_SKILL_IDS.TIGER_STRIKE, 20, 200) orbs.addCharge(ASSASSIN_MARTIAL_SKILL_IDS.TIGER_STRIKE, 20, 220) orbs.addCharge(ASSASSIN_MARTIAL_SKILL_IDS.TIGER_STRIKE, 20, 240) expect(orbs.getChargeLevel(ASSASSIN_MARTIAL_SKILL_IDS.TIGER_STRIKE, 240)).toBe(3) // 5. Release via Dragon Talon multi-kick finisher const talon = evaluateDragonTalonStats(20) expect(talon.kickCount).toBe(4) // 1 + floor(20/6) = 4 kicks const discharge = resolveFinisherDischarge(orbs.dischargeAll(250)) expect(discharge[0]?.enhancedDamagePct).toBe(1440) expect(orbs.getActiveCharges(250).length).toBe(0) // Everything coexists in pristine state expect(traps.getActiveTrapCount(ownerId)).toBe(5) expect(shadows.getActiveShadow()).not.toBeNull() expect(buffs.getBurstOfSpeed()).not.toBeNull() }) }) // ========================================================================= // Scenario 7: Crowd Control & Status Effects (Mind Blast, Psychic Hammer, CoS) // ========================================================================= describe('Scenario 7: Crowd Control & Status Effects (Mind Blast, Psychic Hammer, CoS)', () => { it('verifies Mind Blast 4-yard AOE stun and normal monster conversion', () => { // Normal monster hit by Mind Blast const resNorm = resolveMindBlastTarget(20, { roll: 10 }) expect(resNorm.stunApplied).toBe(true) expect(resNorm.stunDurationFrames).toBe(145) // 50 + 5*(20-1) expect(resNorm.converted).toBe(true) // Unique/Boss immune to conversion const resBoss = resolveMindBlastTarget(20, { isBoss: true, roll: 0 }) expect(resBoss.stunApplied).toBe(true) expect(resBoss.stunDurationFrames).toBe(145) expect(resBoss.converted).toBe(false) }) it('verifies Psychic Hammer knockback and 50/50 magic/phys damage split', () => { const dmg = computePsychicHammerDamage(20) expect(dmg.minPhys).toBe(56) expect(dmg.minMagic).toBe(56) expect(dmg.maxPhys).toBe(79) expect(dmg.maxMagic).toBe(79) expect(computePsychicHammerKnockbackChance(20, 'normal')).toBe(100) expect(computePsychicHammerKnockbackChance(20, 'unique')).toBe(92) expect(computePsychicHammerKnockbackChance(20, 'boss')).toBe(87) }) }) // ========================================================================= // Scenario 8: Complete 30-Skill Assassin Tree Data Registry & Invariants // ========================================================================= describe('Scenario 8: Complete 30-Skill Assassin Tree Data Registry & Architectural Invariants', () => { it('verifies all 30 Assassin skills (IDs 251..280) exist in 1.13c Data Registry', async () => { const registry = await getSharedDataRegistry() // Assassin skill IDs are 251..280 inclusive for (let id = 251; id <= 280; id++) { const skill = registry.getSkillById(id) expect(skill, `Skill ${id} must exist in registry`).toBeDefined() expect(skill?.charClass).toBe('ass') expect(skill?.name).toBeDefined() expect(skill?.name.length).toBeGreaterThan(0) } }) it('enforces BATCH1_SKILLS length invariant = strictly 38', () => { expect(Object.keys(BATCH1_SKILLS).length).toBe(38) }) }) })