From 7a13e99df7ec7b4316e51ecba8930231d875fc94 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Sat, 4 Jul 2026 16:18:22 -0400 Subject: [PATCH] fix(guild): emit is_friend / is_friend_apply in /guild/info members MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GuildMemberInfo.cs:48-49 re-reads json["is_friend"] / json["is_friend_apply"] UNGUARDED on top of the base-class UserInfoBase's guarded read. Our DTO had them as int? with global WhenWritingNull → keys omitted → LitJson threw KeyNotFoundException, crashing the client right after guild create. Flip the fields to non-nullable int with JsonIgnoreCondition.Never so they always ship, and wire a batched IFriendService.GetFriendRelationsAsync lookup so the values are actually populated (single Contains-batched query per relation table). Co-Authored-By: Claude Opus 4.7 --- SVSim.Database/Services/Friend/FriendDtos.cs | 6 ++++ .../Services/Friend/FriendService.cs | 30 +++++++++++++++++++ .../Services/Friend/IFriendService.cs | 8 +++++ .../Controllers/GuildController.cs | 8 +++-- .../Dtos/Common/Guild/GuildMemberInfoDto.cs | 20 +++++++++---- 5 files changed, 64 insertions(+), 8 deletions(-) diff --git a/SVSim.Database/Services/Friend/FriendDtos.cs b/SVSim.Database/Services/Friend/FriendDtos.cs index ec301375..5d9d8c68 100644 --- a/SVSim.Database/Services/Friend/FriendDtos.cs +++ b/SVSim.Database/Services/Friend/FriendDtos.cs @@ -82,3 +82,9 @@ public sealed record BattleParticipationContext( int BattleType, int DeckFormat, int TwoPickType); + +/// +/// Caller-relative friend flags for another viewer. Used to badge rows in guild-member, +/// invite-candidate, and join-request lists on the wire (is_friend, is_friend_apply). +/// +public sealed record FriendRelation(bool IsFriend, bool HasOutgoingApply); diff --git a/SVSim.Database/Services/Friend/FriendService.cs b/SVSim.Database/Services/Friend/FriendService.cs index acf2cd29..89f7414e 100644 --- a/SVSim.Database/Services/Friend/FriendService.cs +++ b/SVSim.Database/Services/Friend/FriendService.cs @@ -313,6 +313,36 @@ public sealed class FriendService : IFriendService, IPlayedTogetherWriter await _db.SaveChangesAsync(ct); } + public async Task> GetFriendRelationsAsync( + long viewerId, IReadOnlyList otherViewerIds, CancellationToken ct) + { + if (otherViewerIds.Count == 0) + return new Dictionary(); + + var idSet = otherViewerIds.Where(id => id != viewerId).Distinct().ToList(); + var friendSet = idSet.Count == 0 + ? new HashSet() + : (await _db.ViewerFriends.AsNoTracking() + .Where(f => f.OwnerViewerId == viewerId && idSet.Contains(f.FriendViewerId)) + .Select(f => f.FriendViewerId) + .ToListAsync(ct)).ToHashSet(); + var applySet = idSet.Count == 0 + ? new HashSet() + : (await _db.ViewerFriendApplies.AsNoTracking() + .Where(a => a.FromViewerId == viewerId && idSet.Contains(a.ToViewerId)) + .Select(a => a.ToViewerId) + .ToListAsync(ct)).ToHashSet(); + + var result = new Dictionary(otherViewerIds.Count); + foreach (var id in otherViewerIds) + { + result[id] = id == viewerId + ? new FriendRelation(false, false) + : new FriendRelation(friendSet.Contains(id), applySet.Contains(id)); + } + return result; + } + // --- helpers --- private sealed record ViewerProjection( diff --git a/SVSim.Database/Services/Friend/IFriendService.cs b/SVSim.Database/Services/Friend/IFriendService.cs index eafae285..e9893b37 100644 --- a/SVSim.Database/Services/Friend/IFriendService.cs +++ b/SVSim.Database/Services/Friend/IFriendService.cs @@ -27,4 +27,12 @@ public interface IFriendService /// Deletes both directions of the friendship (A→B and B→A). Task RejectFriendAsync(long viewerId, int targetViewerId, CancellationToken ct); + + /// + /// Batched caller-relative friend-relation lookup. For each id in + /// returns whether the caller is already friends with them and/or has an outgoing pending apply + /// to them. Self and unknown ids resolve to (false, false). + /// + Task> GetFriendRelationsAsync( + long viewerId, IReadOnlyList otherViewerIds, CancellationToken ct); } diff --git a/SVSim.EmulatedEntrypoint/Controllers/GuildController.cs b/SVSim.EmulatedEntrypoint/Controllers/GuildController.cs index 77b1f478..2832275b 100644 --- a/SVSim.EmulatedEntrypoint/Controllers/GuildController.cs +++ b/SVSim.EmulatedEntrypoint/Controllers/GuildController.cs @@ -396,17 +396,19 @@ public sealed class GuildController : SVSimController var viewerIds = members.Select(m => m.ViewerId).ToList(); var profiles = await _viewers.LoadGuildProfileBatchAsync(viewerIds, ct); + var relations = await _friends.GetFriendRelationsAsync(callerViewerId, viewerIds, ct); var result = new List(members.Count); foreach (var m in members) { profiles.TryGetValue(m.ViewerId, out var p); - result.Add(ToMemberDto(m, p)); + relations.TryGetValue(m.ViewerId, out var r); + result.Add(ToMemberDto(m, p, r)); } return result; } - private static GuildMemberInfoDto ToMemberDto(GuildMember member, GuildMemberProfile? profile) => new() + private static GuildMemberInfoDto ToMemberDto(GuildMember member, GuildMemberProfile? profile, FriendRelation? relation) => new() { ViewerId = member.ViewerId, Name = profile?.Name ?? "", @@ -414,6 +416,8 @@ public sealed class GuildController : SVSimController CountryCode = profile?.CountryCode ?? "", Rank = profile?.Rank ?? 1, DegreeId = profile?.DegreeId ?? 0, + IsFriend = relation?.IsFriend == true ? 1 : 0, + IsFriendApply = relation?.HasOutgoingApply == true ? 1 : 0, IsOfficialMarkDisplayed = profile?.IsOfficialMarkDisplayed == true ? 1 : 0, Role = (int)member.Role, }; diff --git a/SVSim.EmulatedEntrypoint/Models/Dtos/Common/Guild/GuildMemberInfoDto.cs b/SVSim.EmulatedEntrypoint/Models/Dtos/Common/Guild/GuildMemberInfoDto.cs index 61d81418..6c8ca858 100644 --- a/SVSim.EmulatedEntrypoint/Models/Dtos/Common/Guild/GuildMemberInfoDto.cs +++ b/SVSim.EmulatedEntrypoint/Models/Dtos/Common/Guild/GuildMemberInfoDto.cs @@ -30,13 +30,21 @@ public class GuildMemberInfoDto [JsonPropertyName("degree_id"), Key("degree_id"), JsonConverter(typeof(StringifiedIntConverter))] public int DegreeId { get; set; } - /// Optional — omit when the caller has no friend relationship with this member. - [JsonPropertyName("is_friend"), Key("is_friend")] - public int? IsFriend { get; set; } + /// + /// Client reads unconditionally in GuildMemberInfo.cs:48 (json["is_friend"].ToBoolean()) — always emit. + /// Base UserInfoBase guards this, but GuildMemberInfo re-reads it on top, unguarded. 0 = not a friend. + /// + [JsonPropertyName("is_friend"), Key("is_friend"), + JsonIgnore(Condition = JsonIgnoreCondition.Never)] + public int IsFriend { get; set; } - /// Optional — omit when no pending friend apply. - [JsonPropertyName("is_friend_apply"), Key("is_friend_apply")] - public int? IsFriendApply { get; set; } + /// + /// Client reads unconditionally in GuildMemberInfo.cs:49 (json["is_friend_apply"].ToBoolean()) — always emit. + /// 0 = no pending friend apply. + /// + [JsonPropertyName("is_friend_apply"), Key("is_friend_apply"), + JsonIgnore(Condition = JsonIgnoreCondition.Never)] + public int IsFriendApply { get; set; } /// /// Whether the official-account badge is shown for this member.