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:
@@ -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.
|
||||||
|
|||||||
@@ -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)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user