From 89531598a27e503d7163a96188fc7d535ed0e8df Mon Sep 17 00:00:00 2001 From: sven-n Date: Tue, 6 Jan 2026 21:43:08 +0100 Subject: [PATCH 1/6] Don't cause self-defense by own summon attack --- src/GameLogic/PlugIns/SelfDefensePlugIn.cs | 4 +- .../SelfDefensePlugInTest.cs | 37 +++++++++++++++++++ 2 files changed, 39 insertions(+), 2 deletions(-) create mode 100644 tests/MUnique.OpenMU.Tests/SelfDefensePlugInTest.cs diff --git a/src/GameLogic/PlugIns/SelfDefensePlugIn.cs b/src/GameLogic/PlugIns/SelfDefensePlugIn.cs index 3a7c7da51..abd0fb943 100644 --- a/src/GameLogic/PlugIns/SelfDefensePlugIn.cs +++ b/src/GameLogic/PlugIns/SelfDefensePlugIn.cs @@ -44,8 +44,8 @@ public void ForceStart() public void AttackableGotHit(IAttackable attackable, IAttacker attacker, HitInfo hitInfo) { var defender = attackable as Player ?? (attackable as Monster)?.SummonedBy; - var attackerPlayer = attacker as Player ?? (attackable as Monster)?.SummonedBy; - if (defender is null || attackerPlayer is null) + var attackerPlayer = attacker as Player ?? (attacker as Monster)?.SummonedBy; + if (defender is null || attackerPlayer is null || defender == attackerPlayer) { return; } diff --git a/tests/MUnique.OpenMU.Tests/SelfDefensePlugInTest.cs b/tests/MUnique.OpenMU.Tests/SelfDefensePlugInTest.cs new file mode 100644 index 000000000..0f4901a51 --- /dev/null +++ b/tests/MUnique.OpenMU.Tests/SelfDefensePlugInTest.cs @@ -0,0 +1,37 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Tests; + +using Moq; +using MUnique.OpenMU.GameLogic; +using MUnique.OpenMU.GameLogic.NPC; +using MUnique.OpenMU.GameLogic.PlugIns; +using MUnique.OpenMU.DataModel.Configuration; + +/// +/// Tests for . +/// +[TestFixture] +public class SelfDefensePlugInTest +{ + /// + /// Ensures that hitting an own summon doesn't start self-defense. + /// + [Test] + public async ValueTask OwnSummonHitDoesNotStartSelfDefenseAsync() + { + var player = await TestHelper.CreatePlayerAsync().ConfigureAwait(false); + + var summonMock = new Mock(); + var summonable = summonMock.As(); + summonable.Setup(s => s.SummonedBy).Returns(player); + summonable.Setup(s => s.Definition).Returns(new MonsterDefinition()); + + var plugIn = new SelfDefensePlugIn(); + plugIn.AttackableGotHit(summonMock.Object, player, new HitInfo(1, 0, DamageAttributes.Undefined)); + + Assert.That(player.GameContext.SelfDefenseState, Is.Empty); + } +} \ No newline at end of file From 30bc85cc4752f56a89fcf8f5559c0e9239c9daea Mon Sep 17 00:00:00 2001 From: sven-n Date: Tue, 6 Jan 2026 21:43:53 +0100 Subject: [PATCH 2/6] No exp gains or item drops for killing summoned monsters --- src/GameLogic/AttackableExtensions.cs | 6 ++++++ src/GameLogic/NPC/AttackableNpcBase.cs | 11 +++++++---- src/GameLogic/Party.cs | 7 +++++++ 3 files changed, 20 insertions(+), 4 deletions(-) diff --git a/src/GameLogic/AttackableExtensions.cs b/src/GameLogic/AttackableExtensions.cs index 63684c730..ae14ec1df 100644 --- a/src/GameLogic/AttackableExtensions.cs +++ b/src/GameLogic/AttackableExtensions.cs @@ -563,6 +563,12 @@ public static int GetRequiredValue(this IAttacker attacker, AttributeRequirement /// The calculated base experience. public static double CalculateBaseExperience(this IAttackable killedObject, float killerLevel) { + if (killedObject is Monster { SummonedBy: not null }) + { + // Summoned monsters should not yield experience. + return 0; + } + var targetLevel = killedObject.Attributes[Stats.Level]; var tempExperience = (targetLevel + 25) * targetLevel / 3.0; diff --git a/src/GameLogic/NPC/AttackableNpcBase.cs b/src/GameLogic/NPC/AttackableNpcBase.cs index 722d7a372..eedf4281c 100644 --- a/src/GameLogic/NPC/AttackableNpcBase.cs +++ b/src/GameLogic/NPC/AttackableNpcBase.cs @@ -266,12 +266,15 @@ protected virtual async ValueTask OnDeathAsync(IAttacker attacker) await plugInPoint.AttackableGotKilledAsync(this, attacker).ConfigureAwait(false); } - if (player.SelectedCharacter!.State > HeroState.Normal) + if (this is not Monster { SummonedBy: not null } && player.SelectedCharacter is { } selectedCharacter) { - player.SelectedCharacter.StateRemainingSeconds -= (int)this.Attributes[Stats.Level]; - } + if (selectedCharacter.State > HeroState.Normal) + { + selectedCharacter.StateRemainingSeconds -= (int)this.Attributes[Stats.Level]; + } - _ = this.DropItemDelayedAsync(player, exp); // don't wait for completion. + _ = this.DropItemDelayedAsync(player, exp); // don't wait for completion. + } } } diff --git a/src/GameLogic/Party.cs b/src/GameLogic/Party.cs index 55ed373d7..6548d8a5b 100644 --- a/src/GameLogic/Party.cs +++ b/src/GameLogic/Party.cs @@ -7,6 +7,7 @@ namespace MUnique.OpenMU.GameLogic; using System.Diagnostics.Metrics; using System.Threading; using MUnique.OpenMU.GameLogic.Attributes; +using MUnique.OpenMU.GameLogic.NPC; using MUnique.OpenMU.GameLogic.Views; using MUnique.OpenMU.GameLogic.Views.Party; using Nito.AsyncEx; @@ -250,6 +251,12 @@ protected override void Dispose(bool disposing) private async ValueTask InternalDistributeExperienceAfterKillAsync(IAttackable killedObject, IObservable killer) { + if (killedObject is Monster { SummonedBy: not null }) + { + // Do not award experience or drop items for summoned monsters. + return 0; + } + using (await killer.ObserverLock.ReaderLockAsync()) { // All players in the range of the player are getting experience. From 9f7e8a128213a5cdf984fba3b58d7143c54cd3c8 Mon Sep 17 00:00:00 2001 From: sven-n Date: Tue, 6 Jan 2026 21:44:18 +0100 Subject: [PATCH 3/6] Summon should not attack owner --- src/GameLogic/NPC/SummonedMonsterIntelligence.cs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/GameLogic/NPC/SummonedMonsterIntelligence.cs b/src/GameLogic/NPC/SummonedMonsterIntelligence.cs index 4456897a3..444aa854d 100644 --- a/src/GameLogic/NPC/SummonedMonsterIntelligence.cs +++ b/src/GameLogic/NPC/SummonedMonsterIntelligence.cs @@ -27,6 +27,12 @@ public SummonedMonsterIntelligence(Player owner) /// public override void RegisterHit(IAttacker attacker) { + if (attacker == this.Owner) + { + // Never attack the owner. + return; + } + if (this.CurrentTarget is null || attacker.IsInRange(this.Npc.Position, this.Npc.Definition.AttackRange)) { From 686baf22dfe03a710667e49177bbc7e279a7e895 Mon Sep 17 00:00:00 2001 From: sven-n Date: Tue, 6 Jan 2026 21:44:54 +0100 Subject: [PATCH 4/6] Summoned monsters are allowed to spawn in safezone --- src/GameLogic/NPC/Monster.cs | 3 +++ src/GameLogic/NPC/NonPlayerCharacter.cs | 7 ++++++- src/GameLogic/NPC/SummonedMonsterIntelligence.cs | 3 +++ 3 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/GameLogic/NPC/Monster.cs b/src/GameLogic/NPC/Monster.cs index c6d86d85e..29f33c9d2 100644 --- a/src/GameLogic/NPC/Monster.cs +++ b/src/GameLogic/NPC/Monster.cs @@ -93,6 +93,9 @@ public Monster(MonsterSpawnArea spawnInfo, MonsterDefinition stats, GameMap map, /// Monsters don't do combos. public ComboStateMachine? ComboState => null; + /// + protected override bool CanSpawnInSafezone => base.CanSpawnInSafezone || this.SummonedBy is not null; + /// /// Attacks the specified target. /// diff --git a/src/GameLogic/NPC/NonPlayerCharacter.cs b/src/GameLogic/NPC/NonPlayerCharacter.cs index aca20b31c..e29e786e7 100644 --- a/src/GameLogic/NPC/NonPlayerCharacter.cs +++ b/src/GameLogic/NPC/NonPlayerCharacter.cs @@ -184,6 +184,11 @@ protected virtual ValueTask MoveAsync(Point target, MoveType type) throw new NotSupportedException("NPCs can't be moved"); } + /// + /// Gets a value indicating whether this instance can spawn in a safe zone. + /// + protected virtual bool CanSpawnInSafezone => this.Definition.ObjectKind != NpcObjectKind.Monster && this.Definition.ObjectKind != NpcObjectKind.Trap; + /// /// Gets the spawn direction. /// @@ -214,7 +219,7 @@ private static Direction GetSpawnDirection(Direction configuredDirection) private bool IsValidSpawnPoint(Point spawnPoint) { - var isSafezoneAllowed = this.Definition.ObjectKind != NpcObjectKind.Monster && this.Definition.ObjectKind != NpcObjectKind.Trap; + var isSafezoneAllowed = this.CanSpawnInSafezone; var isInSafezone = this.CurrentMap.Terrain.SafezoneMap[spawnPoint.X, spawnPoint.Y]; var npcCanWalk = this.Definition.ObjectKind == NpcObjectKind.Monster || this.Definition.ObjectKind == NpcObjectKind.Guard; var isWalkable = this.CurrentMap.Terrain.WalkMap[spawnPoint.X, spawnPoint.Y]; diff --git a/src/GameLogic/NPC/SummonedMonsterIntelligence.cs b/src/GameLogic/NPC/SummonedMonsterIntelligence.cs index 444aa854d..83028db74 100644 --- a/src/GameLogic/NPC/SummonedMonsterIntelligence.cs +++ b/src/GameLogic/NPC/SummonedMonsterIntelligence.cs @@ -17,6 +17,9 @@ public sealed class SummonedMonsterIntelligence : BasicMonsterIntelligence public SummonedMonsterIntelligence(Player owner) { this.Owner = owner; + + // Summons should be allowed to walk within safezones to follow their owner. + this.CanWalkOnSafezone = true; } /// From 52fec710900005b86b0a9eae271612a676df4166 Mon Sep 17 00:00:00 2001 From: sven-n Date: Tue, 6 Jan 2026 22:35:29 +0100 Subject: [PATCH 5/6] Extension property for IsSummonedMonster --- src/GameLogic/AttackableExtensions.cs | 10 +++++++++- src/GameLogic/Party.cs | 2 +- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/GameLogic/AttackableExtensions.cs b/src/GameLogic/AttackableExtensions.cs index ae14ec1df..610ea54c2 100644 --- a/src/GameLogic/AttackableExtensions.cs +++ b/src/GameLogic/AttackableExtensions.cs @@ -26,6 +26,14 @@ public static class AttackableExtensions { Stats.CurrentAbility, Stats.AbilityUsageReduction }, }; + extension(IAttackable attackable) + { + /// + /// Gets a value indicating whether this instance is a summoned monster. + /// + public bool IsSummonedMonster => attackable is Monster { SummonedBy: not null }; + } + /// /// Calculates the damage, using a skill. /// @@ -563,7 +571,7 @@ public static int GetRequiredValue(this IAttacker attacker, AttributeRequirement /// The calculated base experience. public static double CalculateBaseExperience(this IAttackable killedObject, float killerLevel) { - if (killedObject is Monster { SummonedBy: not null }) + if (killedObject.IsSummonedMonster) { // Summoned monsters should not yield experience. return 0; diff --git a/src/GameLogic/Party.cs b/src/GameLogic/Party.cs index 6548d8a5b..45bd8fb0a 100644 --- a/src/GameLogic/Party.cs +++ b/src/GameLogic/Party.cs @@ -251,7 +251,7 @@ protected override void Dispose(bool disposing) private async ValueTask InternalDistributeExperienceAfterKillAsync(IAttackable killedObject, IObservable killer) { - if (killedObject is Monster { SummonedBy: not null }) + if (killedObject.IsSummonedMonster) { // Do not award experience or drop items for summoned monsters. return 0; From 931a62eb49133321001bfe6fbb31cf8c15e198dc Mon Sep 17 00:00:00 2001 From: sven-n Date: Tue, 6 Jan 2026 22:37:02 +0100 Subject: [PATCH 6/6] Use new extension property --- src/GameLogic/NPC/AttackableNpcBase.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/GameLogic/NPC/AttackableNpcBase.cs b/src/GameLogic/NPC/AttackableNpcBase.cs index eedf4281c..41b576586 100644 --- a/src/GameLogic/NPC/AttackableNpcBase.cs +++ b/src/GameLogic/NPC/AttackableNpcBase.cs @@ -266,7 +266,7 @@ protected virtual async ValueTask OnDeathAsync(IAttacker attacker) await plugInPoint.AttackableGotKilledAsync(this, attacker).ConfigureAwait(false); } - if (this is not Monster { SummonedBy: not null } && player.SelectedCharacter is { } selectedCharacter) + if (!this.IsSummonedMonster && player.SelectedCharacter is { } selectedCharacter) { if (selectedCharacter.State > HeroState.Normal) {