@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;
+ }
+}