From 0fbdd1a649f553121f97c568d86fa677494a0a2b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 18:16:21 +0000 Subject: [PATCH 1/2] fix(castle-siege): prevent damage between players of the same siege side Adds the IPlayerAttackRestrictionPlugIn point, which can prevent a player (or the owner of an attacking pet) from damaging another player. The Castle Siege plugin uses it to stop friendly fire between players of the same side during the battle. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011xNc764bgrPZRQYCRzPj1x --- .../docs/server-features/castle-siege.md | 6 + .../CastleSiege/CastleSiegeContext.cs | 18 +++ .../CastleSiege/CastleSiegePlugIn.cs | 16 +- src/GameLogic/Player.cs | 23 ++- .../PlugIns/IPlayerAttackRestrictionPlugIn.cs | 26 ++++ .../CastleSiegeFriendlyFireTests.cs | 145 ++++++++++++++++++ 6 files changed, 232 insertions(+), 2 deletions(-) create mode 100644 src/GameLogic/PlugIns/IPlayerAttackRestrictionPlugIn.cs create mode 100644 tests/MUnique.OpenMU.Tests/CastleSiegeFriendlyFireTests.cs diff --git a/docs-website/docs/server-features/castle-siege.md b/docs-website/docs/server-features/castle-siege.md index 3fc5f86548..267e79d9b7 100644 --- a/docs-website/docs/server-features/castle-siege.md +++ b/docs-website/docs/server-features/castle-siege.md @@ -27,3 +27,9 @@ position (`B6`), NPC position (`BB`), and guild command (`B2:1D`) packets. Withi each update, the player packet replaces the position layer and the NPC packet adds structures afterward. The commander position is already known locally; the existing position packet has no separate commander-identity field. + +## 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 09e35b4a6a..21b45cc39b 100644 --- a/src/GameLogic/CastleSiege/CastleSiegeContext.cs +++ b/src/GameLogic/CastleSiege/CastleSiegeContext.cs @@ -240,6 +240,24 @@ public CastleSiegeJoinSide GetPlayerJoinSide(Player player) return this.GetTrackedPlayerJoinSide(player); } + /// + /// 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 b4dbfe9150..59246688eb 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; @@ -15,10 +16,13 @@ namespace MUnique.OpenMU.GameLogic.CastleSiege; /// /// Drives the weekly Castle Siege state cycle. /// +/// +/// 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 +public class CastleSiegePlugIn : IPeriodicTaskPlugIn, IObjectAddedToMapPlugIn, IObjectRemovedFromMapPlugIn, IPlayerStateChangedPlugIn, IPlayerSpawnGateSelectionPlugIn, IPlayerAttackRestrictionPlugIn { private static readonly TimeSpan EconomySaveInterval = TimeSpan.FromSeconds(30); private static readonly TimeSpan NpcSaveInterval = TimeSpan.FromMinutes(2); @@ -135,6 +139,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; + } + } + /// /// Forces all Castle Siege contexts to enter a state on their next timer tick. /// diff --git a/src/GameLogic/Player.cs b/src/GameLogic/Player.cs index 53373357f8..01ae0387aa 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; @@ -780,7 +781,8 @@ public async ValueTask KillInstantlyAsync() throw new InvalidOperationException("AttributeSystem not set."); } - if (this.IsAttackBlockedBySafezone(attacker)) + if (this.IsAttackBlockedBySafezone(attacker) + || this.IsAttackPreventedByPlugIns(attacker)) { return null; } @@ -2136,6 +2138,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 0000000000..d1ae91092c --- /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 0000000000..3c92b44b07 --- /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 + { + private 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; + } + } +} From dcd057a3f85fb5bcd6e28f3af17eb66696a9a5d9 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 18:42:53 +0000 Subject: [PATCH 2/2] test(castle-siege): make the test fixture constructor public Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011xNc764bgrPZRQYCRzPj1x --- tests/MUnique.OpenMU.Tests/CastleSiegeFriendlyFireTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/MUnique.OpenMU.Tests/CastleSiegeFriendlyFireTests.cs b/tests/MUnique.OpenMU.Tests/CastleSiegeFriendlyFireTests.cs index 3c92b44b07..d1b4121845 100644 --- a/tests/MUnique.OpenMU.Tests/CastleSiegeFriendlyFireTests.cs +++ b/tests/MUnique.OpenMU.Tests/CastleSiegeFriendlyFireTests.cs @@ -88,7 +88,7 @@ public async ValueTask AttackOnAnOpposingSideIsNotPreventedAsync() private sealed class SiegeFixture { - private SiegeFixture(IGameContext gameContext, CastleSiegeContext context, GameMap map, GameMap otherMap) + public SiegeFixture(IGameContext gameContext, CastleSiegeContext context, GameMap map, GameMap otherMap) { this.GameContext = gameContext; this.Context = context;