diff --git a/Projects/UOContent.Tests/Tests/Items/Weapons/ExtendedWeaponAttributesTests.cs b/Projects/UOContent.Tests/Tests/Items/Weapons/ExtendedWeaponAttributesTests.cs index aba4db807..9df8be24f 100644 --- a/Projects/UOContent.Tests/Tests/Items/Weapons/ExtendedWeaponAttributesTests.cs +++ b/Projects/UOContent.Tests/Tests/Items/Weapons/ExtendedWeaponAttributesTests.cs @@ -28,6 +28,7 @@ public class ExtendedWeaponAttributesTests Assert.Equal(0x00000008, (int)ExtendedWeaponAttribute.BloodDrinker); Assert.Equal(0x00000010, (int)ExtendedWeaponAttribute.HitSwarm); Assert.Equal(0x00000020, (int)ExtendedWeaponAttribute.SplinteringWeapon); + Assert.Equal(0x00000040, (int)ExtendedWeaponAttribute.Focus); } [Fact] @@ -45,6 +46,7 @@ public class ExtendedWeaponAttributesTests Assert.Equal(0, weapon.ExtendedWeaponAttributes.BloodDrinker); Assert.Equal(0, weapon.ExtendedWeaponAttributes.HitSwarm); Assert.Equal(0, weapon.ExtendedWeaponAttributes.SplinteringWeapon); + Assert.Equal(0, weapon.ExtendedWeaponAttributes.Focus); } finally { @@ -82,10 +84,11 @@ public class ExtendedWeaponAttributesTests AssertStaffCommandProperty(nameof(ExtendedWeaponAttributes.BloodDrinker)); AssertStaffCommandProperty(nameof(ExtendedWeaponAttributes.HitSwarm)); AssertStaffCommandProperty(nameof(ExtendedWeaponAttributes.SplinteringWeapon)); + AssertStaffCommandProperty(nameof(ExtendedWeaponAttributes.Focus)); } [Fact] - public void ExtendedWeaponAttributes_PersistBaneAndBattleLustThroughBaseWeaponSerialization() + public void ExtendedWeaponAttributes_PersistThroughBaseWeaponSerialization() { var previousExpansion = Core.Expansion; var weapon = new TestKatana(); @@ -100,6 +103,7 @@ public class ExtendedWeaponAttributesTests weapon.ExtendedWeaponAttributes.BloodDrinker = 1; weapon.ExtendedWeaponAttributes.HitSwarm = 30; weapon.ExtendedWeaponAttributes.SplinteringWeapon = 25; + weapon.ExtendedWeaponAttributes.Focus = 1; var writer = new BufferWriter(true); weapon.Serialize(writer); @@ -116,6 +120,7 @@ public class ExtendedWeaponAttributesTests Assert.Equal(1, deserialized.ExtendedWeaponAttributes.BloodDrinker); Assert.Equal(30, deserialized.ExtendedWeaponAttributes.HitSwarm); Assert.Equal(25, deserialized.ExtendedWeaponAttributes.SplinteringWeapon); + Assert.Equal(1, deserialized.ExtendedWeaponAttributes.Focus); } finally { diff --git a/Projects/UOContent.Tests/Tests/Items/Weapons/FocusPropertyTests.cs b/Projects/UOContent.Tests/Tests/Items/Weapons/FocusPropertyTests.cs new file mode 100644 index 000000000..86bb9abe5 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Items/Weapons/FocusPropertyTests.cs @@ -0,0 +1,605 @@ +using System; +using System.Collections.Generic; +using Server; +using Server.Items; +using Server.Mobiles; +using Server.Text; +using Xunit; + +namespace UOContent.Tests; + +[Collection("Sequential UOContent Tests")] +public class FocusPropertyTests +{ + private const int FocusCliloc = 1150018; + private static readonly Point3D TestLocation = new(6200, 500, 0); + + public FocusPropertyTests() + { + FocusContext.Configure(); + } + + [Fact] + public void ExtendedWeaponAttributes_StoresAndDupesFocus() + { + var weapon = new TestKatana(); + var dupe = new TestKatana(); + + try + { + weapon.ExtendedWeaponAttributes.Focus = 1; + weapon.Dupe(dupe); + + Assert.Equal(1, weapon.ExtendedWeaponAttributes.Focus); + Assert.Equal(1, dupe.ExtendedWeaponAttributes.Focus); + } + finally + { + weapon.Delete(); + dupe.Delete(); + } + } + + [Fact] + public void FocusContext_IsTransientAndIsNotRestoredByWeaponSerialization() + { + var previousExpansion = Core.Expansion; + var attacker = CreateMobile(); + var defender = CreateMobile(location: new Point3D(6201, 500, 0)); + var weapon = EquipFocusWeapon(attacker); + var deserialized = new TestKatana(); + + try + { + Core.Expansion = Expansion.HS; + weapon.OnHit(attacker, defender); + Assert.Equal(1, FocusContext.ActiveContextCount); + + var writer = new BufferWriter(true); + weapon.Serialize(writer); + var buffer = new byte[writer.Position]; + writer.Buffer.AsSpan(0, (int)writer.Position).CopyTo(buffer); + + deserialized.Deserialize(new BufferReader(buffer)); + + Assert.Equal(1, deserialized.ExtendedWeaponAttributes.Focus); + Assert.Equal(0, FocusContext.GetCurrentModifierForTests(deserialized)); + } + finally + { + Core.Expansion = previousExpansion; + FocusContext.ClearAll(); + weapon.Delete(); + deserialized.Delete(); + attacker.Delete(); + defender.Delete(); + } + } + + [Fact] + public void ExtendedWeaponAttributes_GetProperties_GatesFocusTooltipToHighSeas() + { + var previousExpansion = Core.Expansion; + var weapon = new TestKatana(); + + try + { + weapon.ExtendedWeaponAttributes.Focus = 1; + + Core.Expansion = Expansion.HS - 1; + var preHighSeas = new RecordingPropertyList(); + weapon.ExtendedWeaponAttributes.GetProperties(preHighSeas); + Assert.DoesNotContain(preHighSeas.Numbers, number => number == FocusCliloc); + + Core.Expansion = Expansion.HS; + var highSeas = new RecordingPropertyList(); + weapon.ExtendedWeaponAttributes.GetProperties(highSeas); + Assert.Contains(highSeas.Numbers, number => number == FocusCliloc); + } + finally + { + Core.Expansion = previousExpansion; + weapon.Delete(); + } + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void OnHit_UsesTheSameFocusSequenceForPvmAndPvp(bool playerTarget) + { + var previousExpansion = Core.Expansion; + var attacker = CreateMobile(); + var defender = CreateMobile( + player: playerTarget, + location: new Point3D(6201, 500, 0), + hitsMax: 5000, + hits: 5000 + ); + var weapon = EquipFocusWeapon(attacker); + + try + { + Core.Expansion = Expansion.HS; + var expectedDamage = new[] { 50, 60, 70, 80, 90, 100, 110, 120, 120 }; + + foreach (var expected in expectedDamage) + { + var before = defender.Hits; + weapon.OnHit(attacker, defender); + Assert.Equal(expected, before - defender.Hits); + } + + Assert.Equal(FocusContext.MaximumDamageModifier, FocusContext.GetCurrentModifierForTests(weapon)); + } + finally + { + Core.Expansion = previousExpansion; + FocusContext.ClearAll(); + weapon.Delete(); + attacker.Delete(); + defender.Delete(); + } + } + + [Fact] + public void OnHit_ChangingTargetResetsToTheNegativeStartingModifier() + { + var previousExpansion = Core.Expansion; + var attacker = CreateMobile(); + var firstTarget = CreateMobile(location: new Point3D(6201, 500, 0)); + var secondTarget = CreateMobile(location: new Point3D(6202, 500, 0)); + var weapon = EquipFocusWeapon(attacker); + + try + { + Core.Expansion = Expansion.HS; + + var firstBefore = firstTarget.Hits; + weapon.OnHit(attacker, firstTarget); + Assert.Equal(50, firstBefore - firstTarget.Hits); + + firstBefore = firstTarget.Hits; + weapon.OnHit(attacker, firstTarget); + Assert.Equal(60, firstBefore - firstTarget.Hits); + + var secondBefore = secondTarget.Hits; + weapon.OnHit(attacker, secondTarget); + Assert.Equal(50, secondBefore - secondTarget.Hits); + Assert.Equal(-40, FocusContext.GetCurrentModifierForTests(weapon)); + } + finally + { + Core.Expansion = previousExpansion; + FocusContext.ClearAll(); + weapon.Delete(); + attacker.Delete(); + firstTarget.Delete(); + secondTarget.Delete(); + } + } + + [Fact] + public void MissesParriesAndZeroDamageHitsDoNotAdvanceFocus() + { + var previousExpansion = Core.Expansion; + var attacker = CreateMobile(); + var defender = CreateMobile(location: new Point3D(6201, 500, 0)); + var weapon = EquipFocusWeapon(attacker); + + try + { + Core.Expansion = Expansion.HS; + + var before = defender.Hits; + weapon.OnHit(attacker, defender); + Assert.Equal(50, before - defender.Hits); + + weapon.OnMiss(attacker, defender); + before = defender.Hits; + weapon.OnHit(attacker, defender); + Assert.Equal(60, before - defender.Hits); + + weapon.AbsorbAllDamage = true; + WeaponAbility.Table[attacker] = new TestWeaponAbility(); + before = defender.Hits; + weapon.OnHit(attacker, defender); + Assert.Equal(before, defender.Hits); + + WeaponAbility.Table.Remove(attacker); + before = defender.Hits; + weapon.OnHit(attacker, defender); + Assert.Equal(before, defender.Hits); + + weapon.AbsorbAllDamage = false; + before = defender.Hits; + weapon.OnHit(attacker, defender); + Assert.Equal(70, before - defender.Hits); + } + finally + { + Core.Expansion = previousExpansion; + WeaponAbility.Table.Remove(attacker); + FocusContext.ClearAll(); + weapon.Delete(); + attacker.Delete(); + defender.Delete(); + } + } + + [Fact] + public void OnHit_CombinesFocusOnceWithAnExistingWeaponDamageModifier() + { + var previousExpansion = Core.Expansion; + var attacker = CreateMobile(); + var defender = CreateMobile(location: new Point3D(6201, 500, 0)); + var weapon = EquipFocusWeapon(attacker); + + try + { + Core.Expansion = Expansion.HS; + + var before = defender.Hits; + weapon.OnHit(attacker, defender, 2.0); + + // Existing +100% bonus and Focus -50% are combined once in the shared percentage stage. + Assert.Equal(150, before - defender.Hits); + } + finally + { + Core.Expansion = previousExpansion; + FocusContext.ClearAll(); + weapon.Delete(); + attacker.Delete(); + defender.Delete(); + } + } + + [Fact] + public void OnHit_PreHighSeas_DoesNotApplyOrCreateFocusState() + { + var previousExpansion = Core.Expansion; + var attacker = CreateMobile(); + var defender = CreateMobile(location: new Point3D(6201, 500, 0)); + var weapon = EquipFocusWeapon(attacker); + + try + { + Core.Expansion = Expansion.SA; + var before = defender.Hits; + weapon.OnHit(attacker, defender); + + Assert.Equal(100, before - defender.Hits); + Assert.Equal(0, FocusContext.ActiveContextCount); + } + finally + { + Core.Expansion = previousExpansion; + FocusContext.ClearAll(); + weapon.Delete(); + attacker.Delete(); + defender.Delete(); + } + } + + [Fact] + public void OnHit_PlayerTargetsUseTheSameSequenceAndResetOnTargetSwitch() + { + var previousExpansion = Core.Expansion; + var attacker = CreateMobile(); + var firstTarget = CreatePlayerMobile(new Point3D(6201, 500, 0)); + var secondTarget = CreatePlayerMobile(new Point3D(6202, 500, 0)); + var weapon = EquipFocusWeapon(attacker); + + try + { + Core.Expansion = Expansion.HS; + + var before = firstTarget.Hits; + weapon.OnHit(attacker, firstTarget); + Assert.Equal(50, before - firstTarget.Hits); + + before = firstTarget.Hits; + weapon.OnHit(attacker, firstTarget); + Assert.Equal(60, before - firstTarget.Hits); + + before = secondTarget.Hits; + weapon.OnHit(attacker, secondTarget); + Assert.Equal(50, before - secondTarget.Hits); + } + finally + { + Core.Expansion = previousExpansion; + FocusContext.ClearAll(); + weapon.Delete(); + attacker.Delete(); + firstTarget.Delete(); + secondTarget.Delete(); + } + } + + [Fact] + public void FocusContext_ClearsWhenWeaponIsUnequippedDeletedOrPropertyRemoved() + { + var previousExpansion = Core.Expansion; + var attacker = CreateMobile(); + var defender = CreateMobile(location: new Point3D(6201, 500, 0)); + var weapon = EquipFocusWeapon(attacker); + + try + { + Core.Expansion = Expansion.HS; + weapon.OnHit(attacker, defender); + Assert.Equal(1, FocusContext.ActiveContextCount); + + attacker.RemoveItem(weapon); + Assert.Equal(0, FocusContext.ActiveContextCount); + + attacker.AddItem(weapon); + weapon.OnHit(attacker, defender); + Assert.Equal(1, FocusContext.ActiveContextCount); + + weapon.ExtendedWeaponAttributes.Focus = 0; + Assert.Equal(0, FocusContext.ActiveContextCount); + + weapon.ExtendedWeaponAttributes.Focus = 1; + attacker.AddItem(weapon); + weapon.OnHit(attacker, defender); + Assert.Equal(1, FocusContext.ActiveContextCount); + + weapon.Delete(); + Assert.Equal(0, FocusContext.ActiveContextCount); + } + finally + { + Core.Expansion = previousExpansion; + FocusContext.ClearAll(); + weapon.Delete(); + attacker.Delete(); + defender.Delete(); + } + } + + [Fact] + public void FocusContext_ClearsWhenTargetDiesAndWhenAttackerLogsOutOrDies() + { + var previousExpansion = Core.Expansion; + var attacker = CreatePlayerMobile(); + var target = CreatePlayerMobile(new Point3D(6201, 500, 0)); + var deletedTarget = CreatePlayerMobile(new Point3D(6202, 500, 0)); + var secondTarget = CreateMobile(location: new Point3D(6203, 500, 0)); + var weapon = EquipFocusWeapon(attacker); + + try + { + Core.Expansion = Expansion.HS; + weapon.OnHit(attacker, target); + Assert.Equal(1, FocusContext.ActiveContextCount); + + PlayerMobile.PlayerDeathEvent(target); + Assert.Equal(0, FocusContext.ActiveContextCount); + + weapon.OnHit(attacker, deletedTarget); + Assert.Equal(1, FocusContext.ActiveContextCount); + + deletedTarget.Delete(); + Assert.Equal(0, FocusContext.ActiveContextCount); + + weapon.OnHit(attacker, secondTarget); + Assert.Equal(1, FocusContext.ActiveContextCount); + + EventSink.InvokeLogout(attacker); + Assert.Equal(0, FocusContext.ActiveContextCount); + + weapon.OnHit(attacker, secondTarget); + Assert.Equal(1, FocusContext.ActiveContextCount); + + PlayerMobile.PlayerDeathEvent(attacker); + Assert.Equal(0, FocusContext.ActiveContextCount); + } + finally + { + Core.Expansion = previousExpansion; + FocusContext.ClearAll(); + weapon.Delete(); + attacker.Delete(); + target.Delete(); + deletedTarget.Delete(); + secondTarget.Delete(); + } + } + + [Fact] + public void FocusContext_ClearsOnRealCreatureDeathAndDeletion() + { + var previousExpansion = Core.Expansion; + var attacker = CreateCreature(new Point3D(6200, 500, 0)); + var target = CreateCreature(new Point3D(6201, 500, 0)); + var deletedTarget = CreateCreature(new Point3D(6202, 500, 0)); + var replacementTarget = CreateCreature(new Point3D(6203, 500, 0)); + var weapon = EquipFocusWeapon(attacker); + + try + { + Core.Expansion = Expansion.HS; + weapon.OnHit(attacker, target); + Assert.Equal(1, FocusContext.ActiveContextCount); + + target.Kill(); + Assert.Equal(0, FocusContext.ActiveContextCount); + + weapon.OnHit(attacker, deletedTarget); + Assert.Equal(1, FocusContext.ActiveContextCount); + + deletedTarget.Delete(); + Assert.Equal(0, FocusContext.ActiveContextCount); + + weapon.OnHit(attacker, replacementTarget); + Assert.Equal(1, FocusContext.ActiveContextCount); + + attacker.Kill(); + Assert.Equal(0, FocusContext.ActiveContextCount); + } + finally + { + Core.Expansion = previousExpansion; + FocusContext.ClearAll(); + weapon.Delete(); + attacker.Delete(); + target.Delete(); + deletedTarget.Delete(); + replacementTarget.Delete(); + } + } + + [Fact] + public void RunicAttributeGeneration_DoesNotRollFocus() + { + var previousExpansion = Core.Expansion; + var weapon = new TestKatana(); + + try + { + Core.Expansion = Expansion.HS; + BaseRunicTool.ApplyAttributesTo(weapon, false, 0, 25, 100, 100); + + Assert.Equal(0, weapon.ExtendedWeaponAttributes.Focus); + } + finally + { + Core.Expansion = previousExpansion; + weapon.Delete(); + } + } + + private static TestKatana EquipFocusWeapon(Mobile wielder) + { + var weapon = new TestKatana + { + Layer = Layer.OneHanded + }; + + weapon.ExtendedWeaponAttributes.Focus = 1; + wielder.AddItem(weapon); + Assert.Same(weapon, wielder.Weapon); + return weapon; + } + + private static Mobile CreateMobile( + bool player = false, + Point3D location = default, + int hitsMax = 500, + int hits = 500 + ) + { + var mobile = new Mobile(World.NewMobile) + { + Player = player + }; + + mobile.DefaultMobileInit(); + InitializeHits(mobile, hitsMax, hits); + mobile.MoveToWorld(location == default ? TestLocation : location, Map.Felucca); + return mobile; + } + + private static PlayerMobile CreatePlayerMobile(Point3D location = default) + { + var mobile = new PlayerMobile(World.NewMobile); + + mobile.DefaultMobileInit(); + mobile.RawStr = 200; + mobile.Player = true; + mobile.Hits = mobile.HitsMax; + mobile.MoveToWorld(location == default ? TestLocation : location, Map.Felucca); + return mobile; + } + + private static TestCreature CreateCreature(Point3D location) + { + var creature = new TestCreature(); + creature.RawStr = 200; + creature.Hits = creature.HitsMax; + creature.MoveToWorld(location, Map.Felucca); + return creature; + } + + private static void InitializeHits(Mobile mobile, int hitsMax, int hits) + { + mobile.RawStr = Math.Max(1, (hitsMax - 50) * 2); + Assert.Equal(hitsMax, mobile.HitsMax); + mobile.Hits = hits; + } + + private sealed class TestKatana : Katana + { + public bool AbsorbAllDamage { get; set; } + + 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 TestWeaponAbility : WeaponAbility + { + } + + private sealed class TestCreature : BaseCreature + { + public TestCreature() : base(AIType.AI_Melee, FightMode.Closest, 10, 1) + { + Body = 0xC8; + } + + 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 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) => _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/Items/Weapons/BaseWeapon.cs b/Projects/UOContent/Items/Weapons/BaseWeapon.cs index fa8475ffa..453ce5dc3 100644 --- a/Projects/UOContent/Items/Weapons/BaseWeapon.cs +++ b/Projects/UOContent/Items/Weapons/BaseWeapon.cs @@ -1188,6 +1188,7 @@ public abstract partial class BaseWeapon public override void OnRemoved(IEntity parent) { ClearLastParryChance(); + FocusContext.Clear(this); EnchantSpell.StopEffect(this); if (parent is not Mobile m) @@ -1238,6 +1239,7 @@ public abstract partial class BaseWeapon public override void OnAfterDelete() { + FocusContext.Clear(this); EnchantSpell.StopEffect(this); base.OnAfterDelete(); } @@ -1247,6 +1249,11 @@ public abstract partial class BaseWeapon base.OnMapChange(); EnchantSpell.StopEffect(this); + if (Map == null || Map == Map.Internal) + { + FocusContext.Clear(this); + } + if ((Map == null || Map == Map.Internal) && Parent is Mobile m && ExtendedWeaponAttributes.BattleLust != 0) { BattleLust.Clear(m); @@ -1984,6 +1991,8 @@ public abstract partial class BaseWeapon percentageBonus += talisman.Killer.DamageBonus(defender); } + // 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 = Math.Min(percentageBonus, 300); @@ -2143,6 +2152,9 @@ public abstract partial class BaseWeapon if (damageGiven > 0) { + // A Focus cycle advances only after positive damage is applied; misses, parries, and + // zero-damage post-absorption hits leave the current modifier unchanged. + FocusContext.OnSuccessfulHit(this, attacker, defender); var propertyBonus = move?.GetPropertyBonus(attacker) ?? 1.0; var splintering = SplinteringWeapon.TryProcOnEligibleHit( attacker, @@ -2586,6 +2598,7 @@ public abstract partial class BaseWeapon public virtual void OnMiss(Mobile attacker, Mobile defender) { + FocusContext.OnMiss(this, attacker, defender); PlaySwingAnimation(attacker); attacker.PlaySound(GetMissAttackSound(attacker, defender)); defender.PlaySound(GetMissDefendSound(attacker, defender)); diff --git a/Projects/UOContent/Items/Weapons/Focus.cs b/Projects/UOContent/Items/Weapons/Focus.cs new file mode 100644 index 000000000..49af84836 --- /dev/null +++ b/Projects/UOContent/Items/Weapons/Focus.cs @@ -0,0 +1,229 @@ +using System; +using System.Collections.Generic; +using ModernUO.CodeGeneratedEvents; +using Server.Collections; +using Server.Mobiles; + +namespace Server.Items; + +public static class FocusContext +{ + // UO.com defines the -50% starting point and +20% cap but not the intermediate table. + // A deterministic 10-point step preserves those endpoints without copying ServUO's older sequence. + internal const int InitialDamageModifier = -50; + internal const int MaximumDamageModifier = 20; + internal const int DamageModifierStep = 10; + + private static readonly Dictionary _contexts = []; + private static readonly Dictionary> _attackerIndex = []; + private static readonly Dictionary> _targetIndex = []; + private static bool _configured; + + static FocusContext() => Configure(); + + public static void Configure() + { + if (_configured) + { + return; + } + + _configured = true; + EventSink.Logout += Clear; + } + + internal static int ActiveContextCount => _contexts.Count; + + internal static int GetCurrentModifierForTests(BaseWeapon weapon) => + weapon != null && _contexts.TryGetValue(weapon, out var context) ? context.DamageModifier : 0; + + internal static int GetDamageBonus(BaseWeapon weapon, Mobile attacker, Mobile defender) + { + if (!CanUse(weapon, attacker, defender)) + { + Clear(weapon); + return 0; + } + + if (!_contexts.TryGetValue(weapon, out var context) || context.Attacker != attacker) + { + Clear(weapon); + context = new Context(attacker, defender); + _contexts[weapon] = context; + AddToIndex(_attackerIndex, attacker, weapon); + AddToIndex(_targetIndex, defender, weapon); + } + else if (context.Target != defender) + { + SetTarget(weapon, context, defender); + context.DamageModifier = InitialDamageModifier; + } + + return context.DamageModifier; + } + + internal static void OnSuccessfulHit(BaseWeapon weapon, Mobile attacker, Mobile defender) + { + if (!CanUse(weapon, attacker, defender)) + { + Clear(weapon); + return; + } + + if (!_contexts.TryGetValue(weapon, out var context) || context.Attacker != attacker || + context.Target != defender) + { + return; + } + + context.DamageModifier = Math.Min( + MaximumDamageModifier, + context.DamageModifier + DamageModifierStep + ); + } + + internal static void OnMiss(BaseWeapon weapon, Mobile attacker, Mobile defender) + { + if (!CanUse(weapon, attacker, defender)) + { + Clear(weapon); + return; + } + + if (_contexts.TryGetValue(weapon, out var context) && context.Attacker == attacker && + context.Target != defender) + { + SetTarget(weapon, context, defender); + context.DamageModifier = InitialDamageModifier; + } + } + + internal static void Clear(BaseWeapon weapon) + { + if (weapon == null || !_contexts.Remove(weapon, out var context)) + { + return; + } + + RemoveFromIndex(_attackerIndex, context.Attacker, weapon); + RemoveFromIndex(_targetIndex, context.Target, weapon); + } + + [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 weapons = PooledRefList.Create(); + + if (_attackerIndex.TryGetValue(mobile, out var attackers)) + { + foreach (var weapon in attackers) + { + weapons.Add(weapon); + } + } + + if (_targetIndex.TryGetValue(mobile, out var targets)) + { + foreach (var weapon in targets) + { + weapons.Add(weapon); + } + } + + for (var i = 0; i < weapons.Count; i++) + { + Clear(weapons[i]); + } + } + + internal static void ClearAll() + { + using var weapons = PooledRefList.Create(); + + foreach (var weapon in _contexts.Keys) + { + weapons.Add(weapon); + } + + for (var i = 0; i < weapons.Count; i++) + { + Clear(weapons[i]); + } + } + + private static bool CanUse(BaseWeapon weapon, Mobile attacker, Mobile defender) => + Core.HS && + weapon?.Deleted == false && + weapon.ExtendedWeaponAttributes.Focus != 0 && + attacker?.Deleted == false && + attacker.Alive && + attacker.Map != null && + attacker.Map != Map.Internal && + attacker.Weapon == weapon && + weapon.Parent == attacker && + defender?.Deleted == false && + defender.Alive && + defender.Map != null && + defender.Map != Map.Internal; + + private static void SetTarget(BaseWeapon weapon, Context context, Mobile target) + { + RemoveFromIndex(_targetIndex, context.Target, weapon); + context.Target = target; + AddToIndex(_targetIndex, target, weapon); + } + + private static void AddToIndex( + Dictionary> index, + Mobile mobile, + BaseWeapon weapon + ) + { + if (!index.TryGetValue(mobile, out var weapons)) + { + weapons = []; + index[mobile] = weapons; + } + + weapons.Add(weapon); + } + + private static void RemoveFromIndex( + Dictionary> index, + Mobile mobile, + BaseWeapon weapon + ) + { + if (mobile == null || !index.TryGetValue(mobile, out var weapons) || !weapons.Remove(weapon)) + { + return; + } + + if (weapons.Count == 0) + { + index.Remove(mobile); + } + } + + private sealed class Context + { + public Context(Mobile attacker, Mobile target) + { + Attacker = attacker; + Target = target; + DamageModifier = InitialDamageModifier; + } + + public Mobile Attacker { get; } + public Mobile Target { get; set; } + public int DamageModifier { get; set; } + } +} diff --git a/Projects/UOContent/Misc/AOS.cs b/Projects/UOContent/Misc/AOS.cs index 0b70885bc..165106294 100644 --- a/Projects/UOContent/Misc/AOS.cs +++ b/Projects/UOContent/Misc/AOS.cs @@ -1234,7 +1234,8 @@ namespace Server HitSparks = 0x00000004, BloodDrinker = 0x00000008, HitSwarm = 0x00000010, - SplinteringWeapon = 0x00000020 + SplinteringWeapon = 0x00000020, + Focus = 0x00000040 } public sealed class ExtendedWeaponAttributes : BaseAttributes @@ -1305,6 +1306,23 @@ namespace Server set => this[ExtendedWeaponAttribute.SplinteringWeapon] = value; } + [CommandProperty(AccessLevel.GameMaster)] + public int Focus + { + get => this[ExtendedWeaponAttribute.Focus]; + set + { + var hadFocus = Focus != 0; + + this[ExtendedWeaponAttribute.Focus] = value; + + if (hadFocus && value == 0 && Owner is BaseWeapon weapon) + { + FocusContext.Clear(weapon); + } + } + } + public void GetProperties(IPropertyList list) { if (Core.HS && Bane != 0) @@ -1312,6 +1330,11 @@ namespace Server list.Add(1154671); // Bane } + if (Core.HS && Focus != 0) + { + list.Add(1150018); // Focus + } + if (Core.SA && BattleLust != 0) { list.Add(1113710); // Battle Lust