diff --git a/docs-website/docs/server-features/castle-siege.md b/docs-website/docs/server-features/castle-siege.md
index e75787bf5..427bdb371 100644
--- a/docs-website/docs/server-features/castle-siege.md
+++ b/docs-website/docs/server-features/castle-siege.md
@@ -36,3 +36,9 @@ The attacking sides are enemies of each other as well as of the defenders.
This applies only on the Castle Siege map and only to players with a siege
side. Killing a player of the own side, or any player outside the battle, is
still a normal player kill.
+
+## Friendly fire during the battle
+
+During the battle, players of the same siege side can't damage each other,
+including with area skills and pets. This applies only on the Castle Siege map
+and only to players with a siege side.
diff --git a/src/GameLogic/CastleSiege/CastleSiegeContext.cs b/src/GameLogic/CastleSiege/CastleSiegeContext.cs
index 36947be0b..28ad8d89f 100644
--- a/src/GameLogic/CastleSiege/CastleSiegeContext.cs
+++ b/src/GameLogic/CastleSiege/CastleSiegeContext.cs
@@ -263,6 +263,24 @@ public bool AreOnOpposingSides(Player player, Player otherPlayer)
&& side != otherSide;
}
+ ///
+ /// Determines whether two players fight on the same side of the running battle.
+ ///
+ /// The one player.
+ /// The other player.
+ /// if both are on the Castle Siege map during the battle, and on the same side.
+ public bool AreOnSameSide(Player player, Player otherPlayer)
+ {
+ if (this.CurrentState != CastleSiegeState.Start)
+ {
+ return false;
+ }
+
+ var side = this.GetPlayerJoinSide(player);
+ return side != CastleSiegeJoinSide.None
+ && side == this.GetPlayerJoinSide(otherPlayer);
+ }
+
///
/// Assigns the current Castle Siege side to all online players on the Castle Siege map.
///
diff --git a/src/GameLogic/CastleSiege/CastleSiegePlugIn.cs b/src/GameLogic/CastleSiege/CastleSiegePlugIn.cs
index 204b950d4..5bf70dcf3 100644
--- a/src/GameLogic/CastleSiege/CastleSiegePlugIn.cs
+++ b/src/GameLogic/CastleSiege/CastleSiegePlugIn.cs
@@ -4,6 +4,7 @@
namespace MUnique.OpenMU.GameLogic.CastleSiege;
+using System.ComponentModel;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using System.Threading;
@@ -17,11 +18,12 @@ namespace MUnique.OpenMU.GameLogic.CastleSiege;
///
///
/// During the battle, the attacks and kills between players of opposing sides are exempted from the player killer penalty.
+/// During the battle, players of the same side can't damage each other.
///
[PlugIn]
[Display(Name = nameof(PlugInResources.CastleSiegePlugIn_Name), Description = nameof(PlugInResources.CastleSiegePlugIn_Description), ResourceType = typeof(PlugInResources))]
[Guid("B7F62FA9-59E6-49E9-B499-0358A14957CF")]
-public class CastleSiegePlugIn : IPeriodicTaskPlugIn, IObjectAddedToMapPlugIn, IObjectRemovedFromMapPlugIn, IPlayerStateChangedPlugIn, IPlayerSpawnGateSelectionPlugIn, IPlayerKillPenaltyExemptionPlugIn
+public class CastleSiegePlugIn : IPeriodicTaskPlugIn, IObjectAddedToMapPlugIn, IObjectRemovedFromMapPlugIn, IPlayerStateChangedPlugIn, IPlayerSpawnGateSelectionPlugIn, IPlayerKillPenaltyExemptionPlugIn, IPlayerAttackRestrictionPlugIn
{
private static readonly TimeSpan EconomySaveInterval = TimeSpan.FromSeconds(30);
private static readonly TimeSpan NpcSaveInterval = TimeSpan.FromMinutes(2);
@@ -138,6 +140,16 @@ public ValueTask SelectSpawnGateAsync(Player player, SpawnGateSelectionArgs args
return ValueTask.CompletedTask;
}
+ ///
+ public void CheckAttack(Player attacker, Player defender, CancelEventArgs eventArgs)
+ {
+ if (this.GetContext(attacker.GameContext) is { } context
+ && context.AreOnSameSide(attacker, defender))
+ {
+ eventArgs.Cancel = true;
+ }
+ }
+
///
public void CheckExemption(Player attacker, Player defender, PlayerKillPenaltyExemptionArgs eventArgs)
{
diff --git a/src/GameLogic/Player.cs b/src/GameLogic/Player.cs
index b620dde83..2dd61d592 100644
--- a/src/GameLogic/Player.cs
+++ b/src/GameLogic/Player.cs
@@ -5,6 +5,7 @@
namespace MUnique.OpenMU.GameLogic;
using System;
+using System.ComponentModel;
using System.Globalization;
using System.Threading;
using MUnique.OpenMU.AttributeSystem;
@@ -785,7 +786,8 @@ public async ValueTask KillInstantlyAsync()
throw new InvalidOperationException("AttributeSystem not set.");
}
- if (this.IsAttackBlockedBySafezone(attacker))
+ if (this.IsAttackBlockedBySafezone(attacker)
+ || this.IsAttackPreventedByPlugIns(attacker))
{
return null;
}
@@ -2147,6 +2149,25 @@ private async void OnTransformationSkinChanged(object? sender, EventArgs args)
}
}
+ ///
+ /// Determines whether a plugin prevents the attacking player, or the owner of the attacking pet, from damaging this player.
+ ///
+ /// The attacker.
+ /// true, if the attack is prevented; otherwise, false.
+ private bool IsAttackPreventedByPlugIns(IAttacker attacker)
+ {
+ if ((attacker as Player ?? (attacker as IPlayerSurrogate)?.Owner) is not { } attackerPlayer
+ || attackerPlayer == this
+ || this.GameContext.PlugInManager.GetPlugInPoint() is not { } plugInPoint)
+ {
+ return false;
+ }
+
+ var eventArgs = new CancelEventArgs();
+ plugInPoint.CheckAttack(attackerPlayer, this, eventArgs);
+ return eventArgs.Cancel;
+ }
+
///
/// Sets the reclaimable attributes before a character enters the game.
/// Current shield and mana are set to their maximum values.
diff --git a/src/GameLogic/PlugIns/IPlayerAttackRestrictionPlugIn.cs b/src/GameLogic/PlugIns/IPlayerAttackRestrictionPlugIn.cs
new file mode 100644
index 000000000..d1ae91092
--- /dev/null
+++ b/src/GameLogic/PlugIns/IPlayerAttackRestrictionPlugIn.cs
@@ -0,0 +1,26 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.GameLogic.PlugIns;
+
+using System.ComponentModel;
+using System.Runtime.InteropServices;
+using MUnique.OpenMU.PlugIns;
+
+///
+/// Plugins which can prevent a player from damaging another player,
+/// e.g. between the players of the same side of a Castle Siege battle.
+///
+[Guid("9130ECFA-9916-4074-92DC-5AEA17BC4F1A")]
+[PlugInPoint("Player attack restriction", "Plugins which can prevent a player from damaging another player.")]
+public interface IPlayerAttackRestrictionPlugIn
+{
+ ///
+ /// Checks if the attacker may damage the defender.
+ ///
+ /// The attacking player, or the owner of the attacking pet.
+ /// The attacked player.
+ /// The event args. A plugin sets to prevent the attack.
+ void CheckAttack(Player attacker, Player defender, CancelEventArgs eventArgs);
+}
diff --git a/tests/MUnique.OpenMU.Tests/CastleSiegeFriendlyFireTests.cs b/tests/MUnique.OpenMU.Tests/CastleSiegeFriendlyFireTests.cs
new file mode 100644
index 000000000..d1b412184
--- /dev/null
+++ b/tests/MUnique.OpenMU.Tests/CastleSiegeFriendlyFireTests.cs
@@ -0,0 +1,145 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.Tests;
+
+using MUnique.OpenMU.DataModel.Configuration;
+using MUnique.OpenMU.GameLogic;
+using MUnique.OpenMU.GameLogic.Attributes;
+using MUnique.OpenMU.GameLogic.CastleSiege;
+using MUnique.OpenMU.GameLogic.PlugIns;
+using MUnique.OpenMU.Interfaces;
+using MUnique.OpenMU.Pathfinding;
+using MUnique.OpenMU.PlugIns;
+using BasicModel = MUnique.OpenMU.Persistence.BasicModel;
+
+///
+/// Tests that players of the same side of a Castle Siege battle can't damage each other.
+///
+[TestFixture]
+public class CastleSiegeFriendlyFireTests
+{
+ private static readonly Guid CastleSiegePlugInId = new("B7F62FA9-59E6-49E9-B499-0358A14957CF");
+
+ ///
+ /// Verifies that only players of the same side on the siege map during the battle are on the same side.
+ ///
+ [Test]
+ public async ValueTask OnlyTheSameSideDuringTheBattleIsFriendlyAsync()
+ {
+ var fixture = await SiegeFixture.CreateAsync().ConfigureAwait(false);
+ var attacker = await fixture.CreatePlayerAsync(1, CastleSiegeJoinSide.Attack1).ConfigureAwait(false);
+ var ally = await fixture.CreatePlayerAsync(2, CastleSiegeJoinSide.Attack1).ConfigureAwait(false);
+ var otherAttacker = await fixture.CreatePlayerAsync(3, CastleSiegeJoinSide.Attack2).ConfigureAwait(false);
+ var spectator = await fixture.CreatePlayerAsync(4, CastleSiegeJoinSide.None).ConfigureAwait(false);
+ var otherSpectator = await fixture.CreatePlayerAsync(5, CastleSiegeJoinSide.None).ConfigureAwait(false);
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(fixture.Context.AreOnSameSide(attacker, ally), Is.True);
+ Assert.That(fixture.Context.AreOnSameSide(attacker, otherAttacker), Is.False, "Different attacking sides.");
+ Assert.That(fixture.Context.AreOnSameSide(attacker, spectator), Is.False, "The spectator has no side.");
+ Assert.That(fixture.Context.AreOnSameSide(spectator, otherSpectator), Is.False, "Players without side aren't a side.");
+ });
+
+ ally.SetCurrentMapSilently(fixture.OtherMap);
+ Assert.That(fixture.Context.AreOnSameSide(attacker, ally), Is.False, "The ally left the siege map.");
+
+ ally.SetCurrentMapSilently(fixture.Map);
+ fixture.Context.CurrentState = CastleSiegeState.End;
+ Assert.That(fixture.Context.AreOnSameSide(attacker, ally), Is.False, "Outside of the battle.");
+ }
+
+ ///
+ /// Verifies that an attack on a player of the same side doesn't hit.
+ ///
+ [Test]
+ public async ValueTask AttackOnTheSameSideIsPreventedAsync()
+ {
+ var fixture = await SiegeFixture.CreateAsync().ConfigureAwait(false);
+ var attacker = await fixture.CreatePlayerAsync(1, CastleSiegeJoinSide.Attack1).ConfigureAwait(false);
+ var ally = await fixture.CreatePlayerAsync(2, CastleSiegeJoinSide.Attack1).ConfigureAwait(false);
+ var healthBefore = ally.Attributes![Stats.CurrentHealth];
+
+ var hitInfo = await ally.AttackByAsync(attacker, null, false).ConfigureAwait(false);
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(hitInfo, Is.Null);
+ Assert.That(ally.Attributes[Stats.CurrentHealth], Is.EqualTo(healthBefore));
+ });
+ }
+
+ ///
+ /// Verifies that an attack on a player of an opposing side still hits.
+ ///
+ [Test]
+ public async ValueTask AttackOnAnOpposingSideIsNotPreventedAsync()
+ {
+ var fixture = await SiegeFixture.CreateAsync().ConfigureAwait(false);
+ var attacker = await fixture.CreatePlayerAsync(1, CastleSiegeJoinSide.Attack1).ConfigureAwait(false);
+ var defender = await fixture.CreatePlayerAsync(2, CastleSiegeJoinSide.Defense).ConfigureAwait(false);
+
+ var hitInfo = await defender.AttackByAsync(attacker, null, false).ConfigureAwait(false);
+
+ Assert.That(hitInfo, Is.Not.Null);
+ }
+
+ private sealed class SiegeFixture
+ {
+ public SiegeFixture(IGameContext gameContext, CastleSiegeContext context, GameMap map, GameMap otherMap)
+ {
+ this.GameContext = gameContext;
+ this.Context = context;
+ this.Map = map;
+ this.OtherMap = otherMap;
+ }
+
+ public IGameContext GameContext { get; }
+
+ public CastleSiegeContext Context { get; }
+
+ public GameMap Map { get; }
+
+ public GameMap OtherMap { get; }
+
+ public static async ValueTask CreateAsync()
+ {
+ var gameContext = GameContextTestHelper.CreateGameContext(
+ [
+ new PlugInConfiguration { TypeId = CastleSiegePlugInId, IsActive = true },
+ ]);
+ var siegeMap = new BasicModel.GameMapDefinition { Number = 30, TerrainData = new byte[ushort.MaxValue + 3] };
+ gameContext.Configuration.Maps.Add(siegeMap);
+ var configuration = new BasicModel.CastleSiegeConfiguration
+ {
+ Enabled = true,
+ CastleSiegeMapDefinition = siegeMap,
+ };
+ configuration.StateSchedule.Add(new BasicModel.CastleSiegeStateScheduleEntry { State = CastleSiegeState.Ready, DayOfWeek = DayOfWeek.Monday });
+ configuration.StateSchedule.Add(new BasicModel.CastleSiegeStateScheduleEntry { State = CastleSiegeState.Start, DayOfWeek = DayOfWeek.Tuesday });
+ gameContext.Configuration.CastleSiegeConfiguration = configuration;
+
+ var plugIn = gameContext.PlugInManager.GetActivePlugInsOf().OfType().Single();
+ await plugIn.ExecuteTaskAsync(gameContext).ConfigureAwait(false);
+ var context = plugIn.GetContext(gameContext) ?? throw new InvalidOperationException("The context wasn't created.");
+ context.CurrentState = CastleSiegeState.Start;
+
+ var map = new GameMap(siegeMap, TimeSpan.FromMinutes(1), 8);
+ var otherMap = new GameMap(gameContext.Configuration.Maps.First(), TimeSpan.FromMinutes(1), 8);
+ return new SiegeFixture(gameContext, context, map, otherMap);
+ }
+
+ public async ValueTask CreatePlayerAsync(uint guildId, CastleSiegeJoinSide side)
+ {
+ var player = await PlayerTestHelper.CreatePlayerAsync(this.GameContext).ConfigureAwait(false);
+ player.SelectedCharacter!.Id = Guid.NewGuid();
+ player.GuildStatus = new GuildMemberStatus(guildId, GuildPosition.NormalMember);
+ player.Position = new Point((byte)guildId, 20);
+ player.SetCurrentMapSilently(this.Map);
+ this.Context.PlayerJoinSides[player.SelectedCharacter.Id] = side;
+ return player;
+ }
+ }
+}