diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index 94383d0c9b..28d7c55138 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -20,6 +20,7 @@ + diff --git a/src/GameLogic/PlayerActions/Guild/GuildListRequestAction.cs b/src/GameLogic/PlayerActions/Guild/GuildListRequestAction.cs index 6ead462966..1aed0dbf3a 100644 --- a/src/GameLogic/PlayerActions/Guild/GuildListRequestAction.cs +++ b/src/GameLogic/PlayerActions/Guild/GuildListRequestAction.cs @@ -5,6 +5,7 @@ namespace MUnique.OpenMU.GameLogic.PlayerActions.Guild; using MUnique.OpenMU.GameLogic.Views.Guild; +using MUnique.OpenMU.Interfaces; /// /// Action to request the guild list. @@ -26,7 +27,12 @@ public async ValueTask RequestGuildListAsync(Player player) if ((player.GameContext as IGameServerContext)?.GuildServer is { } guildServer && await guildServer.GetGuildAsync(player.GuildStatus.GuildId).ConfigureAwait(false) is { } guild) { - var players = await guildServer.GetGuildListAsync(player.GuildStatus.GuildId).ConfigureAwait(false); + // The client displays the members in the received order, so we sort by rank + // (master, assistant, battle master, normal members) and then by name. + var players = (await guildServer.GetGuildListAsync(player.GuildStatus.GuildId).ConfigureAwait(false)) + .OrderBy(member => member.PlayerPosition, GuildPositionComparer.Instance) + .ThenBy(member => member.PlayerName, StringComparer.OrdinalIgnoreCase) + .ToList(); await player.InvokeViewPlugInAsync(p => p.ShowGuildListAsync(players, guild)).ConfigureAwait(false); } } diff --git a/src/GameLogic/PlayerActions/Guild/GuildRelationshipChangeAction.cs b/src/GameLogic/PlayerActions/Guild/GuildRelationshipChangeAction.cs index c22d8a47b9..2af967f148 100644 --- a/src/GameLogic/PlayerActions/Guild/GuildRelationshipChangeAction.cs +++ b/src/GameLogic/PlayerActions/Guild/GuildRelationshipChangeAction.cs @@ -24,12 +24,14 @@ public class GuildRelationshipChangeAction /// The type of request (Join or Leave). public async ValueTask RequestAsync(Player player, ushort targetPlayerId, GuildRelationshipType relationshipType, GuildRelationshipRequestType requestType) { - var (success, (sourceGuildId, serverContext, sourceGuild)) = await this.CommonChecksAsync(player, targetPlayerId, relationshipType, requestType).ConfigureAwait(false); - if (!success) + var (success, guildData) = await this.CommonChecksAsync(player, targetPlayerId, relationshipType, requestType).ConfigureAwait(false); + if (!success || guildData is null) { return; } + var (sourceGuildId, serverContext, sourceGuild) = guildData; + // Find the target player var targetPlayer = await player.GetObservingPlayerWithIdAsync(targetPlayerId).ConfigureAwait(false); if (targetPlayer?.GuildStatus is not { } targetGuildStatus @@ -114,12 +116,14 @@ await targetPlayer.InvokeViewPlugInAsync(p /// The name of the guild which should be removed. If , then the own guild should be removed. public async ValueTask RequestLeaveAllianceAsync(Player player, string? targetGuildName = null) { - var (success, (sourceGuildId, serverContext, sourceGuild)) = await this.CommonChecksAsync(player, 0, GuildRelationshipType.Alliance, GuildRelationshipRequestType.Leave).ConfigureAwait(false); - if (!success) + var (success, guildData) = await this.CommonChecksAsync(player, 0, GuildRelationshipType.Alliance, GuildRelationshipRequestType.Leave).ConfigureAwait(false); + if (!success || guildData is null) { return; } + var (sourceGuildId, serverContext, sourceGuild) = guildData; + var targetGuildId = sourceGuildId; var leaveWithOwnGuild = string.IsNullOrEmpty(targetGuildName) || sourceGuild.Name == targetGuildName; if (!leaveWithOwnGuild) @@ -192,33 +196,33 @@ await serverContext.GuildServer.CreateAllianceAsync(requesterGuildStatus.GuildId await player.InvokeViewPlugInAsync(p => p.ShowResultAsync(relationshipType, requestType, res, guildMasterId)).ConfigureAwait(false); } - private async ValueTask<(bool Success, GuildData GuildData)> CommonChecksAsync(Player player, ushort? targetPlayerId, GuildRelationshipType relationshipType, GuildRelationshipRequestType requestType) + private async ValueTask<(bool Success, GuildData? GuildData)> CommonChecksAsync(Player player, ushort? targetPlayerId, GuildRelationshipType relationshipType, GuildRelationshipRequestType requestType) { if (player.PendingAllianceRequest != default) { // There is already a pending request, so we cannot process another one at the moment. This can happen with multiple requests from different players. await player.InvokeViewPlugInAsync(p => p.ShowResultAsync(relationshipType, requestType, GuildRelationshipChangeResultType.RequestCancelled, targetPlayerId)).ConfigureAwait(false); - return (false, null!); + return (false, null); } if (player.GuildStatus is not { } guildStatus || player.GameContext is not IGameServerContext serverContext) { await player.InvokeViewPlugInAsync(p => p.ShowResultAsync(relationshipType, requestType, GuildRelationshipChangeResultType.Failed, targetPlayerId)).ConfigureAwait(false); - return (false, null!); + return (false, null); } if (guildStatus.Position != GuildPosition.GuildMaster) { await player.InvokeViewPlugInAsync(p => p.ShowResultAsync(relationshipType, requestType, GuildRelationshipChangeResultType.NoAuthorization, targetPlayerId)).ConfigureAwait(false); - return (false, null!); + return (false, null); } var sourceGuild = await serverContext.GuildServer.GetGuildAsync(guildStatus.GuildId).ConfigureAwait(false); if (sourceGuild is null) { await player.InvokeViewPlugInAsync(p => p.ShowResultAsync(relationshipType, requestType, GuildRelationshipChangeResultType.GuildNotFound, targetPlayerId)).ConfigureAwait(false); - return (false, null!); + return (false, null); } return (true, new(guildStatus.GuildId, serverContext, sourceGuild)); diff --git a/src/GameLogic/PlayerActions/Guild/GuildRoleAssignAction.cs b/src/GameLogic/PlayerActions/Guild/GuildRoleAssignAction.cs new file mode 100644 index 0000000000..722085791a --- /dev/null +++ b/src/GameLogic/PlayerActions/Guild/GuildRoleAssignAction.cs @@ -0,0 +1,101 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.PlayerActions.Guild; + +using MUnique.OpenMU.Interfaces; + +/// +/// Action to assign a role (assistant master, battle master, normal member) to a guild member. +/// Only the guild master may assign roles. Leadership transfer is not supported here. +/// +public class GuildRoleAssignAction +{ + /// + /// Assigns the specified role to the guild member with the specified nickname. + /// + /// The requesting player. Must be the guild master. + /// The nickname of the target guild member. Must be online on the same game server and in the same guild. + /// The new position. Only , and are accepted. + /// + /// Failures are only logged; no dedicated client response packet exists for role assignment. + /// On success, the guild server publishes the change which updates the target's guild status and views. + /// + public async ValueTask AssignRoleAsync(Player player, string nickname, GuildPosition newPosition) + { + using var loggerScope = player.Logger.BeginScope(this.GetType()); + if (player.PlayerState.CurrentState != PlayerState.EnteredWorld) + { + player.Logger.LogError($"Account {player.Account?.LoginName} not in the right state, but {player.PlayerState.CurrentState}."); + return; + } + + var guildStatus = player.GuildStatus; + if (guildStatus is null) + { + player.Logger.LogError($"Player {player} not in a guild."); + return; + } + + // The fixed-size name field may be space-padded by the client. + var targetName = nickname.Trim(); + if (string.IsNullOrEmpty(targetName)) + { + player.Logger.LogWarning("Rejected guild role assignment of player {PlayerName}: empty target name.", player.Name); + return; + } + + if (newPosition is not (GuildPosition.NormalMember or GuildPosition.BattleMaster or GuildPosition.AssistantMaster)) + { + player.Logger.LogWarning("Rejected guild role assignment of player {PlayerName} to {TargetName}: invalid position {Position}.", player.Name, targetName, newPosition); + return; + } + + if (guildStatus.Position != GuildPosition.GuildMaster) + { + player.Logger.LogWarning("Suspicious role assign request for player with name: {PlayerName} (player is not a guild master) to assign {TargetName}.", player.Name, targetName); + return; + } + + var guildServer = (player.GameContext as IGameServerContext)?.GuildServer; + if (guildServer is null) + { + player.Logger.LogWarning("No guild server available"); + return; + } + + var target = player.GameContext.GetPlayerByCharacterName(targetName); + if (target?.SelectedCharacter is null) + { + player.Logger.LogWarning("Rejected guild role assignment: target {TargetName} is not online on this server.", targetName); + return; + } + + if (target.SelectedCharacter.Id == player.SelectedCharacter?.Id) + { + player.Logger.LogWarning("Rejected guild role assignment: guild master {PlayerName} cannot change its own role.", player.Name); + return; + } + + if (target.GuildStatus?.GuildId != guildStatus.GuildId) + { + player.Logger.LogWarning("Rejected guild role assignment: target {TargetName} is not in the same guild.", targetName); + return; + } + + if (target.GuildStatus.Position == GuildPosition.GuildMaster) + { + player.Logger.LogWarning("Rejected guild role assignment: target {TargetName} is the guild master, leadership transfer is not supported.", targetName); + return; + } + + if (target.GuildStatus.Position == newPosition) + { + player.Logger.LogDebug("Guild role assignment skipped: target {TargetName} already has position {Position}.", targetName, newPosition); + return; + } + + await guildServer.ChangeGuildMemberPositionAsync(guildStatus.GuildId, target.SelectedCharacter.Id, newPosition).ConfigureAwait(false); + } +} diff --git a/src/GameServer/GameServerContext.cs b/src/GameServer/GameServerContext.cs index 281447e70e..46ee28ee25 100644 --- a/src/GameServer/GameServerContext.cs +++ b/src/GameServer/GameServerContext.cs @@ -222,7 +222,13 @@ public async ValueTask RegisterGuildMemberAsync(Player guildMember) var guildId = guildMember.GuildStatus.GuildId; var guildList = this._playersByGuild.GetOrAdd(guildId, id => new LockableList()); using var writeLock = await guildList.Lock.WriterLockAsync(); - guildList.Add(guildMember); + + // Membership changes (e.g. role assignments) re-publish the guild assignment, + // so registration must be idempotent to avoid duplicate broadcasts and stale entries. + if (!guildList.Contains(guildMember)) + { + guildList.Add(guildMember); + } } /// diff --git a/src/GameServer/MessageHandler/Guild/GuildRoleAssignHandlerPlugIn.cs b/src/GameServer/MessageHandler/Guild/GuildRoleAssignHandlerPlugIn.cs new file mode 100644 index 0000000000..01692ea8a6 --- /dev/null +++ b/src/GameServer/MessageHandler/Guild/GuildRoleAssignHandlerPlugIn.cs @@ -0,0 +1,50 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameServer.MessageHandler.Guild; + +using System.Runtime.InteropServices; +using Microsoft.Extensions.Logging; +using MUnique.OpenMU.GameLogic; +using MUnique.OpenMU.GameLogic.PlayerActions.Guild; +using MUnique.OpenMU.GameServer.RemoteView.Guild; +using MUnique.OpenMU.Network.Packets.ClientToServer; +using MUnique.OpenMU.PlugIns; + +/// +/// Handler for guild role assign packets. +/// +/// +/// The request's Type byte semantics (values 1..3) are undocumented, so it is +/// intentionally ignored. No dedicated server response packet exists; on success the +/// guild server publishes the change which updates the member's guild status and views. +/// +[PlugIn] +[Display(Name = nameof(PlugInResources.GuildRoleAssignHandlerPlugIn_Name), Description = nameof(PlugInResources.GuildRoleAssignHandlerPlugIn_Description), ResourceType = typeof(PlugInResources))] +[Guid("76191DD2-AFC3-4FFF-8CB0-BB8DD1641B15")] +internal class GuildRoleAssignHandlerPlugIn : IPacketHandlerPlugIn +{ + private readonly GuildRoleAssignAction _roleAssignAction = new(); + + /// + public bool IsEncryptionExpected => false; + + /// + public byte Key => GuildRoleAssignRequest.Code; + + /// + public async ValueTask HandlePacketAsync(Player player, Memory packet) + { + GuildRoleAssignRequest request = packet; + var position = request.Role.ConvertToPosition(); + + if (position is null) + { + player.Logger.LogWarning("Rejected guild role assignment: invalid role {Role} for target {TargetName}, could be hack attempt.", request.Role, request.PlayerName); + return; + } + + await this._roleAssignAction.AssignRoleAsync(player, request.PlayerName, position.Value).ConfigureAwait(false); + } +} diff --git a/src/GameServer/Properties/PlugInResources.Designer.cs b/src/GameServer/Properties/PlugInResources.Designer.cs index 7e767d042f..0c183cbd51 100644 --- a/src/GameServer/Properties/PlugInResources.Designer.cs +++ b/src/GameServer/Properties/PlugInResources.Designer.cs @@ -2724,6 +2724,24 @@ public static string GuildRequestHandlerPlugIn_Name { } } + /// + /// Looks up a localized string similar to Handler for guild role assign packets.. + /// + public static string GuildRoleAssignHandlerPlugIn_Description { + get { + return ResourceManager.GetString("GuildRoleAssignHandlerPlugIn_Description", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Guild Role Assign Handler. + /// + public static string GuildRoleAssignHandlerPlugIn_Name { + get { + return ResourceManager.GetString("GuildRoleAssignHandlerPlugIn_Name", resourceCulture); + } + } + /// /// Looks up a localized string similar to Handler for guild war response packets.. /// diff --git a/src/GameServer/Properties/PlugInResources.resx b/src/GameServer/Properties/PlugInResources.resx index e9361de3f4..3c861aa9c7 100644 --- a/src/GameServer/Properties/PlugInResources.resx +++ b/src/GameServer/Properties/PlugInResources.resx @@ -405,6 +405,12 @@ Handler for guild requests. + + Guild Role Assign Handler + + + Handler for guild role assign packets. + Guild War Response Handler diff --git a/src/GameServer/RemoteView/Guild/EnumExtensions.cs b/src/GameServer/RemoteView/Guild/EnumExtensions.cs index ec1d024c5d..ac22d23b01 100644 --- a/src/GameServer/RemoteView/Guild/EnumExtensions.cs +++ b/src/GameServer/RemoteView/Guild/EnumExtensions.cs @@ -46,6 +46,23 @@ public static GuildMemberRole Convert(this GuildPosition playerPosition) }; } + /// + /// Converts a wire into a . + /// This is the inverse of . + /// + /// The role from the client message. + /// The position, or null for roles which cannot be assigned, such as guild master. + public static GuildPosition? ConvertToPosition(this GuildMemberRole role) + { + return role switch + { + GuildMemberRole.NormalMember => GuildPosition.NormalMember, + GuildMemberRole.BattleMaster => GuildPosition.BattleMaster, + GuildMemberRole.AssistantMaster => GuildPosition.AssistantMaster, + _ => null, + }; + } + /// /// Converts the into a . /// diff --git a/src/GuildServer/GuildServer.cs b/src/GuildServer/GuildServer.cs index 5dc8ac5402..ab9a00dd6c 100644 --- a/src/GuildServer/GuildServer.cs +++ b/src/GuildServer/GuildServer.cs @@ -206,17 +206,34 @@ public async ValueTask ChangeGuildMemberPositionAsync(uint guildId, Guid charact { try { - if (this._guildDictionary.TryGetValue(guildId, out var guild)) + if (!this._guildDictionary.TryGetValue(guildId, out var guild)) { - var guildMember = guild.Guild.Members.FirstOrDefault(m => m.Id == characterId); - if (guildMember != null) - { - guildMember.Status = role; - await guild.DatabaseContext.SaveChangesAsync().ConfigureAwait(false); - var listEntry = guild.Members[characterId]; - listEntry.PlayerPosition = role; - } + this._logger.LogWarning("Guild {GuildId} not found, so the position of member {CharacterId} can't be changed.", guildId, characterId); + return; } + + var guildMember = guild.Guild.Members.FirstOrDefault(m => m.Id == characterId); + if (guildMember is null) + { + this._logger.LogWarning("Guild {GuildId} member {CharacterId} not found, so its position can't be changed.", guildId, characterId); + return; + } + + guildMember.Status = role; + await guild.DatabaseContext.SaveChangesAsync().ConfigureAwait(false); + if (guild.Members.TryGetValue(characterId, out var listEntry)) + { + listEntry.PlayerPosition = role; + + // Offline members keep their cached name while their server id is + // OfflineServerId; publishing to them is pointless (dropped or, over + // Dapr, an error on every call). They pick up the persisted position + // on next login through PlayerEnteredGameAsync. + if (listEntry.PlayerName is not null && listEntry.ServerId != OfflineServerId) + { + await this._changePublisher.AssignGuildToPlayerAsync(listEntry.ServerId, listEntry.PlayerName, new GuildMemberStatus(guildId, role)).ConfigureAwait(false); + } + } } catch (Exception ex) { diff --git a/src/Interfaces/GuildPositionComparer.cs b/src/Interfaces/GuildPositionComparer.cs new file mode 100644 index 0000000000..d1aef4876a --- /dev/null +++ b/src/Interfaces/GuildPositionComparer.cs @@ -0,0 +1,74 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Interfaces; + +/// +/// Comparer for and , ordering by hierarchy: +/// guild master, assistant master, battle master, normal members, anything else; ties are broken by name. +/// +public sealed class GuildPositionComparer : IComparer, IComparer +{ + /// + /// Initializes a new instance of the class. + /// + private GuildPositionComparer() + { + } + + /// + /// Gets the shared instance of the . + /// + public static GuildPositionComparer Instance { get; } = new(); + + /// + /// Gets the numerical rank for ordering purposes. A lower value means a higher rank. + /// + /// The guild position. + /// The rank of the position. + public static int GetRank(GuildPosition position) + { + return position switch + { + GuildPosition.GuildMaster => 0, + GuildPosition.AssistantMaster => 1, + GuildPosition.BattleMaster => 2, + GuildPosition.NormalMember => 3, + _ => 4, + }; + } + + /// + public int Compare(GuildPosition x, GuildPosition y) + { + return GetRank(x).CompareTo(GetRank(y)); + } + + /// + public int Compare(GuildListEntry? x, GuildListEntry? y) + { + if (ReferenceEquals(x, y)) + { + return 0; + } + + if (x is null) + { + return -1; + } + + if (y is null) + { + return 1; + } + + var rankComparison = this.Compare(x.PlayerPosition, y.PlayerPosition); + if (rankComparison != 0) + { + return rankComparison; + } + + return StringComparer.OrdinalIgnoreCase.Compare(x.PlayerName, y.PlayerName); + } +} 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 @@