From 574e9ca58b3a3a9dd30c39aa95afa9975af47447 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Wed, 27 May 2026 10:31:38 -0400 Subject: [PATCH] =?UTF-8?q?feat(missions):=20MissionAssembler=20=E2=80=94?= =?UTF-8?q?=20single=20DTO=20builder=20reused=20by=20all=204=20endpoints?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tests intentionally deferred to controller integration tests (Tasks 18-21) which exercise the assembler end-to-end via the wire. Co-Authored-By: Claude Opus 4.7 (1M context) --- SVSim.EmulatedEntrypoint/Program.cs | 1 + .../Services/IMissionAssembler.cs | 13 ++ .../Services/MissionAssembler.cs | 179 ++++++++++++++++++ 3 files changed, 193 insertions(+) create mode 100644 SVSim.EmulatedEntrypoint/Services/IMissionAssembler.cs create mode 100644 SVSim.EmulatedEntrypoint/Services/MissionAssembler.cs diff --git a/SVSim.EmulatedEntrypoint/Program.cs b/SVSim.EmulatedEntrypoint/Program.cs index 19c19a0..cd2a3b7 100644 --- a/SVSim.EmulatedEntrypoint/Program.cs +++ b/SVSim.EmulatedEntrypoint/Program.cs @@ -94,6 +94,7 @@ public class Program SVSim.Database.Repositories.Mission.ViewerMissionRepository>(); builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddSingleton(TimeProvider.System); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/SVSim.EmulatedEntrypoint/Services/IMissionAssembler.cs b/SVSim.EmulatedEntrypoint/Services/IMissionAssembler.cs new file mode 100644 index 0000000..c7f7c11 --- /dev/null +++ b/SVSim.EmulatedEntrypoint/Services/IMissionAssembler.cs @@ -0,0 +1,13 @@ +using SVSim.Database.Models; +using SVSim.EmulatedEntrypoint.Models.Dtos.Common.Mission; + +namespace SVSim.EmulatedEntrypoint.Services; + +/// +/// Builds the MissionInfoDataDto from (viewer, catalog, counters). One place — reused by +/// all four endpoints. Reads catalog through repos, batches counter reads. +/// +public interface IMissionAssembler +{ + Task BuildAsync(Viewer viewer, CancellationToken ct = default); +} diff --git a/SVSim.EmulatedEntrypoint/Services/MissionAssembler.cs b/SVSim.EmulatedEntrypoint/Services/MissionAssembler.cs new file mode 100644 index 0000000..cbd181e --- /dev/null +++ b/SVSim.EmulatedEntrypoint/Services/MissionAssembler.cs @@ -0,0 +1,179 @@ +using SVSim.Database.Models; +using SVSim.Database.Repositories.Mission; +using SVSim.EmulatedEntrypoint.Models.Dtos.Common.Mission; + +namespace SVSim.EmulatedEntrypoint.Services; + +public sealed class MissionAssembler : IMissionAssembler +{ + private readonly IMissionCatalogRepository _catalog; + private readonly IViewerMissionRepository _viewerRepo; + private readonly TimeProvider _time; + + public MissionAssembler( + IMissionCatalogRepository catalog, + IViewerMissionRepository viewerRepo, + TimeProvider time) + { + _catalog = catalog; + _viewerRepo = viewerRepo; + _time = time; + } + + public async Task BuildAsync(Viewer viewer, CancellationToken ct = default) + { + var now = _time.GetUtcNow(); + var dto = new MissionInfoDataDto(); + + // Read fresh state — don't trust navigation properties. + var viewerMissions = await _viewerRepo.GetMissionsAsync(viewer.Id, ct); + var viewerAchievements = await _viewerRepo.GetAchievementsAsync(viewer.Id, ct); + + var missionCatalogIds = viewerMissions.Select(m => m.MissionCatalogId).Distinct().ToList(); + var missionCatalog = missionCatalogIds.Count == 0 + ? new List() + : await _catalog.GetByIdsAsync(missionCatalogIds, ct); + var missionById = missionCatalog.ToDictionary(c => c.Id); + + var maxLevelByType = await _catalog.GetMaxLevelByAchievementTypeAsync(ct); + + // Gather all event keys we'll need to read counters for, in one batch. + var counterEventKeys = new HashSet(); + foreach (var m in viewerMissions) + { + if (missionById.TryGetValue(m.MissionCatalogId, out var cat) && cat.EventType is not null) + counterEventKeys.Add(cat.EventType); + } + // Achievements need catalog rows at viewer's current Level to find their EventType. + var achievementCatalogByKey = new Dictionary<(int, int), AchievementCatalogEntry>(); + foreach (var a in viewerAchievements) + { + var c = await _catalog.GetAchievementAsync(a.AchievementType, a.Level, ct); + if (c is null) continue; + achievementCatalogByKey[(a.AchievementType, a.Level)] = c; + if (c.EventType is not null) counterEventKeys.Add(c.EventType); + } + + // BP monthly missions for current JST month. + var jstNow = now.ToOffset(TimeSpan.FromHours(9)).AddHours(-2); + var monthlyMissions = await _catalog.GetMonthlyMissionsAsync(jstNow.Year, jstNow.Month, ct); + foreach (var mm in monthlyMissions) + { + if (mm.EventType is not null) counterEventKeys.Add(mm.EventType); + } + + var periods = new[] { JstPeriod.DayKey(now), JstPeriod.WeekKey(now), JstPeriod.MonthKey(now), JstPeriod.AllTime }; + var counters = counterEventKeys.Count == 0 + ? new List() + : await _viewerRepo.GetCountersAsync(viewer.Id, counterEventKeys.ToList(), periods, ct); + var counterLookup = counters.ToDictionary(c => (c.EventKey, c.Period), c => c.Count); + + int GetCounter(string eventKey, string period) => + counterLookup.TryGetValue((eventKey, period), out var v) ? v : 0; + + // user_mission_list + foreach (var m in viewerMissions.OrderBy(m => m.Slot)) + { + if (!missionById.TryGetValue(m.MissionCatalogId, out var cat)) continue; + int total = 0; + if (cat.EventType is not null) + { + string period = cat.LotType == 6 ? JstPeriod.DayKey(now) : JstPeriod.WeekKey(now); + total = GetCounter(cat.EventType, period); + } + dto.UserMissionList.Add(new UserMissionDto + { + Id = m.Id, + MissionId = cat.Id, + TotalCount = total, + MissionStatus = m.MissionStatus, + DisplayOrder = 0, + MissionName = cat.Name, + LotType = cat.LotType.ToString(), + BattlePassPoint = cat.BattlePassPoint.ToString(), + RequireNumber = cat.RequireNumber, + RewardType = cat.RewardType, + RewardDetailId = cat.RewardDetailId, + RewardNumber = cat.RewardNumber, + DefaultFlag = cat.DefaultFlag, + StartTime = cat.StartTime, + EndTime = cat.EndTime, + }); + } + + // user_achievement_list — one row per ViewerAchievement, looking up catalog at viewer's current Level. + foreach (var a in viewerAchievements.OrderBy(a => a.AchievementType)) + { + if (!achievementCatalogByKey.TryGetValue((a.AchievementType, a.Level), out var catalog)) continue; + int total = catalog.EventType is null ? 0 : GetCounter(catalog.EventType, JstPeriod.AllTime); + int maxLevel = maxLevelByType.TryGetValue(a.AchievementType, out var ml) ? ml : a.Level; + dto.UserAchievementList.Add(new UserAchievementDto + { + AchievementType = a.AchievementType, + AchievementStatus = a.AchievementStatus, + Level = a.Level, + NowAchievedLevel = a.NowAchievedLevel, + ResultAnnounceSawLevel = a.ResultAnnounceSawLevel, + TotalCount = total, + AchievementName = catalog.Name, + RequireNumber = catalog.RequireNumber, + RewardType = catalog.RewardType, + RewardDetailId = catalog.RewardDetailId, + RewardNumber = catalog.RewardNumber, + MaxLevel = maxLevel, + OrderNum = catalog.OrderNum, + Ios = "", + Android = "", + }); + } + + // Mission-change gating: viewer.MissionData.MissionChangeTime is when retire becomes available again. + bool canChange = viewer.MissionData.MissionChangeTime <= now.UtcDateTime; + dto.IsChangeMission = canChange; + dto.CanChangeMissionTime = canChange ? null + : new DateTimeOffset(viewer.MissionData.MissionChangeTime, TimeSpan.Zero).ToUnixTimeSeconds(); + + // Receive-type cooldown: v1 has none, always true/null. + dto.IsChangeReceiveType = true; + dto.CanChangeReceiveTypeTime = null; + + dto.MissionReceiveType = viewer.MissionData.MissionReceiveType.ToString(); + + // BP monthly missions block — omit when no rows for current month. + if (monthlyMissions.Count > 0) + { + var startUtc = new DateTimeOffset(jstNow.Year, jstNow.Month, 1, 2, 0, 0, TimeSpan.FromHours(9)); + var endUtc = startUtc.AddMonths(1).AddSeconds(-1); + dto.BattlePassMonthlyMission = new BPMonthlyMissionsDto + { + StartDate = startUtc.ToString("yyyy-MM-dd HH:mm:ss"), + EndDate = endUtc.ToString("yyyy-MM-dd HH:mm:ss"), + MissionList = monthlyMissions.Select(mm => + { + int done = mm.EventType is null ? 0 + : GetCounter(mm.EventType, JstPeriod.MonthKey(now)); + var entry = new BPMonthlyMissionDto + { + Name = mm.Name, + IsCleared = done >= mm.RequireNumber, + RequireNumber = mm.RequireNumber, + DoneNumber = done, + BattlePassPoint = mm.BattlePassPoint, + }; + if (mm.RewardType is not null) + { + entry.RewardInfo = new BPMonthlyMissionRewardInfoDto + { + RewardType = mm.RewardType.Value.ToString(), + RewardDetailId = (mm.RewardDetailId ?? 0).ToString(), + RewardNumber = (mm.RewardNumber ?? 0).ToString(), + }; + } + return entry; + }).ToList(), + }; + } + + return dto; + } +}