feat(items): add Assassin Honed property

This commit is contained in:
Crome696 2026-07-11 23:52:47 +02:00
parent 1bf9c263d5
commit b5b1b566cd
4 changed files with 369 additions and 1 deletions

View file

@ -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<int> 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<char> argument) => Numbers.Add(0);
public void Add(int number, ReadOnlySpan<char> argument) => Numbers.Add(number);
public void AddChunked(ReadOnlySpan<char> 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>(T value)
{
}
public void AppendFormatted<T>(T value, string format)
{
}
public void AppendFormatted<T>(T value, int alignment)
{
}
public void AppendFormatted<T>(T value, int alignment, string format)
{
}
public void AppendFormatted(ReadOnlySpan<char> value)
{
}
public void AppendFormatted(ReadOnlySpan<char> 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)
{
}
}
}

View file

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

View file

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

View file

@ -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() => "...";