diff --git a/src/GameLogic/IHasIpAddress.cs b/src/GameLogic/IHasIpAddress.cs
new file mode 100644
index 0000000..924443d
--- /dev/null
+++ b/src/GameLogic/IHasIpAddress.cs
@@ -0,0 +1,16 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.GameLogic;
+
+///
+/// Interface for objects that expose a remote IP address.
+///
+public interface IHasIpAddress
+{
+ ///
+ /// Gets the IP address of the remote connection.
+ ///
+ string? IpAddress { get; }
+}
diff --git a/src/GameLogic/Player.cs b/src/GameLogic/Player.cs
index df72f5c..1012558 100644
--- a/src/GameLogic/Player.cs
+++ b/src/GameLogic/Player.cs
@@ -203,6 +203,11 @@ public class Player : AsyncDisposable, IBucketMapObserver, IAttackable, IAttacke
///
public ushort Id { get; set; }
+ ///
+ /// Gets or sets a custom login result to override the default when login fails.
+ ///
+ public Views.Login.LoginResult? LoginResultOverride { get; set; }
+
///
public string Name => this.SelectedCharacter?.Name ?? string.Empty;
diff --git a/src/GameLogic/PlayerActions/LoginAction.cs b/src/GameLogic/PlayerActions/LoginAction.cs
index bbbc6a5..8dea162 100644
--- a/src/GameLogic/PlayerActions/LoginAction.cs
+++ b/src/GameLogic/PlayerActions/LoginAction.cs
@@ -189,7 +189,9 @@ public class LoginAction
private async ValueTask HandleAlreadyConnectedAsync(Player player, string username)
{
- await player.InvokeViewPlugInAsync(p => p.ShowLoginResultAsync(LoginResult.AccountAlreadyConnected)).ConfigureAwait(false);
+ var result = player.LoginResultOverride ?? LoginResult.AccountAlreadyConnected;
+ player.LoginResultOverride = null;
+ await player.InvokeViewPlugInAsync(p => p.ShowLoginResultAsync(result)).ConfigureAwait(false);
if (player.GameContext is IGameServerContext gameServerContext)
{
await gameServerContext.EventPublisher.PlayerAlreadyLoggedInAsync(gameServerContext.Id, username).ConfigureAwait(false);
diff --git a/src/GameLogic/PlugIns/MaximumConnectionsPerIpPlugIn.cs b/src/GameLogic/PlugIns/MaximumConnectionsPerIpPlugIn.cs
new file mode 100644
index 0000000..ec7efec
--- /dev/null
+++ b/src/GameLogic/PlugIns/MaximumConnectionsPerIpPlugIn.cs
@@ -0,0 +1,68 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.GameLogic.PlugIns;
+
+using System.ComponentModel.DataAnnotations;
+using System.Linq;
+using System.Runtime.InteropServices;
+using System.Threading.Tasks;
+using Microsoft.Extensions.Logging;
+using MUnique.OpenMU.PlugIns;
+
+///
+/// A plugin that limits the number of simultaneous active player sessions connected from the same IP address.
+///
+[PlugIn]
+[Display(Name = "Maximum Connections Per IP", Description = "Limits the maximum number of parallel connections from the same IP address.")]
+[Guid("2C779F5E-379E-4CE0-BFCC-CA6455D757B3")]
+public class MaximumConnectionsPerIpPlugIn : IPlayerStateChangingPlugIn, ISupportCustomConfiguration, ISupportDefaultCustomConfiguration, IDisabledByDefault
+{
+ ///
+ public MaximumConnectionsPerIpPlugInConfiguration? Configuration { get; set; }
+
+ ///
+ public async ValueTask PlayerStateChangingAsync(Player player, StateMachine.StateChangeEventArgs eventArgs)
+ {
+ if (eventArgs.NextState != PlayerState.Authenticated)
+ {
+ return;
+ }
+
+ var ipAddress = (player as IHasIpAddress)?.IpAddress;
+ if (string.IsNullOrEmpty(ipAddress))
+ {
+ return;
+ }
+
+ var config = this.Configuration ?? CreateDefaultConfiguration();
+ var players = await player.GameContext.GetPlayersAsync().ConfigureAwait(false);
+ var sameIpCount = players.Count(p =>
+ (p as IHasIpAddress)?.IpAddress == ipAddress
+ && p != player
+ && p.PlayerState.CurrentState != PlayerState.Initial
+ && p.PlayerState.CurrentState != PlayerState.LoginScreen);
+
+ if (sameIpCount >= config.MaximumConnectionsPerIp)
+ {
+ player.Logger.LogWarning(
+ "Login request for IP '{IpAddress}' was cancelled. It exceeded the maximum connection limit of {Limit}.",
+ ipAddress,
+ config.MaximumConnectionsPerIp);
+ player.LoginResultOverride = Views.Login.LoginResult.ServerIsFull;
+ eventArgs.Cancel = true;
+ }
+ }
+
+ ///
+ public object CreateDefaultConfig()
+ {
+ return CreateDefaultConfiguration();
+ }
+
+ private static MaximumConnectionsPerIpPlugInConfiguration CreateDefaultConfiguration()
+ {
+ return new MaximumConnectionsPerIpPlugInConfiguration();
+ }
+}
diff --git a/src/GameLogic/PlugIns/MaximumConnectionsPerIpPlugInConfiguration.cs b/src/GameLogic/PlugIns/MaximumConnectionsPerIpPlugInConfiguration.cs
new file mode 100644
index 0000000..1d2d658
--- /dev/null
+++ b/src/GameLogic/PlugIns/MaximumConnectionsPerIpPlugInConfiguration.cs
@@ -0,0 +1,16 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.GameLogic.PlugIns;
+
+///
+/// Configuration for the .
+///
+public class MaximumConnectionsPerIpPlugInConfiguration
+{
+ ///
+ /// Gets or sets the maximum number of concurrent connections per IP address.
+ ///
+ public int MaximumConnectionsPerIp { get; set; } = 3;
+}
diff --git a/src/GameServer/RemoteView/RemotePlayer.cs b/src/GameServer/RemoteView/RemotePlayer.cs
index a455d91..2c0b584 100644
--- a/src/GameServer/RemoteView/RemotePlayer.cs
+++ b/src/GameServer/RemoteView/RemotePlayer.cs
@@ -1,4 +1,4 @@
-//
+//
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
//
@@ -16,12 +16,14 @@ using MUnique.OpenMU.PlugIns;
///
/// A player which is playing through a remote connection.
///
-public class RemotePlayer : Player, IClientVersionProvider
+public class RemotePlayer : Player, IClientVersionProvider, IHasIpAddress
{
private readonly byte[] _packetBuffer = new byte[0xFF];
private ClientVersion _clientVersion;
+ private readonly string? _ipAddress;
+
///
/// Initializes a new instance of the class.
///
@@ -33,6 +35,9 @@ public class RemotePlayer : Player, IClientVersionProvider
{
this.Connection = connection;
this._clientVersion = clientVersion;
+ this._ipAddress = connection.EndPoint is System.Net.IPEndPoint ipEndPoint
+ ? (ipEndPoint.Address.IsIPv4MappedToIPv6 ? ipEndPoint.Address.MapToIPv4() : ipEndPoint.Address).ToString()
+ : null;
this.MainPacketHandler = new MainPacketHandlerPlugInContainer(this, gameContext.PlugInManager, gameContext.LoggerFactory);
this.MainPacketHandler.Initialize();
this.Connection!.PacketReceived += this.PacketReceivedAsync;
@@ -42,6 +47,9 @@ public class RemotePlayer : Player, IClientVersionProvider
///
public event EventHandler? ClientVersionChanged;
+ ///
+ public string? IpAddress => this._ipAddress;
+
///
/// Gets the game server context.
///
diff --git a/tests/MUnique.OpenMU.Tests/MaximumConnectionsPerIpPlugInTests.cs b/tests/MUnique.OpenMU.Tests/MaximumConnectionsPerIpPlugInTests.cs
new file mode 100644
index 0000000..6a4b196
--- /dev/null
+++ b/tests/MUnique.OpenMU.Tests/MaximumConnectionsPerIpPlugInTests.cs
@@ -0,0 +1,200 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.Tests;
+
+
+using System.Threading.Tasks;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+using NUnit.Framework;
+using MUnique.OpenMU.AttributeSystem;
+using MUnique.OpenMU.DataModel.Configuration;
+using MUnique.OpenMU.DataModel.Configuration.Items;
+using MUnique.OpenMU.DataModel.Entities;
+using MUnique.OpenMU.GameLogic;
+using MUnique.OpenMU.GameLogic.Attributes;
+using MUnique.OpenMU.GameLogic.PlugIns;
+using MUnique.OpenMU.Persistence.InMemory;
+using MUnique.OpenMU.PlugIns;
+
+///
+/// Unit tests for the .
+///
+[TestFixture]
+public class MaximumConnectionsPerIpPlugInTests
+{
+ private GameContext _gameContext = null!;
+ private MaximumConnectionsPerIpPlugIn _plugin = null!;
+
+ [SetUp]
+ public async Task SetUp()
+ {
+ var dummyPlayer = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false);
+ this._gameContext = (GameContext)dummyPlayer.GameContext;
+ this._plugin = new MaximumConnectionsPerIpPlugIn
+ {
+ Configuration = new MaximumConnectionsPerIpPlugInConfiguration
+ {
+ MaximumConnectionsPerIp = 3
+ }
+ };
+ }
+
+ [Test]
+ public async ValueTask UnderLimitSucceedsAsync()
+ {
+ // Arrange
+ var player1 = new TestPlayer(this._gameContext);
+ player1.SetIpAddress("127.0.0.1");
+ await player1.PlayerState.TryAdvanceToAsync(PlayerState.LoginScreen).ConfigureAwait(false);
+ await player1.PlayerState.TryAdvanceToAsync(PlayerState.Authenticated).ConfigureAwait(false);
+ await this._gameContext.AddPlayerAsync(player1).ConfigureAwait(false);
+
+ var player2 = new TestPlayer(this._gameContext);
+ player2.SetIpAddress("127.0.0.1");
+ await player2.PlayerState.TryAdvanceToAsync(PlayerState.LoginScreen).ConfigureAwait(false);
+ await player2.PlayerState.TryAdvanceToAsync(PlayerState.Authenticated).ConfigureAwait(false);
+ await this._gameContext.AddPlayerAsync(player2).ConfigureAwait(false);
+
+ var joiningPlayer = new TestPlayer(this._gameContext);
+ joiningPlayer.SetIpAddress("127.0.0.1");
+ await joiningPlayer.PlayerState.TryAdvanceToAsync(PlayerState.LoginScreen).ConfigureAwait(false);
+
+ var eventArgs = new StateMachine.StateChangeEventArgs { NextState = PlayerState.Authenticated };
+
+ // Act
+ await this._plugin.PlayerStateChangingAsync(joiningPlayer, eventArgs).ConfigureAwait(false);
+
+ // Assert
+ Assert.That(eventArgs.Cancel, Is.False);
+ }
+
+ [Test]
+ public async ValueTask AtLimitCancelsTransitionAsync()
+ {
+ // Arrange
+ var player1 = new TestPlayer(this._gameContext);
+ player1.SetIpAddress("127.0.0.1");
+ await player1.PlayerState.TryAdvanceToAsync(PlayerState.LoginScreen).ConfigureAwait(false);
+ await player1.PlayerState.TryAdvanceToAsync(PlayerState.Authenticated).ConfigureAwait(false);
+ await this._gameContext.AddPlayerAsync(player1).ConfigureAwait(false);
+
+ var player2 = new TestPlayer(this._gameContext);
+ player2.SetIpAddress("127.0.0.1");
+ await player2.PlayerState.TryAdvanceToAsync(PlayerState.LoginScreen).ConfigureAwait(false);
+ await player2.PlayerState.TryAdvanceToAsync(PlayerState.Authenticated).ConfigureAwait(false);
+ await this._gameContext.AddPlayerAsync(player2).ConfigureAwait(false);
+
+ var player3 = new TestPlayer(this._gameContext);
+ player3.SetIpAddress("127.0.0.1");
+ await player3.PlayerState.TryAdvanceToAsync(PlayerState.LoginScreen).ConfigureAwait(false);
+ await player3.PlayerState.TryAdvanceToAsync(PlayerState.Authenticated).ConfigureAwait(false);
+ await this._gameContext.AddPlayerAsync(player3).ConfigureAwait(false);
+
+ var joiningPlayer = new TestPlayer(this._gameContext);
+ joiningPlayer.SetIpAddress("127.0.0.1");
+ await joiningPlayer.PlayerState.TryAdvanceToAsync(PlayerState.LoginScreen).ConfigureAwait(false);
+
+ var eventArgs = new StateMachine.StateChangeEventArgs { NextState = PlayerState.Authenticated };
+
+ // Act
+ await this._plugin.PlayerStateChangingAsync(joiningPlayer, eventArgs).ConfigureAwait(false);
+
+ // Assert
+ Assert.That(eventArgs.Cancel, Is.True);
+ Assert.That(joiningPlayer.LoginResultOverride, Is.EqualTo(GameLogic.Views.Login.LoginResult.ServerIsFull));
+ }
+
+ [Test]
+ public async ValueTask DifferentIpDoesNotCountAsync()
+ {
+ // Arrange
+ var player1 = new TestPlayer(this._gameContext);
+ player1.SetIpAddress("127.0.0.1");
+ await player1.PlayerState.TryAdvanceToAsync(PlayerState.LoginScreen).ConfigureAwait(false);
+ await player1.PlayerState.TryAdvanceToAsync(PlayerState.Authenticated).ConfigureAwait(false);
+ await this._gameContext.AddPlayerAsync(player1).ConfigureAwait(false);
+
+ var player2 = new TestPlayer(this._gameContext);
+ player2.SetIpAddress("127.0.0.1");
+ await player2.PlayerState.TryAdvanceToAsync(PlayerState.LoginScreen).ConfigureAwait(false);
+ await player2.PlayerState.TryAdvanceToAsync(PlayerState.Authenticated).ConfigureAwait(false);
+ await this._gameContext.AddPlayerAsync(player2).ConfigureAwait(false);
+
+ var player3 = new TestPlayer(this._gameContext);
+ player3.SetIpAddress("127.0.0.1");
+ await player3.PlayerState.TryAdvanceToAsync(PlayerState.LoginScreen).ConfigureAwait(false);
+ await player3.PlayerState.TryAdvanceToAsync(PlayerState.Authenticated).ConfigureAwait(false);
+ await this._gameContext.AddPlayerAsync(player3).ConfigureAwait(false);
+
+ var joiningPlayer = new TestPlayer(this._gameContext);
+ joiningPlayer.SetIpAddress(new System.Net.IPAddress(new byte[] { 192, 168, 1, 100 }).ToString());
+ await joiningPlayer.PlayerState.TryAdvanceToAsync(PlayerState.LoginScreen).ConfigureAwait(false);
+
+ var eventArgs = new StateMachine.StateChangeEventArgs { NextState = PlayerState.Authenticated };
+
+ // Act
+ await this._plugin.PlayerStateChangingAsync(joiningPlayer, eventArgs).ConfigureAwait(false);
+
+ // Assert
+ Assert.That(eventArgs.Cancel, Is.False);
+ }
+
+ [Test]
+ public async ValueTask LoginScreenAndInitialPlayersDoNotCountAsync()
+ {
+ // Arrange
+ var player1 = new TestPlayer(this._gameContext);
+ player1.SetIpAddress("127.0.0.1");
+ await player1.PlayerState.TryAdvanceToAsync(PlayerState.LoginScreen).ConfigureAwait(false);
+ await player1.PlayerState.TryAdvanceToAsync(PlayerState.Authenticated).ConfigureAwait(false);
+ await this._gameContext.AddPlayerAsync(player1).ConfigureAwait(false);
+
+ var player2 = new TestPlayer(this._gameContext);
+ player2.SetIpAddress("127.0.0.1");
+ await player2.PlayerState.TryAdvanceToAsync(PlayerState.LoginScreen).ConfigureAwait(false);
+ await player2.PlayerState.TryAdvanceToAsync(PlayerState.Authenticated).ConfigureAwait(false);
+ await this._gameContext.AddPlayerAsync(player2).ConfigureAwait(false);
+
+ // This player is only at the login screen
+ var player3 = new TestPlayer(this._gameContext);
+ player3.SetIpAddress("127.0.0.1");
+ await player3.PlayerState.TryAdvanceToAsync(PlayerState.LoginScreen).ConfigureAwait(false);
+ await this._gameContext.AddPlayerAsync(player3).ConfigureAwait(false);
+
+ var joiningPlayer = new TestPlayer(this._gameContext);
+ joiningPlayer.SetIpAddress("127.0.0.1");
+ await joiningPlayer.PlayerState.TryAdvanceToAsync(PlayerState.LoginScreen).ConfigureAwait(false);
+
+ var eventArgs = new StateMachine.StateChangeEventArgs { NextState = PlayerState.Authenticated };
+
+ // Act
+ await this._plugin.PlayerStateChangingAsync(joiningPlayer, eventArgs).ConfigureAwait(false);
+
+ // Assert
+ Assert.That(eventArgs.Cancel, Is.False);
+ }
+
+ private class TestPlayer : Player, IHasIpAddress
+ {
+ private string? _ipAddress;
+
+ public TestPlayer(IGameContext gameContext)
+ : base(gameContext)
+ {
+ }
+
+ ///
+ public string? IpAddress => this._ipAddress;
+
+ /// Sets the IP address for testing purposes.
+ public void SetIpAddress(string? ip) => this._ipAddress = ip;
+
+ protected override ICustomPlugInContainer CreateViewPlugInContainer()
+ {
+ return new MockViewPlugInContainer();
+ }
+ }
+}