using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; namespace SVSim.Database.Services.Friend; public sealed class FriendService : IFriendService, IPlayedTogetherWriter { internal const int FriendMaxCount = 110; internal const int SendApplyMaxCount = 110; internal const int PlayedTogetherRetention = 50; // Cosmetic field defaults matching the prod capture's "no campaign, normal player" state. internal const string DefaultDeviceType = "2"; internal const string DefaultMaxFriend = "110"; internal const string DefaultIsReceivedTwoPickMission = "1"; internal const string DefaultBirth = "0"; internal const string DefaultMissionChangeTime = "2017-09-15 02:36:09"; internal const string DefaultMissionReceiveType = "0"; internal const string DefaultIsOfficial = "0"; internal const string DefaultIsOfficialMarkDisplayed = "0"; private readonly SVSimDbContext _db; private readonly ILogger _log; public FriendService(SVSimDbContext db, ILogger log) { _db = db; _log = log; } public async Task GetFriendsAsync(long viewerId, CancellationToken ct) { var friendIds = await _db.ViewerFriends .AsNoTracking() .Where(f => f.OwnerViewerId == viewerId) .OrderBy(f => f.CreatedAt).ThenBy(f => f.FriendViewerId) .Select(f => f.FriendViewerId) .ToListAsync(ct); var friends = new List(friendIds.Count); foreach (var friendId in friendIds) { var entry = await BuildFriendEntryAsync(friendId, ct); if (entry is not null) friends.Add(entry); } return new FriendInfoResult(friends, friends.Count, FriendMaxCount); } public async Task GetReceiveAppliesAsync(long viewerId, CancellationToken ct) { var rows = await _db.ViewerFriendApplies .Where(a => a.ToViewerId == viewerId) .OrderBy(a => a.CreatedAt).ThenBy(a => a.Id) .AsNoTracking() .ToListAsync(ct); var applies = new List(rows.Count); foreach (var row in rows) applies.Add(await BuildApplyEntryAsync(row.Id, row.FromViewerId, row.CreatedAt, row.MissionType, ct)); return new ReceiveApplyInfoResult(applies, ApproveApplyCount: 0); } public async Task GetSendAppliesAsync(long viewerId, CancellationToken ct) { var rows = await _db.ViewerFriendApplies .Where(a => a.FromViewerId == viewerId) .OrderBy(a => a.CreatedAt).ThenBy(a => a.Id) .AsNoTracking() .ToListAsync(ct); var applies = new List(rows.Count); foreach (var row in rows) applies.Add(await BuildApplyEntryAsync(row.Id, row.ToViewerId, row.CreatedAt, row.MissionType, ct)); int remaining = Math.Max(0, SendApplyMaxCount - rows.Count); return new SendApplyInfoResult(applies, remaining, SendApplyMaxCount); } public async Task GetPlayedTogetherAsync(long viewerId, CancellationToken ct) { var rows = await _db.ViewerPlayedTogethers .Where(p => p.OwnerViewerId == viewerId) .OrderByDescending(p => p.PlayedAt) .AsNoTracking() .ToListAsync(ct); var entries = new List(rows.Count); foreach (var row in rows) { var opp = await LoadViewerProjectionAsync(row.OpponentViewerId, ct); if (opp is null) continue; // opponent deleted; skip the dead row bool isFriend = await _db.ViewerFriends.AsNoTracking() .AnyAsync(f => f.OwnerViewerId == viewerId && f.FriendViewerId == row.OpponentViewerId, ct); int friendStatus = 0; int friendApplyId = 0; if (isFriend) { friendStatus = 1; } else { var sent = await _db.ViewerFriendApplies.AsNoTracking() .Where(a => a.FromViewerId == viewerId && a.ToViewerId == row.OpponentViewerId) .Select(a => (int?)a.Id).FirstOrDefaultAsync(ct); if (sent is { } sId) { friendStatus = 2; friendApplyId = sId; } else { var recv = await _db.ViewerFriendApplies.AsNoTracking() .Where(a => a.FromViewerId == row.OpponentViewerId && a.ToViewerId == viewerId) .Select(a => (int?)a.Id).FirstOrDefaultAsync(ct); if (recv is { } rId) { friendStatus = 3; friendApplyId = rId; } } } entries.Add(new PlayedTogetherEntry( (int)opp.Id, opp.DisplayName, opp.CountryCode, ResolveRank(opp.DisplayName), opp.EmblemId, opp.DegreeId, FormatWireTimestamp(opp.LastLogin), FormatWireTimestamp(row.PlayedAt), friendStatus, friendApplyId, row.PlayedMode, row.BattleType, row.DeckFormat, row.TwoPickType)); } return new PlayedTogetherResult(entries); } public async Task SearchAsync(long viewerId, int targetViewerId, CancellationToken ct) { if (targetViewerId == (int)viewerId) return null; return await BuildFriendEntryAsync(targetViewerId, ct); } public Task SendApplyAsync(long viewerId, int targetViewerId, CancellationToken ct) => throw new NotImplementedException(); public Task ApproveApplyAsync(long viewerId, int applyId, CancellationToken ct) => throw new NotImplementedException(); public Task RejectApplyAsync(long viewerId, int applyId, CancellationToken ct) => throw new NotImplementedException(); public Task CancelApplyAsync(long viewerId, int applyId, CancellationToken ct) => throw new NotImplementedException(); public Task RejectAllAppliesAsync(long viewerId, CancellationToken ct) => throw new NotImplementedException(); public Task CancelAllAppliesAsync(long viewerId, CancellationToken ct) => throw new NotImplementedException(); public Task RejectFriendAsync(long viewerId, int targetViewerId, CancellationToken ct) => throw new NotImplementedException(); public Task RecordAsync(long ownerViewerId, long opponentViewerId, BattleParticipationContext ctx, CancellationToken ct) => throw new NotImplementedException(); // --- helpers --- private sealed record ViewerProjection( long Id, string DisplayName, DateTime LastLogin, string CountryCode, long EmblemId, int DegreeId); /// /// Loads a Viewer with Info + cosmetic nav refs, then projects to a slim record. /// We materialise the full entity rather than using Select() because EF Core /// ignores Include/ThenInclude when a Select projection is present. /// private async Task LoadViewerProjectionAsync(long viewerId, CancellationToken ct) { var v = await _db.Viewers .AsNoTracking() .Where(x => x.Id == viewerId) .Include(x => x.Info).ThenInclude(i => i.SelectedEmblem) .Include(x => x.Info).ThenInclude(i => i.SelectedDegree) .FirstOrDefaultAsync(ct); if (v is null) return null; return new ViewerProjection( v.Id, v.DisplayName, v.LastLogin, v.Info.CountryCode, v.Info.SelectedEmblem?.Id ?? 0, v.Info.SelectedDegree?.Id ?? 0); } private async Task BuildFriendEntryAsync(long friendViewerId, CancellationToken ct) { var v = await LoadViewerProjectionAsync(friendViewerId, ct); if (v is null) return null; return new FriendEntry( ViewerId: (int)v.Id, Name: v.DisplayName, CountryCode: v.CountryCode, Rank: ResolveRank(v.DisplayName), EmblemId: v.EmblemId, DegreeId: v.DegreeId, LastPlayTime: FormatWireTimestamp(v.LastLogin), DeviceType: DefaultDeviceType, MaxFriend: DefaultMaxFriend, IsReceivedTwoPickMission: DefaultIsReceivedTwoPickMission, Birth: DefaultBirth, MissionChangeTime: DefaultMissionChangeTime, MissionReceiveType: DefaultMissionReceiveType, IsOfficial: DefaultIsOfficial, IsOfficialMarkDisplayed: DefaultIsOfficialMarkDisplayed); } private async Task BuildApplyEntryAsync(int applyId, long otherViewerId, DateTime createdAt, int missionType, CancellationToken ct) { var v = await LoadViewerProjectionAsync(otherViewerId, ct); // If viewer was deleted between apply creation and now, emit a placeholder so the wire doesn't break. var displayName = v?.DisplayName ?? string.Empty; var lastLogin = v?.LastLogin ?? DateTime.UnixEpoch; var countryCode = v?.CountryCode ?? string.Empty; var emblemId = v?.EmblemId ?? 0; var degreeId = v?.DegreeId ?? 0; return new FriendApplyEntry( Id: applyId, ViewerId: (int)otherViewerId, Name: displayName, CountryCode: countryCode, Rank: ResolveRank(displayName), EmblemId: emblemId, DegreeId: degreeId, LastPlayTime: FormatWireTimestamp(lastLogin), CreateTime: FormatWireTimestamp(createdAt), MissionType: missionType); } /// /// Rank derivation. We don't track per-viewer rank yet; always 1. Hook here when rank data lands. /// private static int ResolveRank(string _) => 1; private static string FormatWireTimestamp(DateTime dt) => dt.ToString("yyyy-MM-dd HH:mm:ss", System.Globalization.CultureInfo.InvariantCulture); }