feat(items): add Blood Drinker property

This commit is contained in:
Crome696 2026-07-09 08:10:12 +02:00
parent b485b843fd
commit d163761dcd
4 changed files with 466 additions and 4 deletions

View file

@ -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<char> argument) => Entries.Add((0, argument.ToString()));
public void Add(int number, ReadOnlySpan<char> argument) => Entries.Add((number, argument.ToString()));
public void AddChunked(ReadOnlySpan<char> 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>(T value) => _interpolated += value;
public void AppendFormatted<T>(T value, string format) =>
_interpolated += value is IFormattable formattable ? formattable.ToString(format, null) : value;
public void AppendFormatted<T>(T value, int alignment) => _interpolated += value;
public void AppendFormatted<T>(T value, int alignment, string format) =>
_interpolated += value is IFormattable formattable ? formattable.ToString(format, null) : value;
public void AppendFormatted(ReadOnlySpan<char> value) => _interpolated += value.ToString();
public void AppendFormatted(ReadOnlySpan<char> 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;
}
}

View file

@ -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
{

View file

@ -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)
{

View file

@ -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~%