diff --git a/src/GameLogic/AttackableExtensions.cs b/src/GameLogic/AttackableExtensions.cs index 63684c730..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,6 +571,12 @@ public static int GetRequiredValue(this IAttacker attacker, AttributeRequirement /// The calculated base experience. public static double CalculateBaseExperience(this IAttackable killedObject, float killerLevel) { + if (killedObject.IsSummonedMonster) + { + // 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..41b576586 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.IsSummonedMonster && 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/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 4456897a3..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; } /// @@ -27,6 +30,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)) { diff --git a/src/GameLogic/Party.cs b/src/GameLogic/Party.cs index 55ed373d7..45bd8fb0a 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.IsSummonedMonster) + { + // 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. 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