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