Skip to content

Add guild trophy ranking and caching#633

Merged
AngeloTadeucci merged 5 commits intoMS2Community:masterfrom
Dreary:guilds
Feb 10, 2026
Merged

Add guild trophy ranking and caching#633
AngeloTadeucci merged 5 commits intoMS2Community:masterfrom
Dreary:guilds

Conversation

@Dreary
Copy link
Contributor

@Dreary Dreary commented Feb 10, 2026

Summary by CodeRabbit

  • New Features

    • Guild trophy rankings: guilds ranked by total member achievements, including guild name, emblem, leader and trophy breakdowns.
    • New endpoints to fetch guild and personal-guild trophy ranks; rankings are cached (1 hour) for performance and served as full lists.
    • In-game rank packets now return full guild trophy lists instead of a single placeholder.
  • Bug Fixes

    • Guild emblem updates are now persisted to the database.

- Add GuildTrophyRankInfo record as a DTO for guild ranking data.
- Implement GameStorage methods: GetGuildTrophyRankInfo(by id/name), GetGuildIdByCharacterId, GetGuildTrophyRankings, and ComputeGuildTrophy. Rankings are computed by aggregating member achievement totals, ordered, and limited to top 200. Implement batch queries to reduce per-entity EF tracking.
- Update WebController to expose GuildTrophy and PersonalGuildTrophy handlers, add a 3-minute in-memory cache for rankings, and use cached data when possible. Add necessary using and cache fields.
- Change InGameRankPacket.GuildTrophy to accept a list of GuildTrophyRankInfo and serialize full guild info (rank, id, name, emblem, leader, and trophy breakdown).
- Persist guild emblem updates in GuildManager by saving the guild to GameStorage after changes.

These changes enable querying and serving guild trophy leaderboards and individual guild ranks with basic caching and saves emblems.
@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 10, 2026

📝 Walkthrough

Walkthrough

Adds guild trophy ranking support: a new GuildTrophyRankInfo record, GameStorage methods to compute and rank guild trophies (batched aggregation), WebController endpoints and IMemoryCache usage (1-hour TTL), packet serialization for multiple guild trophy entries, and emblem persistence on update.

Changes

Cohort / File(s) Summary
Data Model
Maple2.Database/Model/Ranking/GuildTrophyRankInfo.cs
New public record GuildTrophyRankInfo(int Rank, long GuildId, string Name, string Emblem, string LeaderName, AchievementInfo Trophy).
Game Storage (guild trophy logic)
Maple2.Database/Storage/Game/GameStorage.Web.cs
Added public methods GetGuildTrophyRankInfo(long), GetGuildTrophyRankInfo(string), GetGuildIdByCharacterId(long), GetGuildTrophyRankings() and private ComputeGuildTrophy(long). Implements batched lookups, aggregates AchievementInfo per guild, filters non-positive totals, ranks, and caps results.
Web Controller & Caching
Maple2.Server.Web/Controllers/WebController.cs
Constructor now accepts IMemoryCache; added GuildTrophy(string) and PersonalGuildTrophy(long) endpoints and GetCachedGuildTrophyRankings() helper. Uses 1-hour cache for guild rankings and extends Rankings routing. Minor change: MenteeList initialized as empty array.
Packet Serialization
Maple2.Server.Web/Packet/InGameRankPacket.cs
GuildTrophy() signature changed to accept IList<GuildTrophyRankInfo> and now serializes each guild entry (Rank, GuildId, Name, Emblem, LeaderName, Trophy totals) instead of a single hardcoded placeholder.
Guild Persistence
Maple2.Server.World/Containers/GuildManager.cs
UpdateEmblem() now persists emblem changes by opening GameStorage and calling SaveGuild() after updating Guild.Emblem.
Startup DI
Maple2.Server.Web/Program.cs
Registered MemoryCache in DI via AddMemoryCache().

Sequence Diagram

sequenceDiagram
    participant Client
    participant WebController
    participant Cache
    participant GameStorage
    participant Packet

    Client->>WebController: Request GuildTrophy(guildName) or PersonalGuildTrophy(characterId)
    WebController->>Cache: Try get cached GuildTrophyRankings (1h TTL)
    alt Cache Hit
        Cache-->>WebController: Return cached GuildTrophyRankInfo[]
    else Cache Miss
        WebController->>GameStorage: GetGuildTrophyRankings() or GetGuildTrophyRankInfo(...)
        GameStorage->>GameStorage: Batch fetch guilds, leaders, members, characters
        GameStorage->>GameStorage: Aggregate AchievementInfo per guild (ComputeGuildTrophy)
        GameStorage->>GameStorage: Sort, assign ranks, take top 200
        GameStorage-->>WebController: Return ranked GuildTrophyRankInfo[]
        WebController->>Cache: Store GuildTrophyRankInfo[] (1h TTL)
    end
    WebController->>Packet: GuildTrophy(rankInfos)
    Packet->>Client: Serialized guild trophy ranking packet
Loading

Estimated Code Review Effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Suggested Reviewers

  • Zintixx

Poem

🐰 I hopped through guild halls near and far,
Counting trophies caught beneath each star,
Batched my carrots, cached them tight,
Leaders, emblems, ranks in sight —
Hop! The leaderboard gleams like a jar.

🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The pull request title accurately summarizes the main changes: it adds guild trophy ranking capabilities and implements caching for ranking data across multiple files.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

No actionable comments were generated in the recent review. 🎉

🧹 Recent nitpick comments
Maple2.Server.Web/Controllers/WebController.cs (3)

331-360: GuildTrophy with empty userName bypasses the byte-level cache, re-serializing on every request.

When userName is empty/null, line 359 returns a freshly built ByteWriter from InGameRankPacket.GuildTrophy(...) without caching the serialized result. This is the "all guilds" path — the most expensive case — yet it's the only one that skips caching the serialized packet bytes. Every other ranking method caches byte[] to avoid repeated serialization.

Proposed fix: cache the "all guilds" serialized result too
     public ByteWriter GuildTrophy(string userName) {
         if (!string.IsNullOrEmpty(userName)) {
             // ... per-guild-name caching (unchanged) ...
         }

-        return InGameRankPacket.GuildTrophy(GetCachedGuildTrophyRankings());
+        const string allGuildsCacheKey = "GuildTrophy_all";
+
+        if (!cache.TryGetValue(allGuildsCacheKey, out byte[]? allCachedData)) {
+            ByteWriter writer = InGameRankPacket.GuildTrophy(GetCachedGuildTrophyRankings());
+            allCachedData = writer.Buffer[..writer.Length];
+            cache.Set(allGuildsCacheKey, allCachedData, RankingCacheDuration);
+        }
+
+        var result = new ByteWriter();
+        result.WriteBytes(allCachedData!);
+        return result;
     }

289-289: Ranking helper methods are public but should be private.

Trophy, PersonalTrophy, GuildTrophy, and PersonalGuildTrophy are only called internally from Rankings(). Marking them public on a controller exposes them unintentionally (ASP.NET won't route to them without [Http*] attributes, but it's still unnecessary surface area).

Also applies to: 315-315, 331-331, 362-362


387-397: GetCachedGuildTrophyRankings caches the mutable IList<T> itself.

Unlike the other caching sites that store an immutable byte[], this method caches the IList<GuildTrophyRankInfo> reference directly. If any caller were to mutate the returned list (e.g., add/remove/sort), it would corrupt the shared cache entry for all subsequent readers. Since GuildTrophyRankInfo is a record (immutable), the elements are safe, but the list container is not.

A low-effort safeguard would be to store the result as an immutable type:

Proposed fix
     private IList<GuildTrophyRankInfo> GetCachedGuildTrophyRankings() {
         const string cacheKey = "GuildTrophyRankings";

         if (!cache.TryGetValue(cacheKey, out IList<GuildTrophyRankInfo>? rankings)) {
             using GameStorage.Request db = gameStorage.Context();
-            rankings = db.GetGuildTrophyRankings();
+            rankings = db.GetGuildTrophyRankings().ToList().AsReadOnly();
             cache.Set(cacheKey, rankings, RankingCacheDuration);
         }

         return rankings!;
     }

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@Maple2.Server.Web/Controllers/WebController.cs`:
- Around line 28-29: _guildTrophyCache and _guildTrophyCacheExpiry are static
shared fields accessed from concurrent request threads without synchronization;
protect all reads and writes by introducing a private static readonly lock
object (e.g. _guildTrophyCacheLock) and wrap any access/update logic that checks
expiry, reads, or populates _guildTrophyCache/_guildTrophyCacheExpiry in
lock(_guildTrophyCacheLock) blocks to ensure atomicity and memory visibility;
apply the same locking around the other access site mentioned (lines ~341-350)
so all cache operations use the same lock.
🧹 Nitpick comments (3)
Maple2.Database/Storage/Game/GameStorage.Web.cs (2)

101-138: GetGuildTrophyRankInfo(long) recomputes all guild trophies — consider using the cached rankings instead.

This method calls ComputeGuildTrophy for every guild (line 120-123) just to determine the rank of a single guild. Since WebController already caches the full rankings via GetCachedGuildTrophyRankings(), this expensive per-guild method is only reached as a DB fallback in the GuildTrophy(userName) search path (WebController line 321). For a guild not in the top-200 cache, this triggers a full recomputation.

If this is acceptable as POC (per the comment on line 11), consider at least adding a similar comment here to flag it for future optimization.


159-193: GetGuildTrophyRankings has O(guilds × members) DB round-trips via ComputeGuildTrophy.

Each call to ComputeGuildTrophy (line 174) issues two queries (members + characters) plus one GetAchievementInfo call per member. With many guilds and members, this is a significant DB load. The 3-minute cache in WebController mitigates this, but a single cache-miss still triggers a heavy burst.

This is consistent with the existing character trophy approach, so fine for POC — just noting it for awareness.

Maple2.Server.Web/Controllers/WebController.cs (1)

310-326: Cache miss for a guild name search still triggers a full recomputation.

When userName is provided and the guild isn't found in the cached top-200 rankings (line 314-318), the fallback db.GetGuildTrophyRankInfo(userName) (line 321) recomputes trophies for all guilds to determine the rank of a single guild. This is expected for guilds outside the top 200, but worth noting as a potential abuse vector if users can trigger arbitrary guild-name searches.

Introduce IMemoryCache to cache ranking responses and reduce DB load. WebController now accepts IMemoryCache, removes the old static guild-trophy cache, and caches Trophy, PersonalTrophy, GuildTrophy, PersonalGuildTrophy and full guild rankings (GetCachedGuildTrophyRankings) using hourly TTL (RankingCacheDuration). Program.cs registers AddMemoryCache so the cache is available via DI. Cache keys follow patterns like "Trophy_{user}", "PersonalTrophy_{id}", "GuildTrophy_{name}", "PersonalGuildTrophy_{id}", and "GuildTrophyRankings".
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@Maple2.Server.Web/Controllers/WebController.cs`:
- Around line 289-310: The cached ByteWriter instances in Trophy (and similarly
in PersonalTrophy, GuildTrophy, PersonalGuildTrophy) are mutable and may be
mutated by callers; instead cache an immutable serialized form (e.g., call
ZlibCompress(writer) or writer.ToArray() and cache the resulting byte[]), and on
each request reconstruct a fresh ByteWriter from that byte[] before returning;
update the usage around InGameRankPacket.Trophy, cache.Set(cacheKey, ... ,
RankingCacheDuration) and cache.TryGetValue to store/retrieve byte[] (or
compressed bytes) and wrap them into a new ByteWriter for the response so the
cached data cannot be mutated.
🧹 Nitpick comments (3)
Maple2.Server.Web/Controllers/WebController.cs (3)

28-34: Constructor injection used instead of property injection pattern.

The coding guidelines specify using the property injection pattern with required properties for dependency injection in service classes. The new cache field follows the existing constructor-injection pattern in this class, but if you plan to align with the guidelines, all three dependencies should be converted.

Example property injection pattern
-    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, IMemoryCache cache) {
-        this.webStorage = webStorage;
-        this.gameStorage = gameStorage;
-        this.cache = cache;
-    }
+    public required WebStorage webStorage { private get; init; }
+    public required GameStorage gameStorage { private get; init; }
+    public required IMemoryCache cache { private get; init; }
+    private static readonly TimeSpan RankingCacheDuration = TimeSpan.FromHours(1);

As per coding guidelines: "Use property injection pattern with required properties for dependency injection in service classes".


349-349: Uncached ByteWriter for the "all guilds" path.

When userName is empty, GuildTrophy builds a new ByteWriter from GetCachedGuildTrophyRankings() on every request without caching the serialized result. All other ranking paths cache their ByteWriter. This means every "list all" request re-serializes the full rankings list.

Suggested fix
-        return InGameRankPacket.GuildTrophy(GetCachedGuildTrophyRankings());
+        const string allCacheKey = "GuildTrophy_all";
+        if (!cache.TryGetValue(allCacheKey, out ByteWriter? allResult)) {
+            allResult = InGameRankPacket.GuildTrophy(GetCachedGuildTrophyRankings());
+            cache.Set(allCacheKey, allResult, RankingCacheDuration);
+        }
+        return allResult!;

352-371: Guilds outside top 200 silently report rank 0.

When a character's guild isn't in the cached top-200 rankings (line 363), cached?.Rank ?? 0 returns 0. Unlike the GuildTrophy search path (which falls back to db.GetGuildTrophyRankInfo), this path has no DB fallback to compute the exact rank. If this is intentional (unranked guilds show rank 0), a brief comment would clarify.

@AngeloTadeucci AngeloTadeucci merged commit c73a6de into MS2Community:master Feb 10, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants