diff --git a/src/Web/AdminPanel/Components/PartyBadge.razor b/src/Web/AdminPanel/Components/PartyBadge.razor new file mode 100644 index 000000000..924e548d0 --- /dev/null +++ b/src/Web/AdminPanel/Components/PartyBadge.razor @@ -0,0 +1,31 @@ +@if (PartyMaster is null || PartySize <= 0) +{ + +} +else +{ + @PartyMaster (@PartySize) +} + +@code { + /// + /// Gets or sets the character name of the party master. + /// + [Parameter] + public string? PartyMaster { get; set; } + + /// + /// Gets or sets the number of party members. + /// + [Parameter] + public int PartySize { get; set; } + + private string GetPartyColor() + { + // Deterministic hue per party master, so all members of one party share the same badge color + // and different parties are visually distinct. + var hash = System.HashCode.Combine(this.PartyMaster!.ToUpperInvariant()); + var hue = ((hash % 360) + 360) % 360; + return $"hsl({hue}, 45%, 45%)"; + } +} diff --git a/src/Web/AdminPanel/Pages/LoggedIn.razor b/src/Web/AdminPanel/Pages/LoggedIn.razor index e56aa0f22..90ab5ec9b 100644 --- a/src/Web/AdminPanel/Pages/LoggedIn.razor +++ b/src/Web/AdminPanel/Pages/LoggedIn.razor @@ -7,66 +7,152 @@ @inject LoggedInAccountService AccountService; @inject OfflineAccountService OfflineService; +@inject BotAccountService BotService; @inject IServiceProvider ServiceProvider; OpenMU: @Resources.OnlineAccounts

@Resources.OnlineAccounts

-
- - - @typeof(Account).GetPropertyCaption(nameof(Account.LoginName)) - @Resources.ServerID - @Resources.Action - - - @item.LoginName - @item.Server - - - @if (this.IsNetworkAnalyzerAvailable) - { - - - } - - - -
- -

@Resources.ActiveOfflinePlayer

+ -
- - - @typeof(Account).GetPropertyCaption(nameof(Account.LoginName)) - @Resources.ServerID - @Resources.StartedAt - @Resources.Action - - - @item.LoginName - @item.ServerId - @item.StartedAt.ToString("yyyy-MM-dd HH:mm:ss") UTC - - - - - +
+ @if (_activeTab == Tab.Players) + { +
+ + + @typeof(Account).GetPropertyCaption(nameof(Account.LoginName)) + @Resources.Character + @Resources.ServerID + @Resources.Party + @Resources.Action + + + @item.LoginName + @(item.CharacterName ?? "—") + @item.Server + + + + @if (this.IsNetworkAnalyzerAvailable) + { + + + } + + + +
+ } + else if (_activeTab == Tab.Offlevel) + { +
+ + + @typeof(Account).GetPropertyCaption(nameof(Account.LoginName)) + @Resources.Character + @Resources.ServerID + @Resources.Party + @Resources.StartedAt + @Resources.Action + + + @item.LoginName + @(item.CharacterName ?? "—") + @item.ServerId + + @item.StartedAt.ToString("yyyy-MM-dd HH:mm:ss") UTC + + + + + +
+ } + else + { +
+ + + @typeof(Account).GetPropertyCaption(nameof(Account.LoginName)) + @Resources.Character + @Resources.ServerID + @Resources.Party + @Resources.StartedAt + + + @item.LoginName + @(item.CharacterName ?? "—") + @item.ServerId + + @item.StartedAt.ToString("yyyy-MM-dd HH:mm:ss") UTC + + +
+ }
@code { + private Tab _activeTab = Tab.Players; + + private bool _showOfflevel; + + private bool _showBots; /// /// Gets a value indicating whether the network analyzer is available. It needs the servers /// in the same process, so it's only registered in the all-in-one deployment. /// private bool IsNetworkAnalyzerAvailable => this.ServiceProvider.GetService(typeof(IPacketCaptureService)) is not null; + + protected override void OnInitialized() + { + base.OnInitialized(); + // Tabs are only shown when the corresponding feature plugin is active on an in-process game server. + // In a distributed deployment the contexts aren't available, so the tabs stay hidden. + this._showOfflevel = this.OfflineService.IsOfflevelFeatureAvailable(); + this._showBots = this.BotService.IsBotFeatureAvailable(); + } + + private enum Tab + { + Players, + Offlevel, + Bots, + } } diff --git a/src/Web/AdminPanel/Properties/Resources.Designer.cs b/src/Web/AdminPanel/Properties/Resources.Designer.cs index 4e6d6b8c5..0fa03e26e 100644 --- a/src/Web/AdminPanel/Properties/Resources.Designer.cs +++ b/src/Web/AdminPanel/Properties/Resources.Designer.cs @@ -1,4 +1,4 @@ -//------------------------------------------------------------------------------ +//------------------------------------------------------------------------------ // // This code was generated by a tool. // Runtime Version:4.0.30319.42000 @@ -1746,6 +1746,42 @@ public static string OpenMUAdminPanel { } } + /// + /// Looks up a localized string similar to Off-level Players. + /// + public static string OffLevelPlayers { + get { + return ResourceManager.GetString("OffLevelPlayers", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Bot Players. + /// + public static string BotPlayers { + get { + return ResourceManager.GetString("BotPlayers", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Party. + /// + public static string Party { + get { + return ResourceManager.GetString("Party", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Players. + /// + public static string Players { + get { + return ResourceManager.GetString("Players", resourceCulture); + } + } + /// /// Looks up a localized string similar to Code. /// diff --git a/src/Web/AdminPanel/Properties/Resources.resx b/src/Web/AdminPanel/Properties/Resources.resx index 6cacc17b7..000473472 100644 --- a/src/Web/AdminPanel/Properties/Resources.resx +++ b/src/Web/AdminPanel/Properties/Resources.resx @@ -1242,4 +1242,16 @@ Download the archived session + + Players + + + Off-level Players + + + Bot Players + + + Party + diff --git a/src/Web/AdminPanel/WebApplicationExtensions.cs b/src/Web/AdminPanel/WebApplicationExtensions.cs index ac097683f..ba773cd8c 100644 --- a/src/Web/AdminPanel/WebApplicationExtensions.cs +++ b/src/Web/AdminPanel/WebApplicationExtensions.cs @@ -102,6 +102,8 @@ public static WebApplicationBuilder AddAdminPanel(this WebApplicationBuilder bui services.AddScoped>(serviceProvider => serviceProvider.GetService()!); services.AddScoped(); services.AddScoped>(serviceProvider => serviceProvider.GetService()!); + services.AddScoped(); + services.AddScoped>(serviceProvider => serviceProvider.GetService()!); StaticWebAssetsLoader.UseStaticWebAssets(builder.Environment, builder.Configuration); return builder; diff --git a/src/Web/Shared/Services/BotAccount.cs b/src/Web/Shared/Services/BotAccount.cs new file mode 100644 index 000000000..3d37f9d4c --- /dev/null +++ b/src/Web/Shared/Services/BotAccount.cs @@ -0,0 +1,16 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Web.Shared.Services; + +/// +/// Keeps the displayed data of an active server-side bot. +/// +/// The account login name the bot is driving. +/// The server identifier. +/// The character which the bot is driving. +/// The start timestamp of the bot session. +/// The character name of the party master, if the bot is in a party. +/// The number of party members, if the bot is in a party. +public record BotAccount(string LoginName, byte ServerId, string? CharacterName, DateTime StartedAt, string? PartyMaster = null, int PartySize = 0) : IPartyGroupedAccount; diff --git a/src/Web/Shared/Services/BotAccountService.cs b/src/Web/Shared/Services/BotAccountService.cs new file mode 100644 index 000000000..dd5d4d73c --- /dev/null +++ b/src/Web/Shared/Services/BotAccountService.cs @@ -0,0 +1,65 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Web.Shared.Services; + +using MUnique.OpenMU.GameLogic; +using MUnique.OpenMU.GameLogic.Bots; +using MUnique.OpenMU.Interfaces; + +/// +/// Service for the bot player table on the LoggedIn page. +/// Bots are connection-less instances in the game contexts, +/// so they are only visible in the all-in-one deployment. +/// The table is read-only: there is no supported way to stop a single bot from the admin panel. +/// +public class BotAccountService : IDataService +{ + private readonly IServerProvider _serverProvider; + + /// + /// Initializes a new instance of the class. + /// + /// The server provider. + public BotAccountService(IServerProvider serverProvider) + { + this._serverProvider = serverProvider; + } + + /// + /// Determines whether the bot feature plugin is active on any in-process game server. + /// + public bool IsBotFeatureAvailable() + { + return this._serverProvider.Servers + .OfType() + .Any(s => s.Context.PlugInManager.IsPlugInActive(typeof(BotFeaturePlugIn))); + } + + /// + public async Task> GetAsync(int offset, int count) + { + var result = new List(); + foreach (var server in this._serverProvider.Servers.OfType()) + { + var serverId = (byte)((IManageableServer)server).Id; + var players = await server.Context.GetPlayersAsync().ConfigureAwait(false); + result.AddRange(players + .OfType() + .Select(p => new BotAccount( + p.Account?.LoginName ?? string.Empty, + serverId, + p.SelectedCharacter?.Name, + p.StartTimestamp, + p.Party?.PartyMaster?.Name, + p.Party?.PartyList.Count ?? 0))); + } + + return result + .OrderPartyGrouped() + .Skip(offset) + .Take(count) + .ToList(); + } +} diff --git a/src/Web/Shared/Services/IPartyGroupedAccount.cs b/src/Web/Shared/Services/IPartyGroupedAccount.cs new file mode 100644 index 000000000..e77ba9bae --- /dev/null +++ b/src/Web/Shared/Services/IPartyGroupedAccount.cs @@ -0,0 +1,26 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Web.Shared.Services; + +/// +/// The data shown in one row of the online-accounts tables which can be grouped by party. +/// +public interface IPartyGroupedAccount +{ + /// + /// Gets the account login name. + /// + string LoginName { get; } + + /// + /// Gets the selected character, if it could be resolved. + /// + string? CharacterName { get; } + + /// + /// Gets the character name of the party master, if the player is in a party. + /// + string? PartyMaster { get; } +} diff --git a/src/Web/Shared/Services/LoggedInAccount.cs b/src/Web/Shared/Services/LoggedInAccount.cs index d1822dfb4..29e4b5f25 100644 --- a/src/Web/Shared/Services/LoggedInAccount.cs +++ b/src/Web/Shared/Services/LoggedInAccount.cs @@ -7,4 +7,9 @@ namespace MUnique.OpenMU.Web.Shared.Services; /// /// Keeps the displayed data of a logged-in account. /// -public record LoggedInAccount(string LoginName, byte Server); \ No newline at end of file +/// The account login name. +/// The server identifier. +/// The selected character, if it could be resolved (only available in the all-in-one deployment). +/// The character name of the party master, if the player is in a party. +/// The number of party members, if the player is in a party. +public record LoggedInAccount(string LoginName, byte Server, string? CharacterName = null, string? PartyMaster = null, int PartySize = 0) : IPartyGroupedAccount; \ No newline at end of file diff --git a/src/Web/Shared/Services/LoggedInAccountService.cs b/src/Web/Shared/Services/LoggedInAccountService.cs index 5afe09c83..4e02036cc 100644 --- a/src/Web/Shared/Services/LoggedInAccountService.cs +++ b/src/Web/Shared/Services/LoggedInAccountService.cs @@ -4,6 +4,8 @@ namespace MUnique.OpenMU.Web.Shared.Services; +using MUnique.OpenMU.GameLogic; +using MUnique.OpenMU.GameLogic.Offline; using MUnique.OpenMU.Interfaces; /// @@ -50,11 +52,52 @@ public async Task SetAccountOfflineAsync(LoggedInAccount account) public async Task> GetAsync(int offset, int count) { var snapshot = await this._loginServer.GetSnapshotAsync().ConfigureAwait(false); + var playerLookup = await this.GetPlayerLookupAsync().ConfigureAwait(false); return snapshot - .Select(entry => new LoggedInAccount(entry.Key, entry.Value)) - .OrderBy(e => e.LoginName) + .Select(entry => + { + if (playerLookup.TryGetValue(entry.Key, out var playerInfo)) + { + return new LoggedInAccount(entry.Key, entry.Value, playerInfo.CharacterName, playerInfo.PartyMaster, playerInfo.PartySize); + } + + return new LoggedInAccount(entry.Key, entry.Value); + }) + .OrderPartyGrouped() .Skip(offset) .Take(count) .ToList(); } + + /// + /// Builds a lookup of account login name to character and party info from the in-process game servers. + /// Empty when the servers run in another process (distributed deployment). + /// + private async Task> GetPlayerLookupAsync() + { + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var context in this._serverProvider.Servers.OfType().Select(s => s.Context)) + { + var players = await context.GetPlayersAsync().ConfigureAwait(false); + foreach (var player in players) + { + if (player is OfflinePlayer) + { + continue; // offline sessions and bots are shown on their own tabs. + } + + var loginName = player.Account?.LoginName; + if (string.IsNullOrEmpty(loginName) || result.ContainsKey(loginName)) + { + continue; + } + + result[loginName] = new PlayerInfo(player.SelectedCharacter?.Name, player.Party?.PartyMaster?.Name, player.Party?.PartyList.Count ?? 0); + } + } + + return result; + } + + private sealed record PlayerInfo(string? CharacterName, string? PartyMaster, int PartySize); } \ No newline at end of file diff --git a/src/Web/Shared/Services/OfflineAccount.cs b/src/Web/Shared/Services/OfflineAccount.cs index 5785abdb3..0fb28c7be 100644 --- a/src/Web/Shared/Services/OfflineAccount.cs +++ b/src/Web/Shared/Services/OfflineAccount.cs @@ -7,4 +7,10 @@ namespace MUnique.OpenMU.Web.Shared.Services; /// /// Keeps the displayed data of an active offline session. /// -public record OfflineAccount(string LoginName, byte ServerId, DateTime StartedAt); +/// The account login name. +/// The server identifier. +/// The start timestamp of the offline session. +/// The character which keeps leveling. +/// The character name of the party master, if the player is in a party. +/// The number of party members, if the player is in a party. +public record OfflineAccount(string LoginName, byte ServerId, DateTime StartedAt, string? CharacterName = null, string? PartyMaster = null, int PartySize = 0) : IPartyGroupedAccount; diff --git a/src/Web/Shared/Services/OfflineAccountService.cs b/src/Web/Shared/Services/OfflineAccountService.cs index 32244ef0b..898fcf15f 100644 --- a/src/Web/Shared/Services/OfflineAccountService.cs +++ b/src/Web/Shared/Services/OfflineAccountService.cs @@ -5,6 +5,7 @@ namespace MUnique.OpenMU.Web.Shared.Services; using MUnique.OpenMU.GameLogic; +using MUnique.OpenMU.GameLogic.PlugIns.ChatCommands; using MUnique.OpenMU.Interfaces; /// @@ -43,9 +44,20 @@ public async Task StopOfflinePlayerAsync(OfflineAccount account) this.DataChanged?.Invoke(this, EventArgs.Empty); } + /// + /// Determines whether the /offlevel chat command plugin is active on any in-process game server. + /// + public bool IsOfflevelFeatureAvailable() + { + return this._serverProvider.Servers + .OfType() + .Any(s => s.Context.PlugInManager.IsPlugInActive(typeof(OfflineLevelingChatCommandPlugIn))); + } + /// public Task> GetAsync(int offset, int count) { + // Note: bots never show up here - they are managed by the BotManager, not the OfflinePlayerManager. var result = this._serverProvider.Servers .OfType() .SelectMany(s => s.Context.OfflinePlayerManager @@ -53,8 +65,11 @@ public Task> GetAsync(int offset, int count) .Select(p => new OfflineAccount( p.AccountLoginName ?? string.Empty, (byte)((IManageableServer)s).Id, - p.StartTimestamp))) - .OrderBy(a => a.LoginName) + p.StartTimestamp, + p.SelectedCharacter?.Name, + p.Party?.PartyMaster?.Name, + p.Party?.PartyList.Count ?? 0))) + .OrderPartyGrouped() .Skip(offset) .Take(count) .ToList(); diff --git a/src/Web/Shared/Services/OnlineAccountOrdering.cs b/src/Web/Shared/Services/OnlineAccountOrdering.cs new file mode 100644 index 000000000..e5b13f510 --- /dev/null +++ b/src/Web/Shared/Services/OnlineAccountOrdering.cs @@ -0,0 +1,28 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Web.Shared.Services; + +/// +/// Shared ordering for the online-accounts tables: partied players first so each party's +/// members stay contiguous, then by party master, account and character. +/// +public static class OnlineAccountOrdering +{ + /// + /// Orders the accounts so party members stay contiguous. + /// + /// The account type. + /// The accounts to order. + /// The ordered accounts. + public static IOrderedEnumerable OrderPartyGrouped(this IEnumerable accounts) + where T : IPartyGroupedAccount + { + return accounts + .OrderByDescending(a => a.PartyMaster is not null) + .ThenBy(a => a.PartyMaster) + .ThenBy(a => a.LoginName, StringComparer.OrdinalIgnoreCase) + .ThenBy(a => a.CharacterName, StringComparer.OrdinalIgnoreCase); + } +} diff --git a/tests/MUnique.OpenMU.Web.Tests/NetworkAnalyzer/NetworkAnalyzerLinkTests.cs b/tests/MUnique.OpenMU.Web.Tests/NetworkAnalyzer/NetworkAnalyzerLinkTests.cs index c05e82bb7..879450afc 100644 --- a/tests/MUnique.OpenMU.Web.Tests/NetworkAnalyzer/NetworkAnalyzerLinkTests.cs +++ b/tests/MUnique.OpenMU.Web.Tests/NetworkAnalyzer/NetworkAnalyzerLinkTests.cs @@ -57,9 +57,11 @@ private static BunitContext CreateContext(IPacketCaptureService? captureService context.Services.AddSingleton(); context.Services.AddSingleton(new LoggedInAccountService(Mock.Of(), serverProvider.Object)); context.Services.AddSingleton(new OfflineAccountService(serverProvider.Object)); + context.Services.AddSingleton(new BotAccountService(serverProvider.Object)); context.Services.AddSingleton>( new TestDataService([new LoggedInAccount("Test Account", 3)])); context.Services.AddSingleton>(new TestDataService([])); + context.Services.AddSingleton>(new TestDataService([])); if (captureService is not null) { context.Services.AddSingleton(captureService); diff --git a/tests/MUnique.OpenMU.Web.Tests/OnlineAccounts/OnlineAccountOrderingTests.cs b/tests/MUnique.OpenMU.Web.Tests/OnlineAccounts/OnlineAccountOrderingTests.cs new file mode 100644 index 000000000..23a41b565 --- /dev/null +++ b/tests/MUnique.OpenMU.Web.Tests/OnlineAccounts/OnlineAccountOrderingTests.cs @@ -0,0 +1,71 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Web.Tests.OnlineAccounts; + +using MUnique.OpenMU.Web.Shared.Services; + +/// +/// Tests for the shared party-grouped ordering of the online-accounts tables. +/// +[TestFixture] +public class OnlineAccountOrderingTests +{ + private static readonly DateTime TestTimestamp = new(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc); + + /// + /// Partied players come first and party members stay contiguous, ordered by master then login. + /// + [Test] + public void PartiedAccountsComeFirst_MembersStayContiguous() + { + var accounts = new List + { + new("zSolo", 1), + new("bMember", 1, "CharB", "Master", 2), + new("aMember", 1, "CharA", "Master", 2), + new("aSolo", 1), + }; + + var result = accounts.OrderPartyGrouped().Select(a => a.LoginName).ToList(); + + Assert.That(result, Is.EqualTo(new[] { "aMember", "bMember", "aSolo", "zSolo" })); + } + + /// + /// Multiple parties are grouped by their master. + /// + [Test] + public void MultipleParties_GroupedByMaster() + { + var accounts = new List + { + new("solo", 1, TestTimestamp), + new("m2b", 1, TestTimestamp, "C2", "ZMaster", 2), + new("m1a", 1, TestTimestamp, "C1", "AMaster", 2), + new("m2a", 1, TestTimestamp, "C3", "ZMaster", 2), + }; + + var result = accounts.OrderPartyGrouped().Select(a => a.LoginName).ToList(); + + Assert.That(result, Is.EqualTo(new[] { "m1a", "m2a", "m2b", "solo" })); + } + + /// + /// Ordering is case-insensitive. + /// + [Test] + public void Ordering_IsCaseInsensitive() + { + var accounts = new List + { + new("Bravo", 1, "Char", TestTimestamp), + new("alpha", 1, "Char", TestTimestamp), + }; + + var result = accounts.OrderPartyGrouped().Select(a => a.LoginName).ToList(); + + Assert.That(result, Is.EqualTo(new[] { "alpha", "Bravo" })); + } +} diff --git a/tests/MUnique.OpenMU.Web.Tests/OnlineAccounts/OnlineAccountServiceTests.cs b/tests/MUnique.OpenMU.Web.Tests/OnlineAccounts/OnlineAccountServiceTests.cs new file mode 100644 index 000000000..7ea663f20 --- /dev/null +++ b/tests/MUnique.OpenMU.Web.Tests/OnlineAccounts/OnlineAccountServiceTests.cs @@ -0,0 +1,84 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Web.Tests.OnlineAccounts; + +using Moq; +using MUnique.OpenMU.Interfaces; +using MUnique.OpenMU.Web.Shared.Services; + +/// +/// Tests for the online-accounts data services in a distributed deployment, +/// where no game server runs in the same process. +/// +[TestFixture] +public class OnlineAccountServiceTests +{ + /// + /// Without in-process game servers no character can be resolved (distributed fallback), + /// but accounts are still listed ordered by login name. + /// + [Test] + public async Task LoggedInAccounts_WithoutInProcessServers_HaveNoCharacter() + { + var loginServer = new Mock(); + loginServer.Setup(s => s.GetSnapshotAsync()).Returns( + new ValueTask>(new Dictionary { ["bUser"] = 1, ["aUser"] = 2 })); + var service = new LoggedInAccountService(loginServer.Object, EmptyServerProvider()); + + var result = await service.GetAsync(0, 20); + + Assert.That(result.Select(a => a.LoginName), Is.EqualTo(new[] { "aUser", "bUser" })); + Assert.That(result.All(a => a.CharacterName is null), Is.True); + } + + /// + /// Pagination applies after ordering. + /// + [Test] + public async Task LoggedInAccounts_PaginationAppliesAfterOrdering() + { + var loginServer = new Mock(); + loginServer.Setup(s => s.GetSnapshotAsync()).Returns( + new ValueTask>(new Dictionary { ["cUser"] = 1, ["bUser"] = 1, ["aUser"] = 1 })); + var service = new LoggedInAccountService(loginServer.Object, EmptyServerProvider()); + + var result = await service.GetAsync(1, 1); + + Assert.That(result.Select(a => a.LoginName), Is.EqualTo(new[] { "bUser" })); + } + + /// + /// Without in-process game servers there are no offline sessions to list + /// and the off-level tab stays hidden. + /// + [Test] + public async Task OfflineAccounts_WithoutInProcessServers_AreEmpty() + { + var service = new OfflineAccountService(EmptyServerProvider()); + + Assert.That(await service.GetAsync(0, 20), Is.Empty); + Assert.That(service.IsOfflevelFeatureAvailable(), Is.False); + } + + /// + /// Without in-process game servers there are no bots to list + /// and the bot tab stays hidden. + /// + [Test] + public async Task BotAccounts_WithoutInProcessServers_AreEmpty() + { + var service = new BotAccountService(EmptyServerProvider()); + + Assert.That(await service.GetAsync(0, 20), Is.Empty); + Assert.That(service.IsBotFeatureAvailable(), Is.False); + } + + private static IServerProvider EmptyServerProvider() + { + var provider = new Mock(); + provider.Setup(p => p.Servers).Returns(new List()); + return provider.Object; + } +}