From 1974dfdd8b74f5183ed414450f0c8598c8a77fa7 Mon Sep 17 00:00:00 2001 From: Eduardo <6845999+eduardosmaniotto@users.noreply.github.com> Date: Thu, 10 Sep 2026 18:29:27 -0300 Subject: [PATCH 01/10] Add guild, alliance and guild list pages to the admin panel Adds three new admin panel pages backed by a new GuildService: - /guilds: paginated, searchable list of all guilds, including logo, member count, score and alliance. - /guild/{id}: guild detail page with its members (name, account, class, level, master level, position), ordered by rank. - /alliance/{id}: lists all guilds belonging to an alliance, reachable from either a member guild or the alliance master. Guild data is read from the guild context; member enrichment with character/account data (which lives in a separate player context) is handled by a dedicated IGuildMemberEnricher so the two concerns don't mix. Listing and search are pushed down to the persistence layer via new IGuildServerContext methods (GetGuildsOrderedByNameAsync, SearchGuildsAsync, GetAllianceMasterIdsAsync) instead of loading the whole guild table into memory. Also adds an 8x8 16-color GuildLogo component to render the game's native guild logo bitmap format, and an AssistantMaster guild position caption/resource that was missing from ModelResources. Includes unit tests for GuildService (listing, search, pagination fallback, member ordering, alliance resolution, enrichment) using the in-memory persistence provider. --- src/Interfaces/Properties/ModelResources.resx | 8 +- .../EntityFramework/GuildServerContext.cs | 50 ++- src/Persistence/IGuildServerContext.cs | 30 +- .../InMemory/GuildServerInMemoryContext.cs | 39 +- .../Components/Layout/NavMenu.razor | 17 +- src/Web/AdminPanel/Pages/Alliance.razor | 54 +++ src/Web/AdminPanel/Pages/Alliance.razor.cs | 45 ++ src/Web/AdminPanel/Pages/Guild.razor | 101 +++++ src/Web/AdminPanel/Pages/Guild.razor.cs | 52 +++ src/Web/AdminPanel/Pages/Guilds.razor | 48 ++ .../Properties/Resources.Designer.cs | 146 +++++- src/Web/AdminPanel/Properties/Resources.resx | 50 ++- src/Web/AdminPanel/Startup.cs | 4 + .../AdminPanel/WebApplicationExtensions.cs | 4 + src/Web/Shared/Components/GuildLogo.razor | 66 +++ src/Web/Shared/Models/AllianceDetails.cs | 21 + src/Web/Shared/Models/GuildListItem.cs | 54 +++ src/Web/Shared/Models/GuildMemberViewItem.cs | 54 +++ .../Shared/Models/GuildPositionComparer.cs | 68 +++ .../Services/CharacterGuildMemberEnricher.cs | 132 ++++++ src/Web/Shared/Services/GuildService.cs | 273 ++++++++++++ .../Shared/Services/IGuildMemberEnricher.cs | 22 + src/Web/Shared/Services/IGuildService.cs | 34 ++ .../ModelResourcesTest.cs | 16 + .../Guilds/GuildServiceTests.cs | 420 ++++++++++++++++++ 25 files changed, 1797 insertions(+), 11 deletions(-) create mode 100644 src/Web/AdminPanel/Pages/Alliance.razor create mode 100644 src/Web/AdminPanel/Pages/Alliance.razor.cs create mode 100644 src/Web/AdminPanel/Pages/Guild.razor create mode 100644 src/Web/AdminPanel/Pages/Guild.razor.cs create mode 100644 src/Web/AdminPanel/Pages/Guilds.razor create mode 100644 src/Web/Shared/Components/GuildLogo.razor create mode 100644 src/Web/Shared/Models/AllianceDetails.cs create mode 100644 src/Web/Shared/Models/GuildListItem.cs create mode 100644 src/Web/Shared/Models/GuildMemberViewItem.cs create mode 100644 src/Web/Shared/Models/GuildPositionComparer.cs create mode 100644 src/Web/Shared/Services/CharacterGuildMemberEnricher.cs create mode 100644 src/Web/Shared/Services/GuildService.cs create mode 100644 src/Web/Shared/Services/IGuildMemberEnricher.cs create mode 100644 src/Web/Shared/Services/IGuildService.cs create mode 100644 tests/MUnique.OpenMU.Web.Tests/Guilds/GuildServiceTests.cs diff --git a/src/Interfaces/Properties/ModelResources.resx b/src/Interfaces/Properties/ModelResources.resx index c2d2b3eb16..c0f8545de8 100644 --- a/src/Interfaces/Properties/ModelResources.resx +++ b/src/Interfaces/Properties/ModelResources.resx @@ -1,4 +1,4 @@ - + text/microsoft-resx @@ -328,6 +328,12 @@ + + Assistant Master + + + + Localizable Exception Base diff --git a/src/Persistence/EntityFramework/GuildServerContext.cs b/src/Persistence/EntityFramework/GuildServerContext.cs index eb895bf44f..2e2aae3590 100644 --- a/src/Persistence/EntityFramework/GuildServerContext.cs +++ b/src/Persistence/EntityFramework/GuildServerContext.cs @@ -1,9 +1,10 @@ -// +// // Licensed under the MIT License. See LICENSE file in the project root for full license information. // namespace MUnique.OpenMU.Persistence.EntityFramework; +using System.Threading; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using MUnique.OpenMU.Persistence.EntityFramework.Model; @@ -68,4 +69,51 @@ join character in this.Context.Set() on member.Id equals characte .Include(g => g.RawMembers) .ToListAsync().ConfigureAwait(false); } + + /// + public async ValueTask> GetGuildsOrderedByNameAsync(int skip, int count, CancellationToken cancellationToken = default) + { + return await this.Context.Set() + .AsNoTracking() + .Include(g => g.RawMembers) + .Include(g => g.RawAllianceGuild) + .OrderBy(g => g.Name) + .Skip(skip) + .Take(count) + .ToListAsync(cancellationToken).ConfigureAwait(false); + } + + /// + public async ValueTask> SearchGuildsAsync(string searchTerm, int skip, int count, CancellationToken cancellationToken = default) + { + // Invariant: this runs in .NET, so it must not depend on the server's locale (see the + // equivalent remark in PlayerContext.SearchAccountsAsync). The ToLower() calls below are + // translated to the database's own lower(), which is why they cannot take a culture. + var term = searchTerm.ToLowerInvariant(); + return await this.Context.Set() + .AsNoTracking() + .Include(g => g.RawMembers) + .Include(g => g.RawAllianceGuild) + .Where(g => g.Name != null && g.Name.ToLower().Contains(term)) + .OrderBy(g => g.Name) + .Skip(skip) + .Take(count) + .ToListAsync(cancellationToken).ConfigureAwait(false); + } + + /// + public async ValueTask> GetAllianceMasterIdsAsync(IReadOnlyCollection guildIds) + { + if (guildIds.Count == 0) + { + return []; + } + + return await this.Context.Set() + .AsNoTracking() + .Where(g => g.AllianceGuildId != null && guildIds.Contains(g.AllianceGuildId!.Value)) + .Select(g => g.AllianceGuildId!.Value) + .Distinct() + .ToListAsync().ConfigureAwait(false); + } } diff --git a/src/Persistence/IGuildServerContext.cs b/src/Persistence/IGuildServerContext.cs index 7e2fd3a758..f0b9f48c54 100644 --- a/src/Persistence/IGuildServerContext.cs +++ b/src/Persistence/IGuildServerContext.cs @@ -1,9 +1,10 @@ -// +// // Licensed under the MIT License. See LICENSE file in the project root for full license information. // namespace MUnique.OpenMU.Persistence; +using System.Threading; using MUnique.OpenMU.Interfaces; /// @@ -46,4 +47,31 @@ public interface IGuildServerContext : IContext /// The guild identifier. /// The ids of the alliances of a guild. ValueTask> GetAlliancesAsync(Guid guildId); + + /// + /// Gets a page of guilds, ordered by name, without loading the whole guild table into memory. + /// + /// The number of guilds to skip. + /// The maximum number of guilds to return. + /// The cancellation token. + /// The requested page of guilds, including alliance and member information. + ValueTask> GetGuildsOrderedByNameAsync(int skip, int count, CancellationToken cancellationToken = default); + + /// + /// Searches guilds by name and returns a page of the matching results, without loading the whole guild table into memory. + /// + /// The case-insensitive search term which is matched against the guild name. + /// The number of matching guilds to skip. + /// The maximum number of guilds to return. + /// The cancellation token. + /// The requested page of matching guilds, including alliance and member information. + ValueTask> SearchGuildsAsync(string searchTerm, int skip, int count, CancellationToken cancellationToken = default); + + /// + /// Of the given guild identifiers, returns the ones which are the master of an alliance + /// (i.e. at least one other guild points to them as their ). + /// + /// The guild identifiers to check. Kept small (e.g. one page) to avoid a full table scan. + /// The subset of which are alliance masters. + ValueTask> GetAllianceMasterIdsAsync(IReadOnlyCollection guildIds); } diff --git a/src/Persistence/InMemory/GuildServerInMemoryContext.cs b/src/Persistence/InMemory/GuildServerInMemoryContext.cs index 581d0c5ff3..d3a17b5fec 100644 --- a/src/Persistence/InMemory/GuildServerInMemoryContext.cs +++ b/src/Persistence/InMemory/GuildServerInMemoryContext.cs @@ -1,9 +1,10 @@ -// +// // Licensed under the MIT License. See LICENSE file in the project root for full license information. // namespace MUnique.OpenMU.Persistence.InMemory; +using System.Threading; using MUnique.OpenMU.Persistence.BasicModel; /// @@ -64,4 +65,40 @@ public async ValueTask> GetMemberNamesAsync(Gu .Where(g => g.AllianceGuild?.GetId() == guildId) .ToList(); } + + /// + public async ValueTask> GetGuildsOrderedByNameAsync(int skip, int count, CancellationToken cancellationToken = default) + { + var allGuilds = await this.Provider.GetRepository().GetAllAsync(cancellationToken).ConfigureAwait(false); + return allGuilds.OrderBy(g => g.Name).Skip(skip).Take(count).ToList(); + } + + /// + public async ValueTask> SearchGuildsAsync(string searchTerm, int skip, int count, CancellationToken cancellationToken = default) + { + var allGuilds = await this.Provider.GetRepository().GetAllAsync(cancellationToken).ConfigureAwait(false); + return allGuilds + .Where(g => g.Name?.Contains(searchTerm, StringComparison.InvariantCultureIgnoreCase) == true) + .OrderBy(g => g.Name) + .Skip(skip) + .Take(count) + .ToList(); + } + + /// + public async ValueTask> GetAllianceMasterIdsAsync(IReadOnlyCollection guildIds) + { + if (guildIds.Count == 0) + { + return []; + } + + var guildIdSet = guildIds.ToHashSet(); + var allGuilds = await this.Provider.GetRepository().GetAllAsync().ConfigureAwait(false); + return allGuilds + .Where(g => g.AllianceGuild is { } master && guildIdSet.Contains(master.GetId())) + .Select(g => g.AllianceGuild!.GetId()) + .Distinct() + .ToList(); + } } diff --git a/src/Web/AdminPanel/Components/Layout/NavMenu.razor b/src/Web/AdminPanel/Components/Layout/NavMenu.razor index 6ad2372d5e..6173143d0a 100644 --- a/src/Web/AdminPanel/Components/Layout/NavMenu.razor +++ b/src/Web/AdminPanel/Components/Layout/NavMenu.razor @@ -49,6 +49,18 @@