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.