diff --git a/Projects/UOContent.Tests/Tests/Items/Weapons/AssassinHonedPropertyTests.cs b/Projects/UOContent.Tests/Tests/Items/Weapons/AssassinHonedPropertyTests.cs new file mode 100644 index 000000000..e7e8338d1 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Items/Weapons/AssassinHonedPropertyTests.cs @@ -0,0 +1,332 @@ +using System; +using System.Collections.Generic; +using Server; +using Server.Items; +using Server.Mobiles; +using Server.Tests; +using Server.Text; +using Xunit; + +namespace UOContent.Tests; + +[Collection("Sequential UOContent Tests")] +public class AssassinHonedPropertyTests +{ + private const int AssassinHonedCliloc = 1152206; + + [Fact] + public void ExtendedWeaponAttributes_StoresAndDupesAssassinHoned() + { + var weapon = new TestKatana(); + var dupe = new TestKatana(); + + try + { + weapon.ExtendedWeaponAttributes.AssassinHoned = 1; + weapon.Dupe(dupe); + + Assert.Equal(1, weapon.ExtendedWeaponAttributes.AssassinHoned); + Assert.Equal(1, dupe.ExtendedWeaponAttributes.AssassinHoned); + } + finally + { + weapon.Delete(); + dupe.Delete(); + } + } + + [Fact] + public void ExtendedWeaponAttributes_GetProperties_GatesAssassinHonedTooltipToHighSeas() + { + var previousExpansion = Core.Expansion; + var weapon = new TestKatana(); + + try + { + weapon.ExtendedWeaponAttributes.AssassinHoned = 1; + + Core.Expansion = Expansion.ML; + var preHighSeas = new RecordingPropertyList(); + weapon.ExtendedWeaponAttributes.GetProperties(preHighSeas); + Assert.DoesNotContain(preHighSeas.Numbers, number => number == AssassinHonedCliloc); + + Core.Expansion = Expansion.HS; + var highSeas = new RecordingPropertyList(); + weapon.ExtendedWeaponAttributes.GetProperties(highSeas); + Assert.Contains(highSeas.Numbers, number => number == AssassinHonedCliloc); + } + finally + { + Core.Expansion = previousExpansion; + weapon.Delete(); + } + } + + [Theory] + [InlineData(2.0f, 73)] + [InlineData(4.0f, 36)] + public void GetAssassinHonedDamageBonus_UsesOriginalMlSpeed(float mlSpeed, int expectedBonus) + { + var previousExpansion = Core.Expansion; + var weapon = new TestKatana(mlSpeed); + var attacker = CreateMobile(Direction.East | Direction.Running); + var defender = CreateMobile(Direction.East); + + try + { + Core.Expansion = Expansion.HS; + weapon.ExtendedWeaponAttributes.AssassinHoned = 1; + weapon.Attributes.WeaponSpeed = 60; + + Assert.Equal(expectedBonus, weapon.GetAssassinHonedDamageBonus(attacker, defender)); + } + finally + { + Core.Expansion = previousExpansion; + weapon.Delete(); + attacker.Delete(); + defender.Delete(); + } + } + + [Fact] + public void GetAssassinHonedDamageBonus_RequiresHighSeasAndRearFacingRelation() + { + var previousExpansion = Core.Expansion; + var weapon = new TestKatana(); + var attacker = CreateMobile(Direction.East); + var defender = CreateMobile(Direction.East); + + try + { + weapon.ExtendedWeaponAttributes.AssassinHoned = 1; + + Core.Expansion = Expansion.ML; + Assert.Equal(0, weapon.GetAssassinHonedDamageBonus(attacker, defender)); + + Core.Expansion = Expansion.HS; + defender.Direction = Direction.West; + Assert.Equal(0, weapon.GetAssassinHonedDamageBonus(attacker, defender)); + } + finally + { + Core.Expansion = previousExpansion; + weapon.Delete(); + attacker.Delete(); + defender.Delete(); + } + } + + [Theory] + [InlineData(0, 73)] + [InlineData(1, 0)] + public void GetAssassinHonedDamageBonus_RangedWeaponsUseIndependentHalfChance(int randomValue, int expectedBonus) + { + var previousExpansion = Core.Expansion; + var weapon = new TestBow(); + var attacker = CreateMobile(Direction.East); + var defender = CreateMobile(Direction.East); + + try + { + Core.Expansion = Expansion.HS; + weapon.ExtendedWeaponAttributes.AssassinHoned = 1; + + using var random = new PredictableRandom(randomValue); + Assert.Equal(expectedBonus, weapon.GetAssassinHonedDamageBonus(attacker, defender)); + } + finally + { + Core.Expansion = previousExpansion; + weapon.Delete(); + attacker.Delete(); + defender.Delete(); + } + } + + [Fact] + public void OnHit_AssassinHonedUsesTheExistingThreeHundredPercentDamageCap() + { + var previousExpansion = Core.Expansion; + var weapon = new TestKatana(); + var attacker = CreateMobile(Direction.East); + var defender = CreateMobile(Direction.East); + + try + { + Core.Expansion = Expansion.HS; + weapon.ExtendedWeaponAttributes.AssassinHoned = 1; + var before = defender.Hits; + + weapon.OnHit(attacker, defender, 4.0); + + Assert.Equal(400, before - defender.Hits); + } + finally + { + Core.Expansion = previousExpansion; + weapon.Delete(); + attacker.Delete(); + defender.Delete(); + } + } + + [Fact] + public void OnSwing_MissDoesNotApplyAssassinHoned() + { + var previousExpansion = Core.Expansion; + var weapon = new TestKatana { Hit = false }; + var attacker = CreateMobile(Direction.East, Map.Felucca, new Point3D(6200, 500, 0)); + var defender = CreateMobile(Direction.East, Map.Felucca, new Point3D(6201, 500, 0)); + + try + { + Core.Expansion = Expansion.HS; + weapon.ExtendedWeaponAttributes.AssassinHoned = 1; + var before = defender.Hits; + + weapon.OnSwing(attacker, defender); + + Assert.Equal(before, defender.Hits); + } + finally + { + Core.Expansion = previousExpansion; + weapon.Delete(); + attacker.Delete(); + defender.Delete(); + } + } + + [Fact] + public void OnHit_ZeroDamageAfterAbsorptionDoesNotApplyAssassinHoned() + { + var previousExpansion = Core.Expansion; + var weapon = new TestKatana { AbsorbAllDamage = true }; + var attacker = CreateMobile(Direction.East); + var defender = CreateMobile(Direction.East); + + try + { + Core.Expansion = Expansion.HS; + weapon.ExtendedWeaponAttributes.AssassinHoned = 1; + var before = defender.Hits; + + weapon.OnHit(attacker, defender); + + Assert.Equal(before, defender.Hits); + } + finally + { + Core.Expansion = previousExpansion; + weapon.Delete(); + attacker.Delete(); + defender.Delete(); + } + } + + private static Mobile CreateMobile(Direction direction, Map map = null, Point3D location = default) + { + var mobile = new Mobile(World.NewMobile); + mobile.DefaultMobileInit(); + mobile.RawStr = 1000; + mobile.Hits = mobile.HitsMax; + mobile.Direction = direction; + + if (map != null) + { + mobile.MoveToWorld(location, map); + } + + return mobile; + } + + private sealed class TestKatana(float mlSpeed = 2.0f) : Katana + { + public bool AbsorbAllDamage { get; init; } + public bool Hit { get; init; } = true; + + public override float MlSpeed => mlSpeed; + public override bool CheckHit(Mobile attacker, Mobile defender) => Hit; + public override int ComputeDamage(Mobile attacker, Mobile defender) => 100; + public override int AbsorbDamage(Mobile attacker, Mobile defender, int damage) => AbsorbAllDamage ? 0 : damage; + + public override void AddBlood(Mobile attacker, Mobile defender, int damage) + { + } + } + + private sealed class TestBow : Bow + { + public override float MlSpeed => 2.0f; + } + + private sealed class RecordingPropertyList : IPropertyList + { + public List Numbers { get; } = []; + + public void Reset() + { + } + + public void Terminate() + { + } + + public void Add(int number) => Numbers.Add(number); + public void Add(int number, string argument) => Numbers.Add(number); + public void Add(ReadOnlySpan argument) => Numbers.Add(0); + public void Add(int number, ReadOnlySpan argument) => Numbers.Add(number); + public void AddChunked(ReadOnlySpan text) => Numbers.Add(0); + public OplTextBlock TextBlock() => new(this); + public void Add(int number, int value) => Numbers.Add(number); + public void AddLocalized(int value) => Numbers.Add(0); + public void AddLocalized(int number, int value) => Numbers.Add(number); + public void Add(ref IPropertyList.InterpolatedStringHandler handler) => Numbers.Add(0); + public void Add(int number, ref IPropertyList.InterpolatedStringHandler handler) => Numbers.Add(number); + + public void InitializeInterpolation(int literalLength, int formattedCount) + { + } + + public void AppendLiteral(string value) + { + } + + public void AppendFormatted(T value) + { + } + + public void AppendFormatted(T value, string format) + { + } + + public void AppendFormatted(T value, int alignment) + { + } + + public void AppendFormatted(T value, int alignment, string format) + { + } + + public void AppendFormatted(ReadOnlySpan value) + { + } + + public void AppendFormatted(ReadOnlySpan value, int alignment, string format = null) + { + } + + public void AppendFormatted(object value, int alignment = 0, string format = null) + { + } + + public void AppendFormatted(string value) + { + } + + public void AppendFormatted(string value, int alignment, string format = null) + { + } + } +} diff --git a/Projects/UOContent.Tests/Tests/Items/Weapons/ExtendedWeaponAttributesTests.cs b/Projects/UOContent.Tests/Tests/Items/Weapons/ExtendedWeaponAttributesTests.cs index b98021b40..86318d472 100644 --- a/Projects/UOContent.Tests/Tests/Items/Weapons/ExtendedWeaponAttributesTests.cs +++ b/Projects/UOContent.Tests/Tests/Items/Weapons/ExtendedWeaponAttributesTests.cs @@ -30,6 +30,7 @@ public class ExtendedWeaponAttributesTests Assert.Equal(0x00000020, (int)ExtendedWeaponAttribute.SplinteringWeapon); Assert.Equal(0x00000040, (int)ExtendedWeaponAttribute.Focus); Assert.Equal(0x00000080, (int)ExtendedWeaponAttribute.BoneBreaker); + Assert.Equal(0x00000100, (int)ExtendedWeaponAttribute.AssassinHoned); } [Fact] @@ -49,6 +50,7 @@ public class ExtendedWeaponAttributesTests Assert.Equal(0, weapon.ExtendedWeaponAttributes.SplinteringWeapon); Assert.Equal(0, weapon.ExtendedWeaponAttributes.Focus); Assert.Equal(0, weapon.ExtendedWeaponAttributes.BoneBreaker); + Assert.Equal(0, weapon.ExtendedWeaponAttributes.AssassinHoned); } finally { @@ -88,6 +90,7 @@ public class ExtendedWeaponAttributesTests AssertStaffCommandProperty(nameof(ExtendedWeaponAttributes.SplinteringWeapon)); AssertStaffCommandProperty(nameof(ExtendedWeaponAttributes.Focus)); AssertStaffCommandProperty(nameof(ExtendedWeaponAttributes.BoneBreaker)); + AssertStaffCommandProperty(nameof(ExtendedWeaponAttributes.AssassinHoned)); } [Fact] @@ -108,6 +111,7 @@ public class ExtendedWeaponAttributesTests weapon.ExtendedWeaponAttributes.SplinteringWeapon = 25; weapon.ExtendedWeaponAttributes.Focus = 1; weapon.ExtendedWeaponAttributes.BoneBreaker = 1; + weapon.ExtendedWeaponAttributes.AssassinHoned = 1; var writer = new BufferWriter(true); weapon.Serialize(writer); @@ -126,6 +130,7 @@ public class ExtendedWeaponAttributesTests Assert.Equal(25, deserialized.ExtendedWeaponAttributes.SplinteringWeapon); Assert.Equal(1, deserialized.ExtendedWeaponAttributes.Focus); Assert.Equal(1, deserialized.ExtendedWeaponAttributes.BoneBreaker); + Assert.Equal(1, deserialized.ExtendedWeaponAttributes.AssassinHoned); } finally { diff --git a/Projects/UOContent/Items/Weapons/BaseWeapon.cs b/Projects/UOContent/Items/Weapons/BaseWeapon.cs index eb85e41b5..297edc2c6 100644 --- a/Projects/UOContent/Items/Weapons/BaseWeapon.cs +++ b/Projects/UOContent/Items/Weapons/BaseWeapon.cs @@ -2050,6 +2050,7 @@ public abstract partial class BaseWeapon // Canonical Focus endpoints are -50% and +20%. Intermediate values use a documented 10-point step. percentageBonus += FocusContext.GetDamageBonus(this, attacker, defender); percentageBonus += BattleLust.GetDamageBonus(attacker, defender); + percentageBonus += GetAssassinHonedDamageBonus(attacker, defender); percentageBonus = Math.Min(percentageBonus, 300); @@ -2580,6 +2581,23 @@ public abstract partial class BaseWeapon return (int)(potentialDamage - defender.Hits / (double)defender.HitsMax * potentialDamage); } + internal int GetAssassinHonedDamageBonus(Mobile attacker, Mobile defender) + { + if (!Core.HS || ExtendedWeaponAttributes.AssassinHoned == 0 || MlSpeed <= 0 || + (attacker.Direction & Direction.Mask) != (defender.Direction & Direction.Mask)) + { + return 0; + } + + if (this is BaseRanged && Utility.Random(2) != 0) + { + return 0; + } + + // Publish 74 policy: original ML speed only; SSI does not affect this bonus. + return (int)(146.0 / MlSpeed); + } + public virtual void GetDamageTypes( Mobile wielder, out int phys, out int fire, out int cold, out int pois, out int nrgy, out int chaos, out int direct diff --git a/Projects/UOContent/Misc/AOS.cs b/Projects/UOContent/Misc/AOS.cs index b5c64e8b5..81dec8c77 100644 --- a/Projects/UOContent/Misc/AOS.cs +++ b/Projects/UOContent/Misc/AOS.cs @@ -1274,7 +1274,8 @@ namespace Server HitSwarm = 0x00000010, SplinteringWeapon = 0x00000020, Focus = 0x00000040, - BoneBreaker = 0x00000080 + BoneBreaker = 0x00000080, + AssassinHoned = 0x00000100 } public sealed class ExtendedWeaponAttributes : BaseAttributes @@ -1369,6 +1370,13 @@ namespace Server set => this[ExtendedWeaponAttribute.BoneBreaker] = value; } + [CommandProperty(AccessLevel.GameMaster)] + public int AssassinHoned + { + get => this[ExtendedWeaponAttribute.AssassinHoned]; + set => this[ExtendedWeaponAttribute.AssassinHoned] = value; + } + public void GetProperties(IPropertyList list) { if (Core.HS && Bane != 0) @@ -1410,6 +1418,11 @@ namespace Server { list.Add(1157318); // Bone Breaker } + + if (Core.HS && AssassinHoned != 0) + { + list.Add(1152206); // Assassin Honed + } } public override string ToString() => "...";