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; + } + } +}