feat(missions): MissionAssembler — single DTO builder reused by all 4 endpoints

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) <noreply@anthropic.com>
This commit is contained in:
gamer147
2026-05-27 10:31:38 -04:00
parent df65b7a9c8
commit 574e9ca58b
3 changed files with 193 additions and 0 deletions

View File

@@ -94,6 +94,7 @@ public class Program
SVSim.Database.Repositories.Mission.ViewerMissionRepository>();
builder.Services.AddScoped<IMissionProgressService, MissionProgressService>();
builder.Services.AddScoped<IViewerMissionStateService, ViewerMissionStateService>();
builder.Services.AddScoped<IMissionAssembler, MissionAssembler>();
builder.Services.AddSingleton<TimeProvider>(TimeProvider.System);
builder.Services.AddScoped<IStoryMasterRepository, StoryMasterRepository>();
builder.Services.AddScoped<IViewerStoryProgressRepository, ViewerStoryProgressRepository>();

View File

@@ -0,0 +1,13 @@
using SVSim.Database.Models;
using SVSim.EmulatedEntrypoint.Models.Dtos.Common.Mission;
namespace SVSim.EmulatedEntrypoint.Services;
/// <summary>
/// Builds the MissionInfoDataDto from (viewer, catalog, counters). One place — reused by
/// all four endpoints. Reads catalog through repos, batches counter reads.
/// </summary>
public interface IMissionAssembler
{
Task<MissionInfoDataDto> BuildAsync(Viewer viewer, CancellationToken ct = default);
}

View File

@@ -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<MissionInfoDataDto> 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<MissionCatalogEntry>()
: 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<string>();
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<ViewerEventCounter>()
: 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;
}
}