Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/Dapr/GuildServer.Host/GuildServerController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,7 @@ public ValueTask ChangeGuildMemberPositionAsync([FromBody] GuildMemberRoleChange
[HttpPost(nameof(IGuildServer.ChangeGuildMemberPositionByNameAsync))]
public ValueTask<bool> ChangeGuildMemberPositionByNameAsync([FromBody] GuildMemberRoleChangeByNameArguments data)
{
return this._guildServer.ChangeGuildMemberPositionByNameAsync(data.GuildId, data.CharacterName, data.NewRole);
return this._guildServer.ChangeGuildMemberPositionByNameAsync(data.GuildId, data.CharacterName, data.NewRole, data.MasterTotalLevel);
}

/// <summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,4 @@ namespace MUnique.OpenMU.ServerClients;
/// <summary>
/// Arguments for a guild member role change by character name.
/// </summary>
public record GuildMemberRoleChangeByNameArguments(uint GuildId, string CharacterName, GuildPosition NewRole);
public record GuildMemberRoleChangeByNameArguments(uint GuildId, string CharacterName, GuildPosition NewRole, int MasterTotalLevel);
4 changes: 2 additions & 2 deletions src/Dapr/ServerClients/GuildServer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -202,11 +202,11 @@ public async ValueTask ChangeGuildMemberPositionAsync(uint guildId, Guid charact
}

/// <inheritdoc />
public async ValueTask<bool> ChangeGuildMemberPositionByNameAsync(uint guildId, string characterName, GuildPosition role)
public async ValueTask<bool> ChangeGuildMemberPositionByNameAsync(uint guildId, string characterName, GuildPosition role, int masterTotalLevel)
{
try
{
return await this._daprClient.InvokeMethodAsync<GuildMemberRoleChangeByNameArguments, bool>(this._targetAppId, nameof(this.ChangeGuildMemberPositionByNameAsync), new GuildMemberRoleChangeByNameArguments(guildId, characterName, role)).ConfigureAwait(false);
return await this._daprClient.InvokeMethodAsync<GuildMemberRoleChangeByNameArguments, bool>(this._targetAppId, nameof(this.ChangeGuildMemberPositionByNameAsync), new GuildMemberRoleChangeByNameArguments(guildId, characterName, role, masterTotalLevel)).ConfigureAwait(false);
}
catch (Exception ex)
{
Expand Down
1 change: 1 addition & 0 deletions src/Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
<PackageVersion Include="Microsoft.EntityFrameworkCore" Version="10.0.2" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.2" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.2" />
<PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="10.0.2" />
<PackageVersion Include="Microsoft.Extensions.Configuration.FileExtensions" Version="10.0.2" />
<PackageVersion Include="Microsoft.Extensions.Configuration.Json" Version="10.0.2" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.2" />
Expand Down
34 changes: 17 additions & 17 deletions src/Interfaces/GuildPositionComparer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,23 +22,6 @@ private GuildPositionComparer()
/// </summary>
public static GuildPositionComparer Instance { get; } = new();

/// <summary>
/// Gets the numerical rank for ordering purposes. A lower value means a higher rank.
/// </summary>
/// <param name="position">The guild position.</param>
/// <returns>The rank of the position.</returns>
private static int GetRank(GuildPosition position)
{
return position switch
{
GuildPosition.GuildMaster => 0,
GuildPosition.AssistantMaster => 1,
GuildPosition.BattleMaster => 2,
GuildPosition.NormalMember => 3,
_ => 4,
};
}

/// <inheritdoc/>
public int Compare(GuildPosition x, GuildPosition y)
{
Expand Down Expand Up @@ -71,4 +54,21 @@ public int Compare(GuildListEntry? x, GuildListEntry? y)

return StringComparer.OrdinalIgnoreCase.Compare(x.PlayerName, y.PlayerName);
}

/// <summary>
/// Gets the numerical rank for ordering purposes. A lower value means a higher rank.
/// </summary>
/// <param name="position">The guild position.</param>
/// <returns>The rank of the position.</returns>
private static int GetRank(GuildPosition position)
{
return position switch
{
GuildPosition.GuildMaster => 0,
GuildPosition.AssistantMaster => 1,
GuildPosition.BattleMaster => 2,
GuildPosition.NormalMember => 3,
_ => 4,
};
}
}
8 changes: 7 additions & 1 deletion src/Interfaces/Properties/ModelResources.resx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<?xml version="1.0" encoding="utf-8"?>
<root>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
Expand Down Expand Up @@ -328,6 +328,12 @@
<data name="GuildPosition_BattleMaster_Description" xml:space="preserve">
<value></value>
</data>
<data name="GuildPosition_AssistantMaster_Caption" xml:space="preserve">
<value>Assistant Master</value>
</data>
<data name="GuildPosition_AssistantMaster_Description" xml:space="preserve">
<value></value>
</data>
<data name="LocalizableExceptionBase_TypeCaption" xml:space="preserve">
<value>Localizable Exception Base</value>
</data>
Expand Down
50 changes: 49 additions & 1 deletion src/Persistence/EntityFramework/GuildServerContext.cs
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
// <copyright file="GuildServerContext.cs" company="MUnique">
// <copyright file="GuildServerContext.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>

namespace MUnique.OpenMU.Persistence.EntityFramework;

using System.Threading;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using MUnique.OpenMU.Persistence.EntityFramework.Model;
Expand Down Expand Up @@ -68,4 +69,51 @@ join character in this.Context.Set<CharacterName>() on member.Id equals characte
.Include(g => g.RawMembers)
.ToListAsync().ConfigureAwait(false);
}

/// <inheritdoc/>
public async ValueTask<IReadOnlyList<DataModel.Entities.Guild>> GetGuildsOrderedByNameAsync(int skip, int count, CancellationToken cancellationToken = default)
{
return await this.Context.Set<Guild>()
.AsNoTracking()
.Include(g => g.RawMembers)
.Include(g => g.RawAllianceGuild)
.OrderBy(g => g.Name)
.Skip(skip)
.Take(count)
.ToListAsync(cancellationToken).ConfigureAwait(false);
}

/// <inheritdoc/>
public async ValueTask<IReadOnlyList<DataModel.Entities.Guild>> 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<Guild>()
.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);
}

/// <inheritdoc/>
public async ValueTask<IReadOnlyCollection<Guid>> GetAllianceMasterIdsAsync(IReadOnlyCollection<Guid> guildIds)
{
if (guildIds.Count == 0)
{
return [];
}

return await this.Context.Set<Guild>()
.AsNoTracking()
.Where(g => g.AllianceGuildId != null && guildIds.Contains(g.AllianceGuildId!.Value))
.Select(g => g.AllianceGuildId!.Value)
.Distinct()
.ToListAsync().ConfigureAwait(false);
}
}
30 changes: 29 additions & 1 deletion src/Persistence/IGuildServerContext.cs
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
// <copyright file="IGuildServerContext.cs" company="MUnique">
// <copyright file="IGuildServerContext.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>

namespace MUnique.OpenMU.Persistence;

using System.Threading;
using MUnique.OpenMU.Interfaces;

/// <summary>
Expand Down Expand Up @@ -46,4 +47,31 @@ public interface IGuildServerContext : IContext
/// <param name="guildId">The guild identifier.</param>
/// <returns>The ids of the alliances of a guild.</returns>
ValueTask<IReadOnlyList<DataModel.Entities.Guild>> GetAlliancesAsync(Guid guildId);

/// <summary>
/// Gets a page of guilds, ordered by name, without loading the whole guild table into memory.
/// </summary>
/// <param name="skip">The number of guilds to skip.</param>
/// <param name="count">The maximum number of guilds to return.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The requested page of guilds, including alliance and member information.</returns>
ValueTask<IReadOnlyList<DataModel.Entities.Guild>> GetGuildsOrderedByNameAsync(int skip, int count, CancellationToken cancellationToken = default);

/// <summary>
/// Searches guilds by name and returns a page of the matching results, without loading the whole guild table into memory.
/// </summary>
/// <param name="searchTerm">The case-insensitive search term which is matched against the guild name.</param>
/// <param name="skip">The number of matching guilds to skip.</param>
/// <param name="count">The maximum number of guilds to return.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The requested page of matching guilds, including alliance and member information.</returns>
ValueTask<IReadOnlyList<DataModel.Entities.Guild>> SearchGuildsAsync(string searchTerm, int skip, int count, CancellationToken cancellationToken = default);

/// <summary>
/// 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 <see cref="Interfaces.Guild.AllianceGuild"/>).
/// </summary>
/// <param name="guildIds">The guild identifiers to check. Kept small (e.g. one page) to avoid a full table scan.</param>
/// <returns>The subset of <paramref name="guildIds"/> which are alliance masters.</returns>
ValueTask<IReadOnlyCollection<Guid>> GetAllianceMasterIdsAsync(IReadOnlyCollection<Guid> guildIds);
}
39 changes: 38 additions & 1 deletion src/Persistence/InMemory/GuildServerInMemoryContext.cs
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
// <copyright file="GuildServerInMemoryContext.cs" company="MUnique">
// <copyright file="GuildServerInMemoryContext.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>

namespace MUnique.OpenMU.Persistence.InMemory;

using System.Threading;
using MUnique.OpenMU.Persistence.BasicModel;

/// <summary>
Expand Down Expand Up @@ -64,4 +65,40 @@ public async ValueTask<IReadOnlyDictionary<Guid, string>> GetMemberNamesAsync(Gu
.Where(g => g.AllianceGuild?.GetId() == guildId)
.ToList();
}

/// <inheritdoc/>
public async ValueTask<IReadOnlyList<DataModel.Entities.Guild>> GetGuildsOrderedByNameAsync(int skip, int count, CancellationToken cancellationToken = default)
{
var allGuilds = await this.Provider.GetRepository<DataModel.Entities.Guild>().GetAllAsync(cancellationToken).ConfigureAwait(false);
return allGuilds.OrderBy(g => g.Name).Skip(skip).Take(count).ToList();
}

/// <inheritdoc/>
public async ValueTask<IReadOnlyList<DataModel.Entities.Guild>> SearchGuildsAsync(string searchTerm, int skip, int count, CancellationToken cancellationToken = default)
{
var allGuilds = await this.Provider.GetRepository<DataModel.Entities.Guild>().GetAllAsync(cancellationToken).ConfigureAwait(false);
return allGuilds
.Where(g => g.Name?.Contains(searchTerm, StringComparison.InvariantCultureIgnoreCase) == true)
.OrderBy(g => g.Name)
.Skip(skip)
.Take(count)
.ToList();
}

/// <inheritdoc/>
public async ValueTask<IReadOnlyCollection<Guid>> GetAllianceMasterIdsAsync(IReadOnlyCollection<Guid> guildIds)
{
if (guildIds.Count == 0)
{
return [];
}

var guildIdSet = guildIds.ToHashSet();
var allGuilds = await this.Provider.GetRepository<DataModel.Entities.Guild>().GetAllAsync().ConfigureAwait(false);
return allGuilds
.Where(g => g.AllianceGuild is { } master && guildIdSet.Contains(master.GetId()))
.Select(g => g.AllianceGuild!.GetId())
.Distinct()
.ToList();
}
}
17 changes: 12 additions & 5 deletions src/Web/AdminPanel/Components/Layout/NavMenu.razor
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,18 @@
<span class="oi oi-people" aria-hidden="true" /> @Resources.OnlineAccounts
</NavLink>
</li>
<li class="nav-item px-3">
<NavLink class="nav-link" href="guilds" @onclick="() => NavigationHistory.Clear()">
<span class="oi oi-flag" aria-hidden="true" /> @Resources.Guilds
</NavLink>
</li>
<AuthorizeView Policy="@AdminPolicies.Administrator">
<li class="nav-item px-3">
<NavLink class="nav-link" href="castle-siege" @onclick="() => NavigationHistory.Clear()">
<span class="oi oi-shield" aria-hidden="true"></span> @Resources.CastleSiege
</NavLink>
</li>
</AuthorizeView>
@if (this.GameConfigurationId is { } gameConfigurationId )
{
<ConfigNavMenu GameConfigurationId="@gameConfigurationId" />
Expand All @@ -70,11 +82,6 @@
</li>
}
<AuthorizeView Policy="@AdminPolicies.Administrator">
<li class="nav-item px-3">
<NavLink class="nav-link" href="castle-siege" @onclick="() => NavigationHistory.Clear()">
<span class="oi oi-shield" aria-hidden="true"></span> @Resources.CastleSiege
</NavLink>
</li>
<li class="nav-item px-3">
<NavLink class="nav-link" href="users" @onclick="() => NavigationHistory.Clear()">
<span class="oi oi-key" aria-hidden="true"></span> @Resources.Users
Expand Down
15 changes: 5 additions & 10 deletions src/Web/AdminPanel/Components/PartyBadge.razor
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
@using MUnique.OpenMU.Web.AdminPanel.Properties

@if (PartyMaster is null || PartySize <= 0)
{
<span class="text-muted">—</span>
<span class="text-muted">@Resources.EmptyValue</span>
}
else
{
<span class="badge" title="Party of @PartyMaster: @PartySize members" style="background-color: @GetPartyColor(); color: #fff;">@PartyMaster (@PartySize)</span>
<span class="badge" title="@string.Format(Resources.PartyBadgeTitle, PartyMaster, PartySize)" style="background-color: @GetPartyColor(); color: #fff;">@PartyMaster (@PartySize)</span>
}

@code {
Expand All @@ -20,12 +22,5 @@ else
[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%)";
}
private string GetPartyColor() => PartyColorHelper.GetPartyColor(this.PartyMaster!);
}
42 changes: 42 additions & 0 deletions src/Web/AdminPanel/Components/PartyColorHelper.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
// <copyright file="PartyColorHelper.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>

namespace MUnique.OpenMU.Web.AdminPanel.Components;

/// <summary>
/// Assigns a stable badge color per party master, so all members of one party share
/// the same color and different parties are visually distinct.
/// Uses FNV-1a instead of the process-randomized string hash function, so colors
/// don't change on every restart of the admin panel.
/// </summary>
public static class PartyColorHelper
{
/// <summary>
/// Gets the badge background color for the given party master.
/// </summary>
/// <param name="partyMaster">The character name of the party master.</param>
public static string GetPartyColor(string partyMaster)
{
return $"hsl({Fnv1aUpperInvariant(partyMaster) % 360}, 45%, 45%)";
}

/// <summary>
/// Computes the 32-bit FNV-1a hash of the value, folding casing without allocating.
/// </summary>
/// <param name="value">The value to hash.</param>
internal static uint Fnv1aUpperInvariant(string value)
{
const uint offsetBasis = 2166136261;
const uint prime = 16777619;

var hash = offsetBasis;
foreach (var c in value)
{
hash ^= char.ToUpperInvariant(c);
hash *= prime;
}

return hash;
}
}
Loading
Loading