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 <noreply@anthropic.com>
This commit is contained in:
gamer147
2026-06-09 21:44:51 -04:00
parent f40ecb8ca7
commit 300eee36e9
4 changed files with 194 additions and 0 deletions

View File

@@ -0,0 +1,84 @@
namespace SVSim.Database.Services.Friend;
/// <summary>
/// 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.
/// </summary>
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);
/// <summary>
/// One friend apply (sent or received). Wire field <c>id</c> is the apply's PK.
/// </summary>
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
/// <summary>
/// One recent-opponent row. <see cref="FriendStatus"/> 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).
/// <see cref="FriendApplyId"/> is the relevant apply's PK when status is 2 or 3, else 0.
/// </summary>
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<FriendEntry> Friends,
int Count,
int MaxCount);
public sealed record ReceiveApplyInfoResult(
IReadOnlyList<FriendApplyEntry> ReceiveApplies,
int ApproveApplyCount);
public sealed record SendApplyInfoResult(
IReadOnlyList<FriendApplyEntry> SendApplies,
int RemainingApplyCount,
int SendApplyMaxCount);
public sealed record PlayedTogetherResult(
IReadOnlyList<PlayedTogetherEntry> Histories);
/// <summary>Context recorded by <see cref="IPlayedTogetherWriter.RecordAsync"/>.</summary>
public sealed record BattleParticipationContext(
int PlayedMode,
int BattleType,
int DeckFormat,
int TwoPickType);

View File

@@ -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<FriendService> _log;
public FriendService(SVSimDbContext db, ILogger<FriendService> log)
{
_db = db;
_log = log;
}
public Task<FriendInfoResult> GetFriendsAsync(long viewerId, CancellationToken ct) =>
throw new NotImplementedException();
public Task<ReceiveApplyInfoResult> GetReceiveAppliesAsync(long viewerId, CancellationToken ct) =>
throw new NotImplementedException();
public Task<SendApplyInfoResult> GetSendAppliesAsync(long viewerId, CancellationToken ct) =>
throw new NotImplementedException();
public Task<PlayedTogetherResult> GetPlayedTogetherAsync(long viewerId, CancellationToken ct) =>
throw new NotImplementedException();
public Task<FriendEntry?> 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();
}

View File

@@ -0,0 +1,30 @@
namespace SVSim.Database.Services.Friend;
public interface IFriendService
{
Task<FriendInfoResult> GetFriendsAsync(long viewerId, CancellationToken ct);
Task<ReceiveApplyInfoResult> GetReceiveAppliesAsync(long viewerId, CancellationToken ct);
Task<SendApplyInfoResult> GetSendAppliesAsync(long viewerId, CancellationToken ct);
Task<PlayedTogetherResult> GetPlayedTogetherAsync(long viewerId, CancellationToken ct);
/// <summary>Returns null when not found, self-search, or any error.</summary>
Task<FriendEntry?> SearchAsync(long viewerId, int targetViewerId, CancellationToken ct);
/// <summary>No-op if target missing, self, already friends, already-pending apply, or at outgoing-apply cap.</summary>
Task SendApplyAsync(long viewerId, int targetViewerId, CancellationToken ct);
/// <summary>No-op if apply not addressed to caller, would push either side past friend cap. Cleans reverse-direction apply if present.</summary>
Task ApproveApplyAsync(long viewerId, int applyId, CancellationToken ct);
/// <summary>No-op if apply not addressed to caller.</summary>
Task RejectApplyAsync(long viewerId, int applyId, CancellationToken ct);
/// <summary>No-op if apply not sent by caller.</summary>
Task CancelApplyAsync(long viewerId, int applyId, CancellationToken ct);
Task RejectAllAppliesAsync(long viewerId, CancellationToken ct);
Task CancelAllAppliesAsync(long viewerId, CancellationToken ct);
/// <summary>Deletes both directions of the friendship (A→B and B→A).</summary>
Task RejectFriendAsync(long viewerId, int targetViewerId, CancellationToken ct);
}

View File

@@ -0,0 +1,11 @@
namespace SVSim.Database.Services.Friend;
/// <summary>
/// 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.
/// </summary>
public interface IPlayedTogetherWriter
{
Task RecordAsync(long ownerViewerId, long opponentViewerId, BattleParticipationContext ctx, CancellationToken ct);
}