From 5693ec0302a006c639736cd138007cae55336fa2 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Wed, 27 May 2026 10:45:31 -0400 Subject: [PATCH] feat(missions): /load/index materializes viewer mission/achievement state EnsureCurrentAsync now takes viewerId (was Viewer), so it works with LoadController's AsNoTracking-loaded detached viewers. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Controllers/AchievementController.cs | 2 +- .../Controllers/LoadController.cs | 13 ++++++++++++- .../Controllers/MissionController.cs | 2 +- .../Services/IViewerMissionStateService.cs | 6 +++--- .../Services/ViewerMissionStateService.cs | 12 +++++------- .../Services/ViewerMissionStateServiceTests.cs | 10 +++++----- 6 files changed, 27 insertions(+), 18 deletions(-) diff --git a/SVSim.EmulatedEntrypoint/Controllers/AchievementController.cs b/SVSim.EmulatedEntrypoint/Controllers/AchievementController.cs index 857b4c9..8c186b7 100644 --- a/SVSim.EmulatedEntrypoint/Controllers/AchievementController.cs +++ b/SVSim.EmulatedEntrypoint/Controllers/AchievementController.cs @@ -58,7 +58,7 @@ public class AchievementController : SVSimController .AsSplitQuery() .FirstAsync(v => v.Id == viewerId, ct); - await _state.EnsureCurrentAsync(viewer, ct); + await _state.EnsureCurrentAsync(viewer.Id, ct); await _db.SaveChangesAsync(ct); // Re-read viewer's achievement for this type after state-service materialization. diff --git a/SVSim.EmulatedEntrypoint/Controllers/LoadController.cs b/SVSim.EmulatedEntrypoint/Controllers/LoadController.cs index 0502b3a..d0f6f6b 100644 --- a/SVSim.EmulatedEntrypoint/Controllers/LoadController.cs +++ b/SVSim.EmulatedEntrypoint/Controllers/LoadController.cs @@ -1,5 +1,6 @@ using System.Text.Json; using Microsoft.AspNetCore.Mvc; +using SVSim.Database; using SVSim.Database.Enums; using SVSim.Database.Models; using SVSim.Database.Models.Config; @@ -47,11 +48,14 @@ public class LoadController : SVSimController private readonly ICardAcquisitionService _acquisition; private readonly IGameConfigService _config; private readonly IBattlePassService _battlePass; + private readonly IViewerMissionStateService _missionState; + private readonly SVSimDbContext _db; public LoadController(IViewerRepository viewerRepository, ICardRepository cardRepository, ICollectionRepository collectionRepository, IGlobalsRepository globalsRepository, ICardAcquisitionService acquisition, IGameConfigService config, - IBattlePassService battlePass) + IBattlePassService battlePass, IViewerMissionStateService missionState, + SVSimDbContext db) { _viewerRepository = viewerRepository; _cardRepository = cardRepository; @@ -60,6 +64,8 @@ public class LoadController : SVSimController _acquisition = acquisition; _config = config; _battlePass = battlePass; + _missionState = missionState; + _db = db; } [HttpPost("index")] @@ -83,6 +89,11 @@ public class LoadController : SVSimController // (on a separate tracked instance) won't appear on this snapshot. Without the re-fetch, // the response payload would be one /load/index behind on newly-granted cosmetics. await _acquisition.BackfillCosmeticsAsync(viewer.Id); + + // Lazy-materialize mission/achievement state. Idempotent — safe to call every /load/index. + await _missionState.EnsureCurrentAsync(viewer.Id); + await _db.SaveChangesAsync(); + viewer = await _viewerRepository.GetViewerByShortUdid(shortUdid); if (viewer is null) { diff --git a/SVSim.EmulatedEntrypoint/Controllers/MissionController.cs b/SVSim.EmulatedEntrypoint/Controllers/MissionController.cs index e45fed2..0d729f9 100644 --- a/SVSim.EmulatedEntrypoint/Controllers/MissionController.cs +++ b/SVSim.EmulatedEntrypoint/Controllers/MissionController.cs @@ -49,7 +49,7 @@ public class MissionController : SVSimController if (!TryGetViewerId(out long viewerId)) return Unauthorized(); var viewer = await LoadViewer(viewerId, ct); - await _state.EnsureCurrentAsync(viewer, ct); + await _state.EnsureCurrentAsync(viewer.Id, ct); await _db.SaveChangesAsync(ct); var dto = await _assembler.BuildAsync(viewer, ct); diff --git a/SVSim.EmulatedEntrypoint/Services/IViewerMissionStateService.cs b/SVSim.EmulatedEntrypoint/Services/IViewerMissionStateService.cs index 7acc73a..d1086c7 100644 --- a/SVSim.EmulatedEntrypoint/Services/IViewerMissionStateService.cs +++ b/SVSim.EmulatedEntrypoint/Services/IViewerMissionStateService.cs @@ -1,12 +1,12 @@ -using SVSim.Database.Models; - namespace SVSim.EmulatedEntrypoint.Services; /// /// Lazy-initializes viewer mission/achievement state. Idempotent. Called from /// LoadController on every /load/index and as belt-and-braces from /mission/info. +/// Takes viewerId (not Viewer) so it works against both tracked and detached viewer loads. +/// Caller is responsible for SaveChangesAsync. /// public interface IViewerMissionStateService { - Task EnsureCurrentAsync(Viewer viewer, CancellationToken ct = default); + Task EnsureCurrentAsync(long viewerId, CancellationToken ct = default); } diff --git a/SVSim.EmulatedEntrypoint/Services/ViewerMissionStateService.cs b/SVSim.EmulatedEntrypoint/Services/ViewerMissionStateService.cs index 9e0059d..0fb928f 100644 --- a/SVSim.EmulatedEntrypoint/Services/ViewerMissionStateService.cs +++ b/SVSim.EmulatedEntrypoint/Services/ViewerMissionStateService.cs @@ -25,15 +25,13 @@ public sealed class ViewerMissionStateService : IViewerMissionStateService _time = time; } - public async Task EnsureCurrentAsync(Viewer viewer, CancellationToken ct = default) + public async Task EnsureCurrentAsync(long viewerId, CancellationToken ct = default) { - // Always read fresh from DB. Navigation properties on the passed viewer may be stale or - // double-tracked depending on caller's load path — don't trust them. - var existingAchievements = await _viewerRepo.GetAchievementsAsync(viewer.Id, ct); - var existingMissions = await _viewerRepo.GetMissionsAsync(viewer.Id, ct); + var existingAchievements = await _viewerRepo.GetAchievementsAsync(viewerId, ct); + var existingMissions = await _viewerRepo.GetMissionsAsync(viewerId, ct); - await MaterializeAchievementsAsync(viewer.Id, existingAchievements, ct); - await EnsureMissionSlotsAsync(viewer.Id, existingMissions, ct); + await MaterializeAchievementsAsync(viewerId, existingAchievements, ct); + await EnsureMissionSlotsAsync(viewerId, existingMissions, ct); } private async Task MaterializeAchievementsAsync(long viewerId, List existing, CancellationToken ct) diff --git a/SVSim.UnitTests/Services/ViewerMissionStateServiceTests.cs b/SVSim.UnitTests/Services/ViewerMissionStateServiceTests.cs index 67814e2..6602e37 100644 --- a/SVSim.UnitTests/Services/ViewerMissionStateServiceTests.cs +++ b/SVSim.UnitTests/Services/ViewerMissionStateServiceTests.cs @@ -44,7 +44,7 @@ public class ViewerMissionStateServiceTests .FirstAsync(x => x.Id == vid); var svc = scope.ServiceProvider.GetRequiredService(); - await svc.EnsureCurrentAsync(viewer); + await svc.EnsureCurrentAsync(viewer.Id); await db.SaveChangesAsync(); int catalogTypeCount = await db.AchievementCatalog @@ -67,10 +67,10 @@ public class ViewerMissionStateServiceTests .FirstAsync(x => x.Id == vid); var svc = scope.ServiceProvider.GetRequiredService(); - await svc.EnsureCurrentAsync(viewer); + await svc.EnsureCurrentAsync(viewer.Id); await db.SaveChangesAsync(); int after1 = await db.ViewerAchievements.CountAsync(a => a.ViewerId == vid); - await svc.EnsureCurrentAsync(viewer); + await svc.EnsureCurrentAsync(viewer.Id); await db.SaveChangesAsync(); int after2 = await db.ViewerAchievements.CountAsync(a => a.ViewerId == vid); Assert.That(after2, Is.EqualTo(after1)); @@ -90,7 +90,7 @@ public class ViewerMissionStateServiceTests .FirstAsync(x => x.Id == vid); var svc = scope.ServiceProvider.GetRequiredService(); - await svc.EnsureCurrentAsync(viewer); + await svc.EnsureCurrentAsync(viewer.Id); await db.SaveChangesAsync(); var slots = await db.ViewerMissions @@ -117,7 +117,7 @@ public class ViewerMissionStateServiceTests .FirstAsync(x => x.Id == vid); var svc = scope.ServiceProvider.GetRequiredService(); - await svc.EnsureCurrentAsync(viewer); + await svc.EnsureCurrentAsync(viewer.Id); await db.SaveChangesAsync(); var weeklyIds = await db.ViewerMissions