From 300eee36e960ec6f572f7cfcf9b4ba51c24c7c84 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Tue, 9 Jun 2026 21:44:51 -0400 Subject: [PATCH] feat(friend): IFriendService + IPlayedTogetherWriter + DTO records Task 3: service contract (interface + DTOs) and FriendService skeleton. All methods throw NotImplementedException; Tasks 4-6 fill in the logic. Co-Authored-By: Claude Sonnet 4.6 --- SVSim.Database/Services/Friend/FriendDtos.cs | 84 +++++++++++++++++++ .../Services/Friend/FriendService.cs | 69 +++++++++++++++ .../Services/Friend/IFriendService.cs | 30 +++++++ .../Services/Friend/IPlayedTogetherWriter.cs | 11 +++ 4 files changed, 194 insertions(+) create mode 100644 SVSim.Database/Services/Friend/FriendDtos.cs create mode 100644 SVSim.Database/Services/Friend/FriendService.cs create mode 100644 SVSim.Database/Services/Friend/IFriendService.cs create mode 100644 SVSim.Database/Services/Friend/IPlayedTogetherWriter.cs diff --git a/SVSim.Database/Services/Friend/FriendDtos.cs b/SVSim.Database/Services/Friend/FriendDtos.cs new file mode 100644 index 0000000..ec30137 --- /dev/null +++ b/SVSim.Database/Services/Friend/FriendDtos.cs @@ -0,0 +1,84 @@ +namespace SVSim.Database.Services.Friend; + +/// +/// One friend in the requested viewer's friend list. Wire shape carries 15 fields; +/// most are cosmetic ints emitted as strings (matches prod). Numeric fields +/// (viewer_id, rank, emblem_id, degree_id) ship as native ints. +/// +public sealed record FriendEntry( + int ViewerId, + string Name, + string CountryCode, + int Rank, + long EmblemId, + int DegreeId, + string LastPlayTime, // "yyyy-MM-dd HH:mm:ss" + string DeviceType, + string MaxFriend, + string IsReceivedTwoPickMission, + string Birth, + string MissionChangeTime, + string MissionReceiveType, + string IsOfficial, + string IsOfficialMarkDisplayed); + +/// +/// One friend apply (sent or received). Wire field id is the apply's PK. +/// +public sealed record FriendApplyEntry( + int Id, + int ViewerId, // OTHER viewer's id + string Name, + string CountryCode, + int Rank, + long EmblemId, + int DegreeId, + string LastPlayTime, + string CreateTime, + int MissionType); // 0 when omitted on the wire + +/// +/// One recent-opponent row. is computed at read time: +/// 0 = NO_ACTION, 1 = IS_FRIEND, 2 = IS_SEND (caller has outgoing apply), +/// 3 = IS_RECEIVED (caller has incoming apply from opponent). +/// is the relevant apply's PK when status is 2 or 3, else 0. +/// +public sealed record PlayedTogetherEntry( + int ViewerId, + string Name, + string CountryCode, + int Rank, + long EmblemId, + int DegreeId, + string LastPlayTime, + string PlayedTime, + int FriendStatus, + int FriendApplyId, + int PlayedMode, + int BattleType, + int DeckFormat, + int TwoPickType); + +public sealed record FriendInfoResult( + IReadOnlyList Friends, + int Count, + int MaxCount); + +public sealed record ReceiveApplyInfoResult( + IReadOnlyList ReceiveApplies, + int ApproveApplyCount); + +public sealed record SendApplyInfoResult( + IReadOnlyList SendApplies, + int RemainingApplyCount, + int SendApplyMaxCount); + +public sealed record PlayedTogetherResult( + IReadOnlyList Histories); + +/// Context recorded by . +public sealed record BattleParticipationContext( + int PlayedMode, + int BattleType, + int DeckFormat, + int TwoPickType); diff --git a/SVSim.Database/Services/Friend/FriendService.cs b/SVSim.Database/Services/Friend/FriendService.cs new file mode 100644 index 0000000..c235de1 --- /dev/null +++ b/SVSim.Database/Services/Friend/FriendService.cs @@ -0,0 +1,69 @@ +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 Task GetFriendsAsync(long viewerId, CancellationToken ct) => + throw new NotImplementedException(); + + public Task GetReceiveAppliesAsync(long viewerId, CancellationToken ct) => + throw new NotImplementedException(); + + public Task GetSendAppliesAsync(long viewerId, CancellationToken ct) => + throw new NotImplementedException(); + + public Task GetPlayedTogetherAsync(long viewerId, CancellationToken ct) => + throw new NotImplementedException(); + + public Task SearchAsync(long viewerId, int targetViewerId, CancellationToken ct) => + throw new NotImplementedException(); + + 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(); +} diff --git a/SVSim.Database/Services/Friend/IFriendService.cs b/SVSim.Database/Services/Friend/IFriendService.cs new file mode 100644 index 0000000..eafae28 --- /dev/null +++ b/SVSim.Database/Services/Friend/IFriendService.cs @@ -0,0 +1,30 @@ +namespace SVSim.Database.Services.Friend; + +public interface IFriendService +{ + Task GetFriendsAsync(long viewerId, CancellationToken ct); + Task GetReceiveAppliesAsync(long viewerId, CancellationToken ct); + Task GetSendAppliesAsync(long viewerId, CancellationToken ct); + Task GetPlayedTogetherAsync(long viewerId, CancellationToken ct); + + /// Returns null when not found, self-search, or any error. + Task SearchAsync(long viewerId, int targetViewerId, CancellationToken ct); + + /// No-op if target missing, self, already friends, already-pending apply, or at outgoing-apply cap. + Task SendApplyAsync(long viewerId, int targetViewerId, CancellationToken ct); + + /// No-op if apply not addressed to caller, would push either side past friend cap. Cleans reverse-direction apply if present. + Task ApproveApplyAsync(long viewerId, int applyId, CancellationToken ct); + + /// No-op if apply not addressed to caller. + Task RejectApplyAsync(long viewerId, int applyId, CancellationToken ct); + + /// No-op if apply not sent by caller. + Task CancelApplyAsync(long viewerId, int applyId, CancellationToken ct); + + Task RejectAllAppliesAsync(long viewerId, CancellationToken ct); + Task CancelAllAppliesAsync(long viewerId, CancellationToken ct); + + /// Deletes both directions of the friendship (A→B and B→A). + Task RejectFriendAsync(long viewerId, int targetViewerId, CancellationToken ct); +} diff --git a/SVSim.Database/Services/Friend/IPlayedTogetherWriter.cs b/SVSim.Database/Services/Friend/IPlayedTogetherWriter.cs new file mode 100644 index 0000000..8a56465 --- /dev/null +++ b/SVSim.Database/Services/Friend/IPlayedTogetherWriter.cs @@ -0,0 +1,11 @@ +namespace SVSim.Database.Services.Friend; + +/// +/// Records a recent-opponent entry on the owner viewer. Upserts the (owner, opponent) +/// row to PlayedAt = now, enforces a 50-row per-viewer retention cap by deleting the +/// owner's oldest row when at cap. No-op if owner equals opponent. +/// +public interface IPlayedTogetherWriter +{ + Task RecordAsync(long ownerViewerId, long opponentViewerId, BattleParticipationContext ctx, CancellationToken ct); +}