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