From d163761dcddc266c502ff699f53ce7e8564f97d9 Mon Sep 17 00:00:00 2001 From: Crome696 Date: Thu, 9 Jul 2026 08:10:12 +0200 Subject: [PATCH] feat(items): add Blood Drinker property --- .../Weapons/BloodDrinkerPropertyTests.cs | 412 ++++++++++++++++++ .../Weapons/ExtendedWeaponAttributesTests.cs | 5 + .../Items/Weapons/Abilities/BleedAttack.cs | 38 +- Projects/UOContent/Misc/AOS.cs | 15 +- 4 files changed, 466 insertions(+), 4 deletions(-) create mode 100644 Projects/UOContent.Tests/Tests/Items/Weapons/BloodDrinkerPropertyTests.cs diff --git a/Projects/UOContent.Tests/Tests/Items/Weapons/BloodDrinkerPropertyTests.cs b/Projects/UOContent.Tests/Tests/Items/Weapons/BloodDrinkerPropertyTests.cs new file mode 100644 index 000000000..322541f96 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Items/Weapons/BloodDrinkerPropertyTests.cs @@ -0,0 +1,412 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +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 BloodDrinkerPropertyTests +{ + private const int BloodDrinkerCliloc = 1113591; + private static readonly Point3D TestLocation = new(6200, 500, 0); + + [Fact] + public void ExtendedWeaponAttributes_StoresAndDupesBloodDrinker() + { + var weapon = new TestKatana(); + var dupe = new TestKatana(); + + try + { + weapon.ExtendedWeaponAttributes.BloodDrinker = 1; + + weapon.Dupe(dupe); + + Assert.Equal(1, weapon.ExtendedWeaponAttributes.BloodDrinker); + Assert.Equal(1, dupe.ExtendedWeaponAttributes.BloodDrinker); + } + finally + { + weapon.Delete(); + dupe.Delete(); + } + } + + [Fact] + public void BaseWeapon_GetProperties_GatesBloodDrinkerTooltipToStygianAbyss() + { + var previousExpansion = Core.Expansion; + var weapon = new TestKatana(); + + try + { + weapon.ExtendedWeaponAttributes.BloodDrinker = 1; + + Core.Expansion = Expansion.ML; + var preStygianAbyss = new RecordingPropertyList(); + weapon.GetProperties(preStygianAbyss); + Assert.DoesNotContain(preStygianAbyss.Entries, entry => entry.Number == BloodDrinkerCliloc); + + Core.Expansion = Expansion.SA; + var stygianAbyss = new RecordingPropertyList(); + weapon.GetProperties(stygianAbyss); + Assert.Contains(stygianAbyss.Entries, entry => entry.Number == BloodDrinkerCliloc); + } + finally + { + Core.Expansion = previousExpansion; + weapon.Delete(); + } + } + + [Fact] + public void CheckBloodDrinker_RequiresStygianAbyssLivingAttackerAndEquippedProperty() + { + var previousExpansion = Core.Expansion; + var attacker = CreateMobile(hitsMax: 200, hits: 100); + var weapon = EquipBloodDrinkerWeapon(attacker); + var deadAttacker = CreateMobile(hitsMax: 200, hits: 100); + var deadWeapon = EquipBloodDrinkerWeapon(deadAttacker); + var deletedAttacker = CreateMobile(hitsMax: 200, hits: 100); + var deletedWeapon = EquipBloodDrinkerWeapon(deletedAttacker); + + try + { + Core.Expansion = Expansion.SA; + Assert.True(BleedAttack.CheckBloodDrinker(attacker)); + + weapon.ExtendedWeaponAttributes.BloodDrinker = 0; + Assert.False(BleedAttack.CheckBloodDrinker(attacker)); + + weapon.ExtendedWeaponAttributes.BloodDrinker = 1; + Core.Expansion = Expansion.ML; + Assert.False(BleedAttack.CheckBloodDrinker(attacker)); + + Core.Expansion = Expansion.SA; + deadAttacker.Kill(); + Assert.False(BleedAttack.CheckBloodDrinker(deadAttacker)); + + deletedAttacker.Delete(); + Assert.False(BleedAttack.CheckBloodDrinker(deletedAttacker)); + } + finally + { + Core.Expansion = previousExpansion; + weapon.Delete(); + deadWeapon.Delete(); + deletedWeapon.Delete(); + attacker.Delete(); + deadAttacker.Delete(); + deletedAttacker.Delete(); + } + } + + [Fact] + public void DoBleed_WithBloodDrinker_HealsAttackerForAppliedPlayerDamage() + { + var previousExpansion = Core.Expansion; + using var random = new PredictableRandom(0); + var attacker = CreateMobile(hitsMax: 200, hits: 100); + var defender = CreateMobile(hitsMax: 500, hits: 500, player: true, location: new Point3D(6201, 500, 0)); + + try + { + Core.Expansion = Expansion.SA; + + BleedAttack.DoBleed(defender, attacker, 5, bloodDrinker: true); + + Assert.Equal(495, defender.Hits); + Assert.Equal(105, attacker.Hits); + } + finally + { + Core.Expansion = previousExpansion; + attacker.Delete(); + defender.Delete(); + } + } + + [Fact] + public void DoBleed_NonPlayerDefender_HealsForAppliedPvMBleedDamage() + { + var previousExpansion = Core.Expansion; + using var random = new PredictableRandom(0); + var attacker = CreateMobile(hitsMax: 300, hits: 150); + var defender = CreateMobile(hitsMax: 500, hits: 500, location: new Point3D(6201, 500, 0)); + + try + { + Core.Expansion = Expansion.SA; + + BleedAttack.DoBleed(defender, attacker, 5, bloodDrinker: true); + + Assert.Equal(490, defender.Hits); + Assert.Equal(160, attacker.Hits); + } + finally + { + Core.Expansion = previousExpansion; + attacker.Delete(); + defender.Delete(); + } + } + + [Fact] + public void DoBleed_WithoutBloodDrinker_DoesNotHealAttacker() + { + var previousExpansion = Core.Expansion; + using var random = new PredictableRandom(0); + var attacker = CreateMobile(hitsMax: 200, hits: 100); + var defender = CreateMobile(hitsMax: 500, hits: 500, player: true, location: new Point3D(6201, 500, 0)); + + try + { + Core.Expansion = Expansion.SA; + + BleedAttack.DoBleed(defender, attacker, 5); + + Assert.Equal(495, defender.Hits); + Assert.Equal(100, attacker.Hits); + } + finally + { + Core.Expansion = previousExpansion; + attacker.Delete(); + defender.Delete(); + } + } + + [Fact] + public void DoBleed_WithBloodDrinker_RespectsHitsMaxCap() + { + var previousExpansion = Core.Expansion; + using var random = new PredictableRandom(0); + var attacker = CreateMobile(hitsMax: 200, hits: 199); + var defender = CreateMobile(hitsMax: 500, hits: 500, player: true, location: new Point3D(6201, 500, 0)); + + try + { + Core.Expansion = Expansion.SA; + + BleedAttack.DoBleed(defender, attacker, 5, bloodDrinker: true); + + Assert.Equal(495, defender.Hits); + Assert.Equal(200, attacker.Hits); + } + finally + { + Core.Expansion = previousExpansion; + attacker.Delete(); + defender.Delete(); + } + } + + [Fact] + public void DoBleed_WithDeadAttacker_DoesNotHeal() + { + var previousExpansion = Core.Expansion; + using var random = new PredictableRandom(0); + var attacker = CreateMobile(hitsMax: 200, hits: 100); + var defender = CreateMobile(hitsMax: 500, hits: 500, player: true, location: new Point3D(6201, 500, 0)); + + try + { + Core.Expansion = Expansion.SA; + attacker.Kill(); + Assert.False(attacker.Alive); + + BleedAttack.DoBleed(defender, attacker, 5, bloodDrinker: true); + + Assert.Equal(495, defender.Hits); + Assert.Equal(0, attacker.Hits); + } + finally + { + Core.Expansion = previousExpansion; + attacker.Delete(); + defender.Delete(); + } + } + + [Fact] + public void OnHit_BleedImmuneTarget_DoesNotStartBleedContext() + { + var previousExpansion = Core.Expansion; + var attacker = CreateMobile(hitsMax: 200, hits: 100, mana: 100); + var defender = new TestBleedImmuneCreature(); + var weapon = EquipBloodDrinkerWeapon(attacker); + var ability = new BleedAttack(); + + try + { + Core.Expansion = Expansion.SA; + defender.DefaultMobileInit(); + defender.MoveToWorld(new Point3D(6201, 500, 0), Map.Felucca); + + ability.OnHit(attacker, defender, 1, new WorldLocation(defender)); + + Assert.False(BleedAttack.IsBleeding(defender)); + } + finally + { + Core.Expansion = previousExpansion; + BleedAttack.EndBleed(defender, false); + weapon.Delete(); + attacker.Delete(); + defender.Delete(); + } + } + + [Fact] + public void OnHit_FailedManaCheck_DoesNotStartBleedContext() + { + var previousExpansion = Core.Expansion; + var attacker = CreateMobile(hitsMax: 200, hits: 100, mana: 0); + var defender = CreateMobile(hitsMax: 500, hits: 500, location: new Point3D(6201, 500, 0)); + var weapon = EquipBloodDrinkerWeapon(attacker); + var ability = new BleedAttack(); + + try + { + Core.Expansion = Expansion.SA; + + ability.OnHit(attacker, defender, 1, new WorldLocation(defender)); + + Assert.False(BleedAttack.IsBleeding(defender)); + } + finally + { + Core.Expansion = previousExpansion; + BleedAttack.EndBleed(defender, false); + weapon.Delete(); + attacker.Delete(); + defender.Delete(); + } + } + + [Fact] + public void RunicAttributeGeneration_DoesNotRollBloodDrinker() + { + var previousExpansion = Core.Expansion; + var weapon = new TestKatana(); + + try + { + Core.Expansion = Expansion.SA; + + BaseRunicTool.ApplyAttributesTo(weapon, false, 0, 25, 100, 100); + + Assert.Equal(0, weapon.ExtendedWeaponAttributes.BloodDrinker); + } + finally + { + Core.Expansion = previousExpansion; + weapon.Delete(); + } + } + + private static TestKatana EquipBloodDrinkerWeapon(Mobile wielder) + { + var weapon = new TestKatana(); + weapon.ExtendedWeaponAttributes.BloodDrinker = 1; + wielder.AddItem(weapon); + return weapon; + } + + private static Mobile CreateMobile( + int hitsMax, + int hits, + int manaMax = 100, + int mana = 100, + bool player = false, + Point3D location = default + ) + { + var mobile = new Mobile(World.NewMobile) + { + Player = player + }; + + mobile.DefaultMobileInit(); + mobile.RawStr = Math.Max(1, (hitsMax - 50) * 2); + mobile.RawInt = manaMax; + Assert.Equal(hitsMax, mobile.HitsMax); + Assert.Equal(manaMax, mobile.ManaMax); + mobile.Hits = hits; + mobile.Mana = mana; + mobile.MoveToWorld(location == default ? TestLocation : location, Map.Felucca); + return mobile; + } + + private class TestKatana : Katana + { + public override int ComputeDamage(Mobile attacker, Mobile defender) => 1; + public override int AbsorbDamage(Mobile attacker, Mobile defender, int damage) => damage; + public override void AddBlood(Mobile attacker, Mobile defender, int damage) + { + } + } + + private class TestBleedImmuneCreature : BaseCreature + { + public TestBleedImmuneCreature() : base(AIType.AI_Melee, FightMode.Closest, 10, 1) + { + Body = 0xC8; + } + + public override bool BleedImmune => true; + + public override void GetSpeeds(out double activeSpeed, out double passiveSpeed) + { + activeSpeed = 0.2; + passiveSpeed = 0.4; + } + } + + private sealed class RecordingPropertyList : IPropertyList + { + private string _interpolated = string.Empty; + + public List<(int Number, string Argument)> Entries { get; } = []; + + public void Reset() + { + } + + public void Terminate() + { + } + + public void Add(int number) => Entries.Add((number, null)); + public void Add(int number, string argument) => Entries.Add((number, argument)); + public void Add(ReadOnlySpan argument) => Entries.Add((0, argument.ToString())); + public void Add(int number, ReadOnlySpan argument) => Entries.Add((number, argument.ToString())); + public void AddChunked(ReadOnlySpan text) => Entries.Add((0, text.ToString())); + public OplTextBlock TextBlock() => new(this); + public void Add(int number, int value) => Entries.Add((number, value.ToString(CultureInfo.InvariantCulture))); + public void AddLocalized(int value) => Entries.Add((0, value.ToString(CultureInfo.InvariantCulture))); + public void AddLocalized(int number, int value) => Entries.Add((number, value.ToString(CultureInfo.InvariantCulture))); + public void Add(ref IPropertyList.InterpolatedStringHandler handler) => Entries.Add((0, _interpolated)); + public void Add(int number, ref IPropertyList.InterpolatedStringHandler handler) => Entries.Add((number, _interpolated)); + public void InitializeInterpolation(int literalLength, int formattedCount) => _interpolated = string.Empty; + public void AppendLiteral(string value) => _interpolated += value; + public void AppendFormatted(T value) => _interpolated += value; + public void AppendFormatted(T value, string format) => + _interpolated += value is IFormattable formattable ? formattable.ToString(format, null) : value; + public void AppendFormatted(T value, int alignment) => _interpolated += value; + public void AppendFormatted(T value, int alignment, string format) => + _interpolated += value is IFormattable formattable ? formattable.ToString(format, null) : value; + public void AppendFormatted(ReadOnlySpan value) => _interpolated += value.ToString(); + public void AppendFormatted(ReadOnlySpan value, int alignment, string format = null) => + _interpolated += value.ToString(); + public void AppendFormatted(object value, int alignment = 0, string format = null) => _interpolated += value; + public void AppendFormatted(string value) => _interpolated += value; + public void AppendFormatted(string value, int alignment, string format = null) => _interpolated += value; + } +} diff --git a/Projects/UOContent.Tests/Tests/Items/Weapons/ExtendedWeaponAttributesTests.cs b/Projects/UOContent.Tests/Tests/Items/Weapons/ExtendedWeaponAttributesTests.cs index 75420971c..0e1cf0b5d 100644 --- a/Projects/UOContent.Tests/Tests/Items/Weapons/ExtendedWeaponAttributesTests.cs +++ b/Projects/UOContent.Tests/Tests/Items/Weapons/ExtendedWeaponAttributesTests.cs @@ -25,6 +25,7 @@ public class ExtendedWeaponAttributesTests Assert.Equal(0x00000001, (int)ExtendedWeaponAttribute.Bane); Assert.Equal(0x00000002, (int)ExtendedWeaponAttribute.BattleLust); Assert.Equal(0x00000004, (int)ExtendedWeaponAttribute.HitSparks); + Assert.Equal(0x00000008, (int)ExtendedWeaponAttribute.BloodDrinker); } [Fact] @@ -39,6 +40,7 @@ public class ExtendedWeaponAttributesTests Assert.Equal(0, weapon.ExtendedWeaponAttributes.Bane); Assert.Equal(0, weapon.ExtendedWeaponAttributes.BattleLust); Assert.Equal(0, weapon.ExtendedWeaponAttributes.HitSparks); + Assert.Equal(0, weapon.ExtendedWeaponAttributes.BloodDrinker); } finally { @@ -73,6 +75,7 @@ public class ExtendedWeaponAttributesTests AssertStaffCommandProperty(nameof(ExtendedWeaponAttributes.Bane)); AssertStaffCommandProperty(nameof(ExtendedWeaponAttributes.BattleLust)); AssertStaffCommandProperty(nameof(ExtendedWeaponAttributes.HitSparks)); + AssertStaffCommandProperty(nameof(ExtendedWeaponAttributes.BloodDrinker)); } [Fact] @@ -88,6 +91,7 @@ public class ExtendedWeaponAttributesTests weapon.ExtendedWeaponAttributes.Bane = 1; weapon.ExtendedWeaponAttributes.BattleLust = 1; weapon.ExtendedWeaponAttributes.HitSparks = 20; + weapon.ExtendedWeaponAttributes.BloodDrinker = 1; var writer = new BufferWriter(true); weapon.Serialize(writer); @@ -101,6 +105,7 @@ public class ExtendedWeaponAttributesTests Assert.Equal(1, deserialized.ExtendedWeaponAttributes.Bane); Assert.Equal(1, deserialized.ExtendedWeaponAttributes.BattleLust); Assert.Equal(20, deserialized.ExtendedWeaponAttributes.HitSparks); + Assert.Equal(1, deserialized.ExtendedWeaponAttributes.BloodDrinker); } finally { diff --git a/Projects/UOContent/Items/Weapons/Abilities/BleedAttack.cs b/Projects/UOContent/Items/Weapons/Abilities/BleedAttack.cs index bf07da601..34d1fe204 100644 --- a/Projects/UOContent/Items/Weapons/Abilities/BleedAttack.cs +++ b/Projects/UOContent/Items/Weapons/Abilities/BleedAttack.cs @@ -63,11 +63,18 @@ namespace Server.Items _table.TryGetValue(m, out var timer); timer?.Stop(); - _table[m] = timer = new InternalTimer(from, m); + var bloodDrinker = CheckBloodDrinker(from); + + _table[m] = timer = new InternalTimer(from, m, bloodDrinker); timer.Start(); } public static void DoBleed(Mobile m, Mobile from, int level) + { + DoBleed(m, from, level, false); + } + + internal static void DoBleed(Mobile m, Mobile from, int level, bool bloodDrinker) { if (m.Alive) { @@ -79,7 +86,20 @@ namespace Server.Items } m.PlaySound(0x133); + var oldHits = m.Hits; m.Damage(damage, from); + var applied = Math.Max(0, oldHits - m.Hits); + + if (bloodDrinker && applied > 0 && from is { Deleted: false, Alive: true }) + { + var oldFromHits = from.Hits; + from.Heal(applied, from, message: false); + + if (from.Hits > oldFromHits) + { + from.SendLocalizedMessage(1113606); // The blood drinker effect heals you. + } + } var blood = new Blood { ItemID = Utility.Random(0x122A, 5) }; blood.MoveToWorld(m.Location, m.Map); @@ -105,20 +125,32 @@ namespace Server.Items } } + internal static bool CheckBloodDrinker(Mobile from) + { + if (!Core.SA || from?.Deleted != false || !from.Alive) + { + return false; + } + + return from.Weapon is BaseWeapon weapon && weapon.ExtendedWeaponAttributes.BloodDrinker != 0; + } + private class InternalTimer : Timer { private readonly Mobile m_From; private readonly Mobile m_Mobile; + private readonly bool m_BloodDrinker; - public InternalTimer(Mobile from, Mobile m) : base(TimeSpan.FromSeconds(2.0), TimeSpan.FromSeconds(2.0), 5) + public InternalTimer(Mobile from, Mobile m, bool bloodDrinker) : base(TimeSpan.FromSeconds(2.0), TimeSpan.FromSeconds(2.0), 5) { m_From = from; m_Mobile = m; + m_BloodDrinker = bloodDrinker; } protected override void OnTick() { - DoBleed(m_Mobile, m_From, 5 - Index); + DoBleed(m_Mobile, m_From, 5 - Index, m_BloodDrinker); if (Index == 4) { diff --git a/Projects/UOContent/Misc/AOS.cs b/Projects/UOContent/Misc/AOS.cs index aacbad397..cf07a1b8f 100644 --- a/Projects/UOContent/Misc/AOS.cs +++ b/Projects/UOContent/Misc/AOS.cs @@ -1115,7 +1115,8 @@ namespace Server { Bane = 0x00000001, BattleLust = 0x00000002, - HitSparks = 0x00000004 + HitSparks = 0x00000004, + BloodDrinker = 0x00000008 } public sealed class ExtendedWeaponAttributes : BaseAttributes @@ -1165,6 +1166,13 @@ namespace Server set => this[ExtendedWeaponAttribute.HitSparks] = value; } + [CommandProperty(AccessLevel.GameMaster)] + public int BloodDrinker + { + get => this[ExtendedWeaponAttribute.BloodDrinker]; + set => this[ExtendedWeaponAttribute.BloodDrinker] = value; + } + public void GetProperties(IPropertyList list) { if (Core.HS && Bane != 0) @@ -1177,6 +1185,11 @@ namespace Server list.Add(1113710); // Battle Lust } + if (Core.SA && BloodDrinker != 0) + { + list.Add(1113591); // Blood Drinker + } + if (Core.TOL && HitSparks != 0) { list.Add(1157326, HitSparks); // Sparks ~1_val~%