feat(items): add Bone Breaker property
This commit is contained in:
parent
8a02a60f80
commit
519e8debbd
8 changed files with 720 additions and 1 deletions
|
|
@ -25,6 +25,9 @@ public static partial class EventSink
|
|||
public static event Action<Mobile> Logout;
|
||||
public static void InvokeLogout(Mobile m) => Logout?.Invoke(m);
|
||||
|
||||
public static event Action<Mobile, Map> MapChanged;
|
||||
public static void InvokeMapChanged(Mobile mobile, Map oldMap) => MapChanged?.Invoke(mobile, oldMap);
|
||||
|
||||
public static event Action<Mobile> Connected;
|
||||
public static void InvokeConnected(Mobile m) => Connected?.Invoke(m);
|
||||
|
||||
|
|
|
|||
|
|
@ -2581,6 +2581,7 @@ public partial class Mobile : IHued, IComparable<Mobile>, ISpawnable, IObjectPro
|
|||
ns.SendMobileAttributes(this);
|
||||
|
||||
OnMapChange(oldMap);
|
||||
EventSink.InvokeMapChanged(this, oldMap);
|
||||
}
|
||||
|
||||
public virtual void MoveToWorld(Point3D newLocation, Map map)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,490 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using Server;
|
||||
using Server.Items;
|
||||
using Server.Mobiles;
|
||||
using Server.Spells;
|
||||
using Xunit;
|
||||
|
||||
namespace UOContent.Tests;
|
||||
|
||||
[Collection("Sequential UOContent Tests")]
|
||||
public class BoneBreakerPropertyTests
|
||||
{
|
||||
private const int BoneBreakerCliloc = 1157318;
|
||||
private static readonly Point3D TestLocation = new(6200, 520, 0);
|
||||
|
||||
[Fact]
|
||||
public void ExtendedWeaponAttributes_StoresAndDupesBoneBreaker()
|
||||
{
|
||||
var weapon = new TestKatana();
|
||||
var dupe = new TestKatana();
|
||||
|
||||
try
|
||||
{
|
||||
weapon.ExtendedWeaponAttributes.BoneBreaker = 1;
|
||||
|
||||
weapon.Dupe(dupe);
|
||||
|
||||
Assert.Equal(1, weapon.ExtendedWeaponAttributes.BoneBreaker);
|
||||
Assert.Equal(1, dupe.ExtendedWeaponAttributes.BoneBreaker);
|
||||
}
|
||||
finally
|
||||
{
|
||||
weapon.Delete();
|
||||
dupe.Delete();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtendedWeaponAttributes_GetProperties_GatesBoneBreakerTooltipToTimeOfLegends()
|
||||
{
|
||||
var previousExpansion = Core.Expansion;
|
||||
var weapon = new TestKatana();
|
||||
|
||||
try
|
||||
{
|
||||
weapon.ExtendedWeaponAttributes.BoneBreaker = 1;
|
||||
|
||||
Core.Expansion = Expansion.HS;
|
||||
var highSeas = new RecordingPropertyList();
|
||||
weapon.ExtendedWeaponAttributes.GetProperties(highSeas);
|
||||
Assert.DoesNotContain(highSeas.Entries, entry => entry.Number == BoneBreakerCliloc);
|
||||
|
||||
Core.Expansion = Expansion.TOL;
|
||||
var timeOfLegends = new RecordingPropertyList();
|
||||
weapon.ExtendedWeaponAttributes.GetProperties(timeOfLegends);
|
||||
Assert.Contains(timeOfLegends.Entries, entry => entry.Number == BoneBreakerCliloc);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Core.Expansion = previousExpansion;
|
||||
weapon.Delete();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OnHit_NormalHitAppliesIndependentManaBonus()
|
||||
{
|
||||
var previousExpansion = Core.Expansion;
|
||||
var weapon = new TestKatana();
|
||||
var attacker = CreateMobile(mana: BoneBreaker.ManaThreshold);
|
||||
var defender = CreateMobile(location: new Point3D(6201, 520, 0));
|
||||
|
||||
try
|
||||
{
|
||||
Core.Expansion = Expansion.TOL;
|
||||
weapon.ExtendedWeaponAttributes.BoneBreaker = 1;
|
||||
|
||||
weapon.OnHit(attacker, defender);
|
||||
|
||||
Assert.Equal(0, attacker.Mana);
|
||||
Assert.Equal(149, defender.Hits);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Core.Expansion = previousExpansion;
|
||||
BoneBreaker.ClearAll();
|
||||
weapon.Delete();
|
||||
attacker.Delete();
|
||||
defender.Delete();
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0, 30)]
|
||||
[InlineData(20, 24)]
|
||||
[InlineData(40, 18)]
|
||||
[InlineData(75, 18)]
|
||||
public void TryProcOnNormalHit_ScalesManaCostWithLowerManaCost(int lowerManaCost, int expectedCost)
|
||||
{
|
||||
var previousExpansion = Core.Expansion;
|
||||
var weapon = new TestKatana();
|
||||
var attacker = CreateMobile(mana: BoneBreaker.ManaThreshold);
|
||||
var defender = CreateMobile(location: new Point3D(6201, 520, 0));
|
||||
|
||||
try
|
||||
{
|
||||
Core.Expansion = Expansion.TOL;
|
||||
weapon.Attributes.LowerManaCost = lowerManaCost;
|
||||
Assert.True(attacker.EquipItem(weapon));
|
||||
|
||||
BoneBreaker.TryProcOnNormalHit(attacker, defender, 1, 0);
|
||||
|
||||
Assert.Equal(BoneBreaker.ManaThreshold - expectedCost, attacker.Mana);
|
||||
Assert.Equal(150, defender.Hits);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Core.Expansion = previousExpansion;
|
||||
BoneBreaker.ClearAll();
|
||||
weapon.Delete();
|
||||
attacker.Delete();
|
||||
defender.Delete();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryProcOnNormalHit_BelowManaThresholdCanDrainButDoesNotDamageOrSpendMana()
|
||||
{
|
||||
var previousExpansion = Core.Expansion;
|
||||
var attacker = CreateMobile(mana: BoneBreaker.ManaThreshold - 1);
|
||||
var defender = CreateMobile(location: new Point3D(6201, 520, 0));
|
||||
|
||||
try
|
||||
{
|
||||
Core.Expansion = Expansion.TOL;
|
||||
|
||||
BoneBreaker.TryProcOnNormalHit(attacker, defender, 1, 100);
|
||||
|
||||
Assert.Equal(BoneBreaker.ManaThreshold - 1, attacker.Mana);
|
||||
Assert.Equal(200, defender.Hits);
|
||||
Assert.True(BoneBreaker.HasActiveDrain(defender));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Core.Expansion = previousExpansion;
|
||||
BoneBreaker.ClearAll();
|
||||
attacker.Delete();
|
||||
defender.Delete();
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(typeof(TestWeaponAbility))]
|
||||
[InlineData(typeof(TestSpecialMove))]
|
||||
public void OnHit_WeaponAbilitiesAndSpecialMovesDoNotTriggerBoneBreaker(Type actionType)
|
||||
{
|
||||
var previousExpansion = Core.Expansion;
|
||||
var weapon = new TestKatana();
|
||||
var attacker = CreateMobile(mana: BoneBreaker.ManaThreshold);
|
||||
var defender = CreateMobile(location: new Point3D(6201, 520, 0));
|
||||
|
||||
try
|
||||
{
|
||||
Core.Expansion = Expansion.TOL;
|
||||
weapon.ExtendedWeaponAttributes.BoneBreaker = 1;
|
||||
|
||||
if (typeof(WeaponAbility).IsAssignableFrom(actionType))
|
||||
{
|
||||
WeaponAbility.Table[attacker] = (WeaponAbility)Activator.CreateInstance(actionType);
|
||||
}
|
||||
else
|
||||
{
|
||||
SpecialMove.Table[attacker] = (SpecialMove)Activator.CreateInstance(actionType);
|
||||
}
|
||||
|
||||
weapon.OnHit(attacker, defender);
|
||||
|
||||
Assert.Equal(BoneBreaker.ManaThreshold, attacker.Mana);
|
||||
Assert.False(BoneBreaker.HasActiveDrain(defender));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Core.Expansion = previousExpansion;
|
||||
WeaponAbility.Table.Remove(attacker);
|
||||
SpecialMove.Table.Remove(attacker);
|
||||
BoneBreaker.ClearAll();
|
||||
weapon.Delete();
|
||||
attacker.Delete();
|
||||
defender.Delete();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Drain_BlocksOnlyRefreshPotionsUntilTheFourTicksComplete()
|
||||
{
|
||||
var previousExpansion = Core.Expansion;
|
||||
var attacker = CreateMobile();
|
||||
var defender = CreateMobile(location: new Point3D(6201, 520, 0));
|
||||
var refresh = new RefreshPotion();
|
||||
var totalRefresh = new TotalRefreshPotion();
|
||||
|
||||
try
|
||||
{
|
||||
Core.Expansion = Expansion.TOL;
|
||||
defender.Stam = defender.StamMax - 1;
|
||||
refresh.MoveToWorld(defender.Location, defender.Map);
|
||||
totalRefresh.MoveToWorld(defender.Location, defender.Map);
|
||||
BoneBreaker.TryProcOnNormalHit(attacker, defender, 1, 100);
|
||||
|
||||
Assert.True(BoneBreaker.HasActiveDrain(defender));
|
||||
Assert.False(refresh.CanDrink(defender));
|
||||
Assert.False(totalRefresh.CanDrink(defender));
|
||||
|
||||
for (var i = 0; i < BoneBreaker.DrainTickCount; i++)
|
||||
{
|
||||
Assert.True(BoneBreaker.TickForTests(defender));
|
||||
}
|
||||
|
||||
Assert.False(BoneBreaker.HasActiveDrain(defender));
|
||||
Assert.True(BoneBreaker.IsImmune(defender));
|
||||
Assert.True(refresh.CanDrink(defender));
|
||||
Assert.True(totalRefresh.CanDrink(defender));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Core.Expansion = previousExpansion;
|
||||
BoneBreaker.ClearAll();
|
||||
refresh.Delete();
|
||||
totalRefresh.Delete();
|
||||
attacker.Delete();
|
||||
defender.Delete();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Drain_UsesFourOneSecondTicksAndPreservesOneStamina()
|
||||
{
|
||||
var previousExpansion = Core.Expansion;
|
||||
var attacker = CreateMobile();
|
||||
var defender = CreateMobile(location: new Point3D(6201, 520, 0));
|
||||
|
||||
try
|
||||
{
|
||||
Core.Expansion = Expansion.TOL;
|
||||
defender.Stam = 2;
|
||||
BoneBreaker.TryProcOnNormalHit(attacker, defender, 1, 100);
|
||||
|
||||
Assert.Equal(TimeSpan.FromSeconds(1.0), BoneBreaker.DrainTickInterval);
|
||||
|
||||
for (var i = 0; i < BoneBreaker.DrainTickCount; i++)
|
||||
{
|
||||
Assert.True(BoneBreaker.TickForTests(defender));
|
||||
Assert.Equal(1, defender.Stam);
|
||||
}
|
||||
|
||||
Assert.False(BoneBreaker.HasActiveDrain(defender));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Core.Expansion = previousExpansion;
|
||||
BoneBreaker.ClearAll();
|
||||
attacker.Delete();
|
||||
defender.Delete();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ImmunityAfterDrainExpires_SuppressesBothBranchesWithoutManaCost()
|
||||
{
|
||||
var previousExpansion = Core.Expansion;
|
||||
var attacker = CreateMobile(mana: 100);
|
||||
var defender = CreateMobile(location: new Point3D(6201, 520, 0));
|
||||
|
||||
try
|
||||
{
|
||||
Core.Expansion = Expansion.TOL;
|
||||
BoneBreaker.TryProcOnNormalHit(attacker, defender, 1, 100);
|
||||
|
||||
for (var i = 0; i < BoneBreaker.DrainTickCount; i++)
|
||||
{
|
||||
BoneBreaker.TickForTests(defender);
|
||||
}
|
||||
|
||||
var hitsAfterFirstProc = defender.Hits;
|
||||
attacker.Mana = 100;
|
||||
|
||||
BoneBreaker.TryProcOnNormalHit(attacker, defender, 1, 100);
|
||||
|
||||
Assert.True(BoneBreaker.IsImmune(defender));
|
||||
Assert.False(BoneBreaker.HasActiveDrain(defender));
|
||||
Assert.Equal(100, attacker.Mana);
|
||||
Assert.Equal(hitsAfterFirstProc, defender.Hits);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Core.Expansion = previousExpansion;
|
||||
BoneBreaker.ClearAll();
|
||||
attacker.Delete();
|
||||
defender.Delete();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExpiredImmunity_AllowsBoneBreakerToProcAgain()
|
||||
{
|
||||
var previousExpansion = Core.Expansion;
|
||||
var attacker = CreateMobile(mana: 100);
|
||||
var defender = CreateMobile(location: new Point3D(6201, 520, 0));
|
||||
|
||||
try
|
||||
{
|
||||
Core.Expansion = Expansion.TOL;
|
||||
Assert.Equal(TimeSpan.FromSeconds(60), BoneBreaker.ImmunityDuration);
|
||||
BoneBreaker.TryProcOnNormalHit(attacker, defender, 1, 100);
|
||||
|
||||
for (var i = 0; i < BoneBreaker.DrainTickCount; i++)
|
||||
{
|
||||
BoneBreaker.TickForTests(defender);
|
||||
}
|
||||
|
||||
BoneBreaker.ExpireImmunityForTests(defender);
|
||||
attacker.Mana = 100;
|
||||
var hitsBeforeSecondProc = defender.Hits;
|
||||
|
||||
BoneBreaker.TryProcOnNormalHit(attacker, defender, 1, 100);
|
||||
|
||||
Assert.False(BoneBreaker.IsImmune(defender));
|
||||
Assert.True(BoneBreaker.HasActiveDrain(defender));
|
||||
Assert.Equal(70, attacker.Mana);
|
||||
Assert.Equal(hitsBeforeSecondProc - BoneBreaker.Damage, defender.Hits);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Core.Expansion = previousExpansion;
|
||||
BoneBreaker.ClearAll();
|
||||
attacker.Delete();
|
||||
defender.Delete();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DrainCleanup_ReleasesStateWhenTargetLeavesTheWorld()
|
||||
{
|
||||
var previousExpansion = Core.Expansion;
|
||||
var attacker = CreateMobile();
|
||||
var defender = CreateMobile(location: new Point3D(6201, 520, 0));
|
||||
|
||||
try
|
||||
{
|
||||
BoneBreaker.Configure();
|
||||
Core.Expansion = Expansion.TOL;
|
||||
var map = defender.Map;
|
||||
BoneBreaker.TryProcOnNormalHit(attacker, defender, 1, 100);
|
||||
defender.Internalize();
|
||||
defender.MoveToWorld(new Point3D(6201, 520, 0), map);
|
||||
|
||||
Assert.False(BoneBreaker.HasActiveDrain(defender));
|
||||
Assert.Equal(0, BoneBreaker.ActiveDrainCount);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Core.Expansion = previousExpansion;
|
||||
BoneBreaker.ClearAll();
|
||||
attacker.Delete();
|
||||
defender.Delete();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DrainCleanup_ReleasesStateWhenAttackerDies()
|
||||
{
|
||||
var previousExpansion = Core.Expansion;
|
||||
var attacker = CreateMobile();
|
||||
var defender = CreateMobile(location: new Point3D(6201, 520, 0));
|
||||
|
||||
try
|
||||
{
|
||||
Core.Expansion = Expansion.TOL;
|
||||
BoneBreaker.TryProcOnNormalHit(attacker, defender, 1, 100);
|
||||
|
||||
BoneBreaker.Clear(attacker);
|
||||
|
||||
Assert.False(BoneBreaker.HasActiveDrain(defender));
|
||||
Assert.Equal(0, BoneBreaker.ActiveDrainCount);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Core.Expansion = previousExpansion;
|
||||
BoneBreaker.ClearAll();
|
||||
attacker.Delete();
|
||||
defender.Delete();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RunicAttributeGeneration_DoesNotRollBoneBreaker()
|
||||
{
|
||||
var previousExpansion = Core.Expansion;
|
||||
var weapon = new TestKatana();
|
||||
|
||||
try
|
||||
{
|
||||
Core.Expansion = Expansion.TOL;
|
||||
|
||||
BaseRunicTool.ApplyAttributesTo(weapon, false, 0, 25, 100, 100);
|
||||
|
||||
Assert.Equal(0, weapon.ExtendedWeaponAttributes.BoneBreaker);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Core.Expansion = previousExpansion;
|
||||
weapon.Delete();
|
||||
}
|
||||
}
|
||||
|
||||
private static Mobile CreateMobile(int mana = 0, Point3D location = default)
|
||||
{
|
||||
var mobile = new Mobile(World.NewMobile);
|
||||
mobile.DefaultMobileInit();
|
||||
mobile.RawStr = 300;
|
||||
mobile.RawDex = 100;
|
||||
mobile.RawInt = 100;
|
||||
mobile.Hits = mobile.HitsMax;
|
||||
mobile.Stam = mobile.StamMax;
|
||||
mobile.Mana = mana;
|
||||
mobile.MoveToWorld(location == default ? TestLocation : location, Map.Felucca);
|
||||
return mobile;
|
||||
}
|
||||
|
||||
private class TestKatana : Katana
|
||||
{
|
||||
public override bool CheckHit(Mobile attacker, Mobile defender) => true;
|
||||
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 TestWeaponAbility : WeaponAbility
|
||||
{
|
||||
}
|
||||
|
||||
private class TestSpecialMove : SpecialMove
|
||||
{
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -29,6 +29,7 @@ public class ExtendedWeaponAttributesTests
|
|||
Assert.Equal(0x00000010, (int)ExtendedWeaponAttribute.HitSwarm);
|
||||
Assert.Equal(0x00000020, (int)ExtendedWeaponAttribute.SplinteringWeapon);
|
||||
Assert.Equal(0x00000040, (int)ExtendedWeaponAttribute.Focus);
|
||||
Assert.Equal(0x00000080, (int)ExtendedWeaponAttribute.BoneBreaker);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -47,6 +48,7 @@ public class ExtendedWeaponAttributesTests
|
|||
Assert.Equal(0, weapon.ExtendedWeaponAttributes.HitSwarm);
|
||||
Assert.Equal(0, weapon.ExtendedWeaponAttributes.SplinteringWeapon);
|
||||
Assert.Equal(0, weapon.ExtendedWeaponAttributes.Focus);
|
||||
Assert.Equal(0, weapon.ExtendedWeaponAttributes.BoneBreaker);
|
||||
}
|
||||
finally
|
||||
{
|
||||
|
|
@ -85,6 +87,7 @@ public class ExtendedWeaponAttributesTests
|
|||
AssertStaffCommandProperty(nameof(ExtendedWeaponAttributes.HitSwarm));
|
||||
AssertStaffCommandProperty(nameof(ExtendedWeaponAttributes.SplinteringWeapon));
|
||||
AssertStaffCommandProperty(nameof(ExtendedWeaponAttributes.Focus));
|
||||
AssertStaffCommandProperty(nameof(ExtendedWeaponAttributes.BoneBreaker));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -104,6 +107,7 @@ public class ExtendedWeaponAttributesTests
|
|||
weapon.ExtendedWeaponAttributes.HitSwarm = 30;
|
||||
weapon.ExtendedWeaponAttributes.SplinteringWeapon = 25;
|
||||
weapon.ExtendedWeaponAttributes.Focus = 1;
|
||||
weapon.ExtendedWeaponAttributes.BoneBreaker = 1;
|
||||
|
||||
var writer = new BufferWriter(true);
|
||||
weapon.Serialize(writer);
|
||||
|
|
@ -121,6 +125,7 @@ public class ExtendedWeaponAttributesTests
|
|||
Assert.Equal(30, deserialized.ExtendedWeaponAttributes.HitSwarm);
|
||||
Assert.Equal(25, deserialized.ExtendedWeaponAttributes.SplinteringWeapon);
|
||||
Assert.Equal(1, deserialized.ExtendedWeaponAttributes.Focus);
|
||||
Assert.Equal(1, deserialized.ExtendedWeaponAttributes.BoneBreaker);
|
||||
}
|
||||
finally
|
||||
{
|
||||
|
|
|
|||
|
|
@ -18,6 +18,11 @@ public abstract partial class BaseRefreshPotion : BasePotion
|
|||
return false;
|
||||
}
|
||||
|
||||
if (BoneBreaker.HasActiveDrain(from))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (from.Stam >= from.StamMax)
|
||||
{
|
||||
from.SendMessage("You decide against drinking this potion, as you are already at full stamina.");
|
||||
|
|
|
|||
|
|
@ -2390,6 +2390,7 @@ public abstract partial class BaseWeapon
|
|||
{
|
||||
Sparks.TryProcOnNormalHit(attacker, defender, ExtendedWeaponAttributes.HitSparks);
|
||||
Swarm.TryProcOnNormalHit(attacker, defender, ExtendedWeaponAttributes.HitSwarm);
|
||||
BoneBreaker.TryProcOnNormalHit(attacker, defender, ExtendedWeaponAttributes.BoneBreaker);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
201
Projects/UOContent/Items/Weapons/BoneBreaker.cs
Normal file
201
Projects/UOContent/Items/Weapons/BoneBreaker.cs
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using ModernUO.CodeGeneratedEvents;
|
||||
using Server.Collections;
|
||||
using Server.Mobiles;
|
||||
|
||||
namespace Server.Items;
|
||||
|
||||
public static class BoneBreaker
|
||||
{
|
||||
internal const int Damage = 50;
|
||||
internal const int DrainChance = 20;
|
||||
internal const int ManaThreshold = 30;
|
||||
internal const int DrainTickCount = 4;
|
||||
|
||||
internal static readonly TimeSpan DrainTickInterval = TimeSpan.FromSeconds(1.0);
|
||||
internal static readonly TimeSpan ImmunityDuration = TimeSpan.FromSeconds(60.0);
|
||||
|
||||
private static readonly Dictionary<Mobile, DrainContext> _drains = [];
|
||||
private static readonly Dictionary<Mobile, TimerExecutionToken> _immunities = [];
|
||||
|
||||
public static void Configure()
|
||||
{
|
||||
EventSink.Logout += Clear;
|
||||
EventSink.MapChanged += OnMapChanged;
|
||||
}
|
||||
|
||||
private static void OnMapChanged(Mobile mobile, Map oldMap) => Clear(mobile);
|
||||
|
||||
public static void TryProcOnNormalHit(Mobile attacker, Mobile defender, int boneBreaker)
|
||||
{
|
||||
TryProcOnNormalHit(attacker, defender, boneBreaker, DrainChance);
|
||||
}
|
||||
|
||||
internal static void TryProcOnNormalHit(Mobile attacker, Mobile defender, int boneBreaker, int drainChance)
|
||||
{
|
||||
if (!Core.TOL || boneBreaker <= 0 || !IsValidCombatant(attacker) || !IsValidCombatant(defender) ||
|
||||
IsImmune(defender))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (attacker.Mana >= ManaThreshold)
|
||||
{
|
||||
var manaCost = GetManaCost(attacker);
|
||||
attacker.Mana -= manaCost;
|
||||
AOS.Damage(defender, attacker, Damage, false, 100, 0, 0, 0, 0);
|
||||
}
|
||||
|
||||
if (drainChance > Utility.Random(100) && IsValidCombatant(defender) && !_drains.ContainsKey(defender))
|
||||
{
|
||||
_drains[defender] = new DrainContext(attacker, defender);
|
||||
}
|
||||
}
|
||||
|
||||
public static bool HasActiveDrain(Mobile mobile) => mobile != null && _drains.ContainsKey(mobile);
|
||||
|
||||
internal static bool IsImmune(Mobile mobile) => mobile != null && _immunities.ContainsKey(mobile);
|
||||
|
||||
internal static int ActiveDrainCount => _drains.Count;
|
||||
|
||||
internal static int GetManaCost(Mobile attacker)
|
||||
{
|
||||
var lowerManaCost = Math.Min(AosAttributes.GetValue(attacker, AosAttribute.LowerManaCost), 40);
|
||||
return (int)(ManaThreshold * (1.0 - lowerManaCost / 100.0));
|
||||
}
|
||||
|
||||
internal static bool TickForTests(Mobile defender)
|
||||
{
|
||||
if (!_drains.TryGetValue(defender, out var context))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
context.Tick();
|
||||
return true;
|
||||
}
|
||||
|
||||
internal static void ExpireImmunityForTests(Mobile mobile) => RemoveImmunity(mobile);
|
||||
|
||||
internal static void ClearAll()
|
||||
{
|
||||
foreach (var context in _drains.Values)
|
||||
{
|
||||
context.Stop();
|
||||
}
|
||||
|
||||
_drains.Clear();
|
||||
|
||||
foreach (var token in _immunities.Values)
|
||||
{
|
||||
token.Cancel();
|
||||
}
|
||||
|
||||
_immunities.Clear();
|
||||
}
|
||||
|
||||
[OnEvent(nameof(PlayerMobile.PlayerDeathEvent))]
|
||||
[OnEvent(nameof(PlayerMobile.PlayerDeletedEvent))]
|
||||
[OnEvent(nameof(BaseCreature.CreatureDeathEvent))]
|
||||
[OnEvent(nameof(BaseCreature.CreatureDeletedEvent))]
|
||||
public static void Clear(Mobile mobile)
|
||||
{
|
||||
if (mobile == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
using var defenders = PooledRefList<Mobile>.Create();
|
||||
|
||||
foreach (var (defender, context) in _drains)
|
||||
{
|
||||
if (defender == mobile || context.Attacker == mobile)
|
||||
{
|
||||
defenders.Add(defender);
|
||||
}
|
||||
}
|
||||
|
||||
for (var i = 0; i < defenders.Count; i++)
|
||||
{
|
||||
StopDrain(defenders[i], false);
|
||||
}
|
||||
|
||||
RemoveImmunity(mobile);
|
||||
}
|
||||
|
||||
private static bool IsValidCombatant(Mobile mobile) =>
|
||||
mobile is { Deleted: false, Alive: true } && mobile.Map != null && mobile.Map != Map.Internal;
|
||||
|
||||
private static void StopDrain(Mobile defender, bool grantImmunity)
|
||||
{
|
||||
if (!_drains.Remove(defender, out var context))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
context.Stop();
|
||||
|
||||
if (grantImmunity)
|
||||
{
|
||||
AddImmunity(defender);
|
||||
}
|
||||
}
|
||||
|
||||
private static void AddImmunity(Mobile defender)
|
||||
{
|
||||
if (_immunities.ContainsKey(defender))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Timer.StartTimer(ImmunityDuration, () => RemoveImmunity(defender), out var token);
|
||||
_immunities[defender] = token;
|
||||
}
|
||||
|
||||
private static void RemoveImmunity(Mobile mobile)
|
||||
{
|
||||
if (mobile != null && _immunities.Remove(mobile, out var token))
|
||||
{
|
||||
token.Cancel();
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class DrainContext
|
||||
{
|
||||
private readonly Mobile _defender;
|
||||
private TimerExecutionToken _tickTimerToken;
|
||||
|
||||
public DrainContext(Mobile attacker, Mobile defender)
|
||||
{
|
||||
Attacker = attacker;
|
||||
_defender = defender;
|
||||
TicksRemaining = DrainTickCount;
|
||||
Timer.StartTimer(DrainTickInterval, DrainTickInterval, Tick, out _tickTimerToken);
|
||||
}
|
||||
|
||||
public Mobile Attacker { get; }
|
||||
|
||||
public int TicksRemaining { get; private set; }
|
||||
|
||||
public void Stop() => _tickTimerToken.Cancel();
|
||||
|
||||
public void Tick()
|
||||
{
|
||||
if (!IsValidCombatant(Attacker) || !IsValidCombatant(_defender))
|
||||
{
|
||||
StopDrain(_defender, false);
|
||||
return;
|
||||
}
|
||||
|
||||
var staminaDrain = Math.Max(1, _defender.StamMax / 10);
|
||||
_defender.Stam = Math.Max(1, _defender.Stam - staminaDrain);
|
||||
TicksRemaining--;
|
||||
|
||||
if (TicksRemaining <= 0)
|
||||
{
|
||||
StopDrain(_defender, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1257,7 +1257,8 @@ namespace Server
|
|||
BloodDrinker = 0x00000008,
|
||||
HitSwarm = 0x00000010,
|
||||
SplinteringWeapon = 0x00000020,
|
||||
Focus = 0x00000040
|
||||
Focus = 0x00000040,
|
||||
BoneBreaker = 0x00000080
|
||||
}
|
||||
|
||||
public sealed class ExtendedWeaponAttributes : BaseAttributes
|
||||
|
|
@ -1345,6 +1346,13 @@ namespace Server
|
|||
}
|
||||
}
|
||||
|
||||
[CommandProperty(AccessLevel.GameMaster)]
|
||||
public int BoneBreaker
|
||||
{
|
||||
get => this[ExtendedWeaponAttribute.BoneBreaker];
|
||||
set => this[ExtendedWeaponAttribute.BoneBreaker] = value;
|
||||
}
|
||||
|
||||
public void GetProperties(IPropertyList list)
|
||||
{
|
||||
if (Core.HS && Bane != 0)
|
||||
|
|
@ -1381,6 +1389,11 @@ namespace Server
|
|||
{
|
||||
list.Add(1157325, HitSwarm); // Swarm ~1_val~%
|
||||
}
|
||||
|
||||
if (Core.TOL && BoneBreaker != 0)
|
||||
{
|
||||
list.Add(1157318); // Bone Breaker
|
||||
}
|
||||
}
|
||||
|
||||
public override string ToString() => "...";
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue