diff --git a/Maple2.Database/Model/Ranking/GuildTrophyRankInfo.cs b/Maple2.Database/Model/Ranking/GuildTrophyRankInfo.cs new file mode 100644 index 000000000..86e1553c7 --- /dev/null +++ b/Maple2.Database/Model/Ranking/GuildTrophyRankInfo.cs @@ -0,0 +1,11 @@ +using Maple2.Model.Game; + +namespace Maple2.Database.Model.Ranking; + +public record GuildTrophyRankInfo( + int Rank, + long GuildId, + string Name, + string Emblem, + string LeaderName, + AchievementInfo Trophy); diff --git a/Maple2.Database/Storage/Game/GameStorage.Web.cs b/Maple2.Database/Storage/Game/GameStorage.Web.cs index 8bc8d3957..672a3919a 100644 --- a/Maple2.Database/Storage/Game/GameStorage.Web.cs +++ b/Maple2.Database/Storage/Game/GameStorage.Web.cs @@ -98,6 +98,120 @@ public IList GetTrophyRankings() { Trophy: r.Trophy)) .ToList(); } + public GuildTrophyRankInfo? GetGuildTrophyRankInfo(long guildId) { + var guild = Context.Guild + .Where(g => g.Id == guildId) + .Select(g => new { g.Id, g.Name, g.Emblem, g.LeaderId }) + .FirstOrDefault(); + if (guild == null) { + return null; + } + + string leaderName = Context.Character + .Where(c => c.Id == guild.LeaderId) + .Select(c => c.Name) + .FirstOrDefault() ?? string.Empty; + + AchievementInfo guildTrophy = ComputeGuildTrophy(guildId); + + // Compute all guild totals to determine rank + var allGuildIds = Context.Guild.Select(g => g.Id).ToList(); + var guildTotals = new List<(long GuildId, int Total)>(); + foreach (long id in allGuildIds) { + AchievementInfo info = ComputeGuildTrophy(id); + guildTotals.Add((id, info.Total)); + } + + guildTotals = guildTotals.OrderByDescending(g => g.Total).ToList(); + int rank = guildTotals.FindIndex(g => g.GuildId == guildId) + 1; + if (rank == 0) { + rank = guildTotals.Count + 1; + } + + return new GuildTrophyRankInfo( + Rank: rank, + GuildId: guild.Id, + Name: guild.Name, + Emblem: guild.Emblem, + LeaderName: leaderName, + Trophy: guildTrophy); + } + + public GuildTrophyRankInfo? GetGuildTrophyRankInfo(string guildName) { + long? guildId = Context.Guild + .Where(g => g.Name == guildName) + .Select(g => (long?) g.Id) + .FirstOrDefault(); + if (guildId == null) { + return null; + } + + return GetGuildTrophyRankInfo(guildId.Value); + } + + public long GetGuildIdByCharacterId(long characterId) { + return Context.GuildMember + .Where(m => m.CharacterId == characterId) + .Select(m => m.GuildId) + .FirstOrDefault(); + } + + public IList GetGuildTrophyRankings() { + // Read guild data with Select projection to avoid EF tracking issues + var guilds = Context.Guild + .Select(g => new { g.Id, g.Name, g.Emblem, g.LeaderId }) + .ToList(); + + // Batch lookup leader names + var leaderIds = guilds.Select(g => g.LeaderId).Distinct().ToList(); + var leaderNames = Context.Character + .Where(c => leaderIds.Contains(c.Id)) + .Select(c => new { c.Id, c.Name }) + .ToDictionary(c => c.Id, c => c.Name); + + var rankings = new List(); + foreach (var guild in guilds) { + AchievementInfo trophy = ComputeGuildTrophy(guild.Id); + if (trophy.Total <= 0) { + continue; + } + string leaderName = leaderNames.GetValueOrDefault(guild.LeaderId, string.Empty); + rankings.Add(new GuildTrophyRankInfo(0, guild.Id, guild.Name, guild.Emblem, leaderName, trophy)); + } + + return rankings + .OrderByDescending(r => r.Trophy.Total) + .Select((r, index) => new GuildTrophyRankInfo( + Rank: index + 1, + GuildId: r.GuildId, + Name: r.Name, + Emblem: r.Emblem, + LeaderName: r.LeaderName, + Trophy: r.Trophy)) + .Take(200) + .ToList(); + } + + private AchievementInfo ComputeGuildTrophy(long guildId) { + // Get all member character IDs and their account IDs via Select projection + var members = Context.GuildMember + .Where(m => m.GuildId == guildId) + .Select(m => m.CharacterId) + .ToList(); + + // Batch lookup account IDs + var characterInfo = Context.Character + .Where(c => members.Contains(c.Id)) + .Select(c => new { c.Id, c.AccountId }) + .ToList(); + + var total = new AchievementInfo(); + foreach (var info in characterInfo) { + total += GetAchievementInfo(info.AccountId, info.Id); + } + + return total; + } #endregion public IList GetMentorList(long accountId, long characterId) { diff --git a/Maple2.Server.Web/Controllers/WebController.cs b/Maple2.Server.Web/Controllers/WebController.cs index 7ee7834fc..84b694b69 100644 --- a/Maple2.Server.Web/Controllers/WebController.cs +++ b/Maple2.Server.Web/Controllers/WebController.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.IO; using System.IO.Compression; +using System.Linq; using System.Threading.Tasks; using Maple2.Database.Model.Ranking; using Maple2.Database.Storage; @@ -13,6 +14,7 @@ using Maple2.Tools.Extensions; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Caching.Memory; using Serilog; using Enum = System.Enum; @@ -23,10 +25,13 @@ public class WebController : ControllerBase { private readonly WebStorage webStorage; private readonly GameStorage gameStorage; + private readonly IMemoryCache cache; + private static readonly TimeSpan RankingCacheDuration = TimeSpan.FromHours(1); - public WebController(WebStorage webStorage, GameStorage gameStorage) { + public WebController(WebStorage webStorage, GameStorage gameStorage, IMemoryCache cache) { this.webStorage = webStorage; this.gameStorage = gameStorage; + this.cache = cache; } [HttpPost("irrq.aspx")] @@ -58,6 +63,8 @@ public async Task Rankings() { ByteWriter pWriter = type switch { GameRankingType.Trophy => Trophy(userName), GameRankingType.PersonalTrophy => PersonalTrophy(characterId), + GameRankingType.GuildTrophy => GuildTrophy(userName), + GameRankingType.PersonalGuildTrophy => PersonalGuildTrophy(characterId), _ => new ByteWriter(), }; @@ -280,26 +287,113 @@ private static IResult HandleUnknownMode(UgcType mode) { #region Ranking public ByteWriter Trophy(string userName) { - List rankInfos = []; - using GameStorage.Request db = gameStorage.Context(); - if (!string.IsNullOrEmpty(userName)) { - TrophyRankInfo? info = db.GetTrophyRankInfo(userName); - if (info != null) { - rankInfos.Add(info); + string cacheKey = $"Trophy_{userName ?? "all"}"; + + if (!cache.TryGetValue(cacheKey, out byte[]? cachedData)) { + List rankInfos = []; + using GameStorage.Request db = gameStorage.Context(); + if (!string.IsNullOrEmpty(userName)) { + TrophyRankInfo? info = db.GetTrophyRankInfo(userName); + if (info != null) { + rankInfos.Add(info); + } + } else { + IList infos = db.GetTrophyRankings(); + rankInfos.AddRange(infos); } - } else { - IList infos = db.GetTrophyRankings(); - rankInfos.AddRange(infos); + + ByteWriter writer = InGameRankPacket.Trophy(rankInfos); + cachedData = writer.Buffer[..writer.Length]; + cache.Set(cacheKey, cachedData, RankingCacheDuration); } - return InGameRankPacket.Trophy(rankInfos); + var result = new ByteWriter(); + result.WriteBytes(cachedData!); + return result; } public ByteWriter PersonalTrophy(long characterId) { - using GameStorage.Request db = gameStorage.Context(); - TrophyRankInfo? info = db.GetTrophyRankInfo(characterId); - return InGameRankPacket.PersonalRank(GameRankingType.PersonalTrophy, info?.Rank ?? 0); + string cacheKey = $"PersonalTrophy_{characterId}"; + + if (!cache.TryGetValue(cacheKey, out byte[]? cachedData)) { + using GameStorage.Request db = gameStorage.Context(); + TrophyRankInfo? info = db.GetTrophyRankInfo(characterId); + ByteWriter writer = InGameRankPacket.PersonalRank(GameRankingType.PersonalTrophy, info?.Rank ?? 0); + cachedData = writer.Buffer[..writer.Length]; + cache.Set(cacheKey, cachedData, RankingCacheDuration); + } + + var result = new ByteWriter(); + result.WriteBytes(cachedData!); + return result; + } + + public ByteWriter GuildTrophy(string userName) { + if (!string.IsNullOrEmpty(userName)) { + string cacheKey = $"GuildTrophy_{userName}"; + + if (!cache.TryGetValue(cacheKey, out byte[]? cachedData)) { + // Search by guild name, check full rankings cache first then fall back to DB + IList rankings = GetCachedGuildTrophyRankings(); + GuildTrophyRankInfo? cached = rankings.FirstOrDefault(r => + r.Name.Equals(userName, StringComparison.OrdinalIgnoreCase)); + + ByteWriter writer; + if (cached != null) { + writer = InGameRankPacket.GuildTrophy([cached]); + } else { + using GameStorage.Request db = gameStorage.Context(); + GuildTrophyRankInfo? info = db.GetGuildTrophyRankInfo(userName); + writer = InGameRankPacket.GuildTrophy(info != null ? new[] { info } : []); + } + + cachedData = writer.Buffer[..writer.Length]; + cache.Set(cacheKey, cachedData, RankingCacheDuration); + } + + var result = new ByteWriter(); + result.WriteBytes(cachedData!); + return result; + } + + return InGameRankPacket.GuildTrophy(GetCachedGuildTrophyRankings()); + } + + public ByteWriter PersonalGuildTrophy(long characterId) { + string cacheKey = $"PersonalGuildTrophy_{characterId}"; + + if (!cache.TryGetValue(cacheKey, out byte[]? cachedData)) { + using GameStorage.Request db = gameStorage.Context(); + long guildId = db.GetGuildIdByCharacterId(characterId); + ByteWriter writer; + if (guildId == 0) { + writer = InGameRankPacket.PersonalRank(GameRankingType.PersonalGuildTrophy, 0); + } else { + // Check cached rankings for this guild's rank + IList rankings = GetCachedGuildTrophyRankings(); + GuildTrophyRankInfo? cached = rankings.FirstOrDefault(r => r.GuildId == guildId); + writer = InGameRankPacket.PersonalRank(GameRankingType.PersonalGuildTrophy, cached?.Rank ?? 0); + } + + cachedData = writer.Buffer[..writer.Length]; + cache.Set(cacheKey, cachedData, RankingCacheDuration); + } + + var result = new ByteWriter(); + result.WriteBytes(cachedData!); + return result; + } + + private IList GetCachedGuildTrophyRankings() { + const string cacheKey = "GuildTrophyRankings"; + + if (!cache.TryGetValue(cacheKey, out IList? rankings)) { + using GameStorage.Request db = gameStorage.Context(); + rankings = db.GetGuildTrophyRankings(); + cache.Set(cacheKey, rankings, RankingCacheDuration); + } + return rankings!; } #endregion @@ -307,7 +401,7 @@ public ByteWriter MenteeList(long accountId, long characterId) { using GameStorage.Request db = gameStorage.Context(); IList list = db.GetMentorList(accountId, characterId); - IList players = new List(); + IList players = []; foreach (long menteeId in list) { PlayerInfo? playerInfo = db.GetPlayerInfo(menteeId); if (playerInfo != null) { diff --git a/Maple2.Server.Web/Packet/InGameRankPacket.cs b/Maple2.Server.Web/Packet/InGameRankPacket.cs index 2accf1af7..6ae14a122 100644 --- a/Maple2.Server.Web/Packet/InGameRankPacket.cs +++ b/Maple2.Server.Web/Packet/InGameRankPacket.cs @@ -37,23 +37,24 @@ public static ByteWriter PersonalRank(GameRankingType type, int rank) { return pWriter; } - public static ByteWriter GuildTrophy() { + public static ByteWriter GuildTrophy(IList rankInfos) { var pWriter = new ByteWriter(); pWriter.Write(GameRankingType.GuildTrophy); pWriter.WriteInt(0); pWriter.WriteUnicodeStringWithLength(DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss")); - pWriter.WriteInt(1); // count - - pWriter.WriteInt(0); // rank - pWriter.WriteLong(0); // guildId - pWriter.WriteUnicodeStringWithLength(string.Empty); // guildName - pWriter.WriteUnicodeStringWithLength(string.Empty); // guildEmblem - pWriter.WriteLong(); // guild leader id ? - pWriter.WriteInt(); // guild trophy total - pWriter.WriteInt(0); // guild trophy combat - pWriter.WriteInt(0); // guild trophy adventure - pWriter.WriteInt(0); // guild trophy lifestyle + pWriter.WriteInt(rankInfos.Count); + foreach (GuildTrophyRankInfo info in rankInfos) { + pWriter.WriteInt(info.Rank); + pWriter.WriteLong(info.GuildId); + pWriter.WriteUnicodeStringWithLength(info.Name); + pWriter.WriteUnicodeStringWithLength(info.Emblem); + pWriter.WriteUnicodeStringWithLength(info.LeaderName); + pWriter.WriteInt(info.Trophy.Total); + pWriter.WriteInt(info.Trophy.Combat); + pWriter.WriteInt(info.Trophy.Adventure); + pWriter.WriteInt(info.Trophy.Lifestyle); + } return pWriter; } diff --git a/Maple2.Server.Web/Program.cs b/Maple2.Server.Web/Program.cs index fa5e19ea2..6e699334e 100644 --- a/Maple2.Server.Web/Program.cs +++ b/Maple2.Server.Web/Program.cs @@ -46,6 +46,7 @@ // }); }); builder.Services.Configure(options => options.ShutdownTimeout = TimeSpan.FromSeconds(15)); +builder.Services.AddMemoryCache(); builder.Services.AddControllers(); builder.Logging.ClearProviders(); diff --git a/Maple2.Server.World/Containers/GuildManager.cs b/Maple2.Server.World/Containers/GuildManager.cs index 9e4e7f187..ef4030b2d 100644 --- a/Maple2.Server.World/Containers/GuildManager.cs +++ b/Maple2.Server.World/Containers/GuildManager.cs @@ -308,6 +308,11 @@ public GuildError UpdateEmblem(long requestorId, string emblem) { } Guild.Emblem = emblem; + + using (GameStorage.Request db = GameStorage.Context()) { + db.SaveGuild(Guild); + } + Broadcast(new GuildRequest { UpdateEmblem = new GuildRequest.Types.UpdateEmblem { RequestorName = requestor.Name,