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) <noreply@anthropic.com>
This commit is contained in:
gamer147
2026-05-27 10:45:31 -04:00
parent 640a77ec6c
commit 5693ec0302
6 changed files with 27 additions and 18 deletions

View File

@@ -58,7 +58,7 @@ public class AchievementController : SVSimController
.AsSplitQuery() .AsSplitQuery()
.FirstAsync(v => v.Id == viewerId, ct); .FirstAsync(v => v.Id == viewerId, ct);
await _state.EnsureCurrentAsync(viewer, ct); await _state.EnsureCurrentAsync(viewer.Id, ct);
await _db.SaveChangesAsync(ct); await _db.SaveChangesAsync(ct);
// Re-read viewer's achievement for this type after state-service materialization. // Re-read viewer's achievement for this type after state-service materialization.

View File

@@ -1,5 +1,6 @@
using System.Text.Json; using System.Text.Json;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using SVSim.Database;
using SVSim.Database.Enums; using SVSim.Database.Enums;
using SVSim.Database.Models; using SVSim.Database.Models;
using SVSim.Database.Models.Config; using SVSim.Database.Models.Config;
@@ -47,11 +48,14 @@ public class LoadController : SVSimController
private readonly ICardAcquisitionService _acquisition; private readonly ICardAcquisitionService _acquisition;
private readonly IGameConfigService _config; private readonly IGameConfigService _config;
private readonly IBattlePassService _battlePass; private readonly IBattlePassService _battlePass;
private readonly IViewerMissionStateService _missionState;
private readonly SVSimDbContext _db;
public LoadController(IViewerRepository viewerRepository, ICardRepository cardRepository, public LoadController(IViewerRepository viewerRepository, ICardRepository cardRepository,
ICollectionRepository collectionRepository, IGlobalsRepository globalsRepository, ICollectionRepository collectionRepository, IGlobalsRepository globalsRepository,
ICardAcquisitionService acquisition, IGameConfigService config, ICardAcquisitionService acquisition, IGameConfigService config,
IBattlePassService battlePass) IBattlePassService battlePass, IViewerMissionStateService missionState,
SVSimDbContext db)
{ {
_viewerRepository = viewerRepository; _viewerRepository = viewerRepository;
_cardRepository = cardRepository; _cardRepository = cardRepository;
@@ -60,6 +64,8 @@ public class LoadController : SVSimController
_acquisition = acquisition; _acquisition = acquisition;
_config = config; _config = config;
_battlePass = battlePass; _battlePass = battlePass;
_missionState = missionState;
_db = db;
} }
[HttpPost("index")] [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, // (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. // the response payload would be one /load/index behind on newly-granted cosmetics.
await _acquisition.BackfillCosmeticsAsync(viewer.Id); 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); viewer = await _viewerRepository.GetViewerByShortUdid(shortUdid);
if (viewer is null) if (viewer is null)
{ {

View File

@@ -49,7 +49,7 @@ public class MissionController : SVSimController
if (!TryGetViewerId(out long viewerId)) return Unauthorized(); if (!TryGetViewerId(out long viewerId)) return Unauthorized();
var viewer = await LoadViewer(viewerId, ct); var viewer = await LoadViewer(viewerId, ct);
await _state.EnsureCurrentAsync(viewer, ct); await _state.EnsureCurrentAsync(viewer.Id, ct);
await _db.SaveChangesAsync(ct); await _db.SaveChangesAsync(ct);
var dto = await _assembler.BuildAsync(viewer, ct); var dto = await _assembler.BuildAsync(viewer, ct);

View File

@@ -1,12 +1,12 @@
using SVSim.Database.Models;
namespace SVSim.EmulatedEntrypoint.Services; namespace SVSim.EmulatedEntrypoint.Services;
/// <summary> /// <summary>
/// Lazy-initializes viewer mission/achievement state. Idempotent. Called from /// Lazy-initializes viewer mission/achievement state. Idempotent. Called from
/// LoadController on every /load/index and as belt-and-braces from /mission/info. /// 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.
/// </summary> /// </summary>
public interface IViewerMissionStateService public interface IViewerMissionStateService
{ {
Task EnsureCurrentAsync(Viewer viewer, CancellationToken ct = default); Task EnsureCurrentAsync(long viewerId, CancellationToken ct = default);
} }

View File

@@ -25,15 +25,13 @@ public sealed class ViewerMissionStateService : IViewerMissionStateService
_time = time; _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 var existingAchievements = await _viewerRepo.GetAchievementsAsync(viewerId, ct);
// double-tracked depending on caller's load path — don't trust them. var existingMissions = await _viewerRepo.GetMissionsAsync(viewerId, ct);
var existingAchievements = await _viewerRepo.GetAchievementsAsync(viewer.Id, ct);
var existingMissions = await _viewerRepo.GetMissionsAsync(viewer.Id, ct);
await MaterializeAchievementsAsync(viewer.Id, existingAchievements, ct); await MaterializeAchievementsAsync(viewerId, existingAchievements, ct);
await EnsureMissionSlotsAsync(viewer.Id, existingMissions, ct); await EnsureMissionSlotsAsync(viewerId, existingMissions, ct);
} }
private async Task MaterializeAchievementsAsync(long viewerId, List<ViewerAchievement> existing, CancellationToken ct) private async Task MaterializeAchievementsAsync(long viewerId, List<ViewerAchievement> existing, CancellationToken ct)

View File

@@ -44,7 +44,7 @@ public class ViewerMissionStateServiceTests
.FirstAsync(x => x.Id == vid); .FirstAsync(x => x.Id == vid);
var svc = scope.ServiceProvider.GetRequiredService<IViewerMissionStateService>(); var svc = scope.ServiceProvider.GetRequiredService<IViewerMissionStateService>();
await svc.EnsureCurrentAsync(viewer); await svc.EnsureCurrentAsync(viewer.Id);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
int catalogTypeCount = await db.AchievementCatalog int catalogTypeCount = await db.AchievementCatalog
@@ -67,10 +67,10 @@ public class ViewerMissionStateServiceTests
.FirstAsync(x => x.Id == vid); .FirstAsync(x => x.Id == vid);
var svc = scope.ServiceProvider.GetRequiredService<IViewerMissionStateService>(); var svc = scope.ServiceProvider.GetRequiredService<IViewerMissionStateService>();
await svc.EnsureCurrentAsync(viewer); await svc.EnsureCurrentAsync(viewer.Id);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
int after1 = await db.ViewerAchievements.CountAsync(a => a.ViewerId == vid); int after1 = await db.ViewerAchievements.CountAsync(a => a.ViewerId == vid);
await svc.EnsureCurrentAsync(viewer); await svc.EnsureCurrentAsync(viewer.Id);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
int after2 = await db.ViewerAchievements.CountAsync(a => a.ViewerId == vid); int after2 = await db.ViewerAchievements.CountAsync(a => a.ViewerId == vid);
Assert.That(after2, Is.EqualTo(after1)); Assert.That(after2, Is.EqualTo(after1));
@@ -90,7 +90,7 @@ public class ViewerMissionStateServiceTests
.FirstAsync(x => x.Id == vid); .FirstAsync(x => x.Id == vid);
var svc = scope.ServiceProvider.GetRequiredService<IViewerMissionStateService>(); var svc = scope.ServiceProvider.GetRequiredService<IViewerMissionStateService>();
await svc.EnsureCurrentAsync(viewer); await svc.EnsureCurrentAsync(viewer.Id);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
var slots = await db.ViewerMissions var slots = await db.ViewerMissions
@@ -117,7 +117,7 @@ public class ViewerMissionStateServiceTests
.FirstAsync(x => x.Id == vid); .FirstAsync(x => x.Id == vid);
var svc = scope.ServiceProvider.GetRequiredService<IViewerMissionStateService>(); var svc = scope.ServiceProvider.GetRequiredService<IViewerMissionStateService>();
await svc.EnsureCurrentAsync(viewer); await svc.EnsureCurrentAsync(viewer.Id);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
var weeklyIds = await db.ViewerMissions var weeklyIds = await db.ViewerMissions