Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs-website/docs/server-features/castle-siege.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
18 changes: 18 additions & 0 deletions src/GameLogic/CastleSiege/CastleSiegeContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,24 @@ public bool AreOnOpposingSides(Player player, Player otherPlayer)
&& side != otherSide;
}

/// <summary>
/// Determines whether two players fight on the same side of the running battle.
/// </summary>
/// <param name="player">The one player.</param>
/// <param name="otherPlayer">The other player.</param>
/// <returns><see langword="true"/> if both are on the Castle Siege map during the battle, and on the same side.</returns>
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);
}

/// <summary>
/// Assigns the current Castle Siege side to all online players on the Castle Siege map.
/// </summary>
Expand Down
14 changes: 13 additions & 1 deletion src/GameLogic/CastleSiege/CastleSiegePlugIn.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

namespace MUnique.OpenMU.GameLogic.CastleSiege;

using System.ComponentModel;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using System.Threading;
Expand All @@ -17,11 +18,12 @@ namespace MUnique.OpenMU.GameLogic.CastleSiege;
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
[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);
Expand Down Expand Up @@ -138,6 +140,16 @@ public ValueTask SelectSpawnGateAsync(Player player, SpawnGateSelectionArgs args
return ValueTask.CompletedTask;
}

/// <inheritdoc />
public void CheckAttack(Player attacker, Player defender, CancelEventArgs eventArgs)
{
if (this.GetContext(attacker.GameContext) is { } context
&& context.AreOnSameSide(attacker, defender))
{
eventArgs.Cancel = true;
}
}

/// <inheritdoc />
public void CheckExemption(Player attacker, Player defender, PlayerKillPenaltyExemptionArgs eventArgs)
{
Expand Down
23 changes: 22 additions & 1 deletion src/GameLogic/Player.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
namespace MUnique.OpenMU.GameLogic;

using System;
using System.ComponentModel;
using System.Globalization;
using System.Threading;
using MUnique.OpenMU.AttributeSystem;
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -2147,6 +2149,25 @@ private async void OnTransformationSkinChanged(object? sender, EventArgs args)
}
}

/// <summary>
/// Determines whether a plugin prevents the attacking player, or the owner of the attacking pet, from damaging this player.
/// </summary>
/// <param name="attacker">The attacker.</param>
/// <returns><c>true</c>, if the attack is prevented; otherwise, <c>false</c>.</returns>
private bool IsAttackPreventedByPlugIns(IAttacker attacker)
{
if ((attacker as Player ?? (attacker as IPlayerSurrogate)?.Owner) is not { } attackerPlayer
|| attackerPlayer == this
|| this.GameContext.PlugInManager.GetPlugInPoint<IPlayerAttackRestrictionPlugIn>() is not { } plugInPoint)
{
return false;
}

var eventArgs = new CancelEventArgs();
plugInPoint.CheckAttack(attackerPlayer, this, eventArgs);
return eventArgs.Cancel;
}

/// <summary>
/// Sets the reclaimable attributes before a character enters the game.
/// Current shield and mana are set to their maximum values.
Expand Down
26 changes: 26 additions & 0 deletions src/GameLogic/PlugIns/IPlayerAttackRestrictionPlugIn.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
// <copyright file="IPlayerAttackRestrictionPlugIn.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>

namespace MUnique.OpenMU.GameLogic.PlugIns;

using System.ComponentModel;
using System.Runtime.InteropServices;
using MUnique.OpenMU.PlugIns;

/// <summary>
/// Plugins which can prevent a player from damaging another player,
/// e.g. between the players of the same side of a Castle Siege battle.
/// </summary>
[Guid("9130ECFA-9916-4074-92DC-5AEA17BC4F1A")]
[PlugInPoint("Player attack restriction", "Plugins which can prevent a player from damaging another player.")]
public interface IPlayerAttackRestrictionPlugIn
{
/// <summary>
/// Checks if the attacker may damage the defender.
/// </summary>
/// <param name="attacker">The attacking player, or the owner of the attacking pet.</param>
/// <param name="defender">The attacked player.</param>
/// <param name="eventArgs">The event args. A plugin sets <see cref="CancelEventArgs.Cancel"/> to prevent the attack.</param>
void CheckAttack(Player attacker, Player defender, CancelEventArgs eventArgs);
}
145 changes: 145 additions & 0 deletions tests/MUnique.OpenMU.Tests/CastleSiegeFriendlyFireTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
// <copyright file="CastleSiegeFriendlyFireTests.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>

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;

/// <summary>
/// Tests that players of the same side of a Castle Siege battle can't damage each other.
/// </summary>
[TestFixture]
public class CastleSiegeFriendlyFireTests
{
private static readonly Guid CastleSiegePlugInId = new("B7F62FA9-59E6-49E9-B499-0358A14957CF");

/// <summary>
/// Verifies that only players of the same side on the siege map during the battle are on the same side.
/// </summary>
[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.");
}

/// <summary>
/// Verifies that an attack on a player of the same side doesn't hit.
/// </summary>
[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));
});
}

/// <summary>
/// Verifies that an attack on a player of an opposing side still hits.
/// </summary>
[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<SiegeFixture> 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<IPeriodicTaskPlugIn>().OfType<CastleSiegePlugIn>().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<Player> 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;
}
}
}
Loading