Merge pull request #827 from Rhefew/feature/ip-connection-limit
feat(plugins): implement customizable maximum concurrent connections (cherry picked from commit 0e30df9ae22a265c3486dea1eba259567631eb87)
This commit is contained in:
16
src/GameLogic/IHasIpAddress.cs
Normal file
16
src/GameLogic/IHasIpAddress.cs
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
// <copyright file="IHasIpAddress.cs" company="MUnique">
|
||||||
|
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
|
||||||
|
// </copyright>
|
||||||
|
|
||||||
|
namespace MUnique.OpenMU.GameLogic;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Interface for objects that expose a remote IP address.
|
||||||
|
/// </summary>
|
||||||
|
public interface IHasIpAddress
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the IP address of the remote connection.
|
||||||
|
/// </summary>
|
||||||
|
string? IpAddress { get; }
|
||||||
|
}
|
||||||
@@ -203,6 +203,11 @@ public class Player : AsyncDisposable, IBucketMapObserver, IAttackable, IAttacke
|
|||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public ushort Id { get; set; }
|
public ushort Id { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets a custom login result to override the default when login fails.
|
||||||
|
/// </summary>
|
||||||
|
public Views.Login.LoginResult? LoginResultOverride { get; set; }
|
||||||
|
|
||||||
/// <inheritdoc cref="IPartyMember" />
|
/// <inheritdoc cref="IPartyMember" />
|
||||||
public string Name => this.SelectedCharacter?.Name ?? string.Empty;
|
public string Name => this.SelectedCharacter?.Name ?? string.Empty;
|
||||||
|
|
||||||
|
|||||||
@@ -189,7 +189,9 @@ public class LoginAction
|
|||||||
|
|
||||||
private async ValueTask HandleAlreadyConnectedAsync(Player player, string username)
|
private async ValueTask HandleAlreadyConnectedAsync(Player player, string username)
|
||||||
{
|
{
|
||||||
await player.InvokeViewPlugInAsync<IShowLoginResultPlugIn>(p => p.ShowLoginResultAsync(LoginResult.AccountAlreadyConnected)).ConfigureAwait(false);
|
var result = player.LoginResultOverride ?? LoginResult.AccountAlreadyConnected;
|
||||||
|
player.LoginResultOverride = null;
|
||||||
|
await player.InvokeViewPlugInAsync<IShowLoginResultPlugIn>(p => p.ShowLoginResultAsync(result)).ConfigureAwait(false);
|
||||||
if (player.GameContext is IGameServerContext gameServerContext)
|
if (player.GameContext is IGameServerContext gameServerContext)
|
||||||
{
|
{
|
||||||
await gameServerContext.EventPublisher.PlayerAlreadyLoggedInAsync(gameServerContext.Id, username).ConfigureAwait(false);
|
await gameServerContext.EventPublisher.PlayerAlreadyLoggedInAsync(gameServerContext.Id, username).ConfigureAwait(false);
|
||||||
|
|||||||
68
src/GameLogic/PlugIns/MaximumConnectionsPerIpPlugIn.cs
Normal file
68
src/GameLogic/PlugIns/MaximumConnectionsPerIpPlugIn.cs
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
// <copyright file="MaximumConnectionsPerIpPlugIn.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.DataAnnotations;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using MUnique.OpenMU.PlugIns;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A plugin that limits the number of simultaneous active player sessions connected from the same IP address.
|
||||||
|
/// </summary>
|
||||||
|
[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<MaximumConnectionsPerIpPlugInConfiguration>, ISupportDefaultCustomConfiguration, IDisabledByDefault
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public MaximumConnectionsPerIpPlugInConfiguration? Configuration { get; set; }
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public object CreateDefaultConfig()
|
||||||
|
{
|
||||||
|
return CreateDefaultConfiguration();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static MaximumConnectionsPerIpPlugInConfiguration CreateDefaultConfiguration()
|
||||||
|
{
|
||||||
|
return new MaximumConnectionsPerIpPlugInConfiguration();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
// <copyright file="MaximumConnectionsPerIpPlugInConfiguration.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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Configuration for the <see cref="MaximumConnectionsPerIpPlugIn"/>.
|
||||||
|
/// </summary>
|
||||||
|
public class MaximumConnectionsPerIpPlugInConfiguration
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the maximum number of concurrent connections per IP address.
|
||||||
|
/// </summary>
|
||||||
|
public int MaximumConnectionsPerIp { get; set; } = 3;
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
// <copyright file="RemotePlayer.cs" company="MUnique">
|
// <copyright file="RemotePlayer.cs" company="MUnique">
|
||||||
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
|
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
|
||||||
// </copyright>
|
// </copyright>
|
||||||
|
|
||||||
@@ -16,12 +16,14 @@ using MUnique.OpenMU.PlugIns;
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// A player which is playing through a remote connection.
|
/// A player which is playing through a remote connection.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class RemotePlayer : Player, IClientVersionProvider
|
public class RemotePlayer : Player, IClientVersionProvider, IHasIpAddress
|
||||||
{
|
{
|
||||||
private readonly byte[] _packetBuffer = new byte[0xFF];
|
private readonly byte[] _packetBuffer = new byte[0xFF];
|
||||||
|
|
||||||
private ClientVersion _clientVersion;
|
private ClientVersion _clientVersion;
|
||||||
|
|
||||||
|
private readonly string? _ipAddress;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Initializes a new instance of the <see cref="RemotePlayer"/> class.
|
/// Initializes a new instance of the <see cref="RemotePlayer"/> class.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -33,6 +35,9 @@ public class RemotePlayer : Player, IClientVersionProvider
|
|||||||
{
|
{
|
||||||
this.Connection = connection;
|
this.Connection = connection;
|
||||||
this._clientVersion = clientVersion;
|
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 = new MainPacketHandlerPlugInContainer(this, gameContext.PlugInManager, gameContext.LoggerFactory);
|
||||||
this.MainPacketHandler.Initialize();
|
this.MainPacketHandler.Initialize();
|
||||||
this.Connection!.PacketReceived += this.PacketReceivedAsync;
|
this.Connection!.PacketReceived += this.PacketReceivedAsync;
|
||||||
@@ -42,6 +47,9 @@ public class RemotePlayer : Player, IClientVersionProvider
|
|||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public event EventHandler? ClientVersionChanged;
|
public event EventHandler? ClientVersionChanged;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public string? IpAddress => this._ipAddress;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets the game server context.
|
/// Gets the game server context.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
200
tests/MUnique.OpenMU.Tests/MaximumConnectionsPerIpPlugInTests.cs
Normal file
200
tests/MUnique.OpenMU.Tests/MaximumConnectionsPerIpPlugInTests.cs
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
// <copyright file="MaximumConnectionsPerIpPlugInTests.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 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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Unit tests for the <see cref="MaximumConnectionsPerIpPlugIn"/>.
|
||||||
|
/// </summary>
|
||||||
|
[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)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public string? IpAddress => this._ipAddress;
|
||||||
|
|
||||||
|
/// <summary>Sets the IP address for testing purposes.</summary>
|
||||||
|
public void SetIpAddress(string? ip) => this._ipAddress = ip;
|
||||||
|
|
||||||
|
protected override ICustomPlugInContainer<GameLogic.Views.IViewPlugIn> CreateViewPlugInContainer()
|
||||||
|
{
|
||||||
|
return new MockViewPlugInContainer();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user