feat(missions): ViewerMissionStateService — lazy materialize achievements + assign slots

Reads existing state from DB on each call (don't trust navigation
property — caller may pass it stale or double-tracked). Adds via DbSet
only, not via navigation property, to avoid EF double-tracking.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gamer147
2026-05-27 10:29:30 -04:00
parent aad604a589
commit c9534d8fac
4 changed files with 255 additions and 0 deletions

View File

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

View File

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

View File

@@ -0,0 +1,113 @@
using SVSim.Database.Models;
using SVSim.Database.Repositories.Mission;
namespace SVSim.EmulatedEntrypoint.Services;
public sealed class ViewerMissionStateService : IViewerMissionStateService
{
private const int DailySlot = 0;
private const int WeeklySlotStart = 1;
private const int WeeklySlotCount = 3;
private const int LotTypeDaily = 6;
private const int LotTypeWeekly = 2;
private readonly IMissionCatalogRepository _catalog;
private readonly IViewerMissionRepository _viewerRepo;
private readonly TimeProvider _time;
public ViewerMissionStateService(
IMissionCatalogRepository catalog,
IViewerMissionRepository viewerRepo,
TimeProvider time)
{
_catalog = catalog;
_viewerRepo = viewerRepo;
_time = time;
}
public async Task EnsureCurrentAsync(Viewer viewer, 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);
await MaterializeAchievementsAsync(viewer.Id, existingAchievements, ct);
await EnsureMissionSlotsAsync(viewer.Id, existingMissions, ct);
}
private async Task MaterializeAchievementsAsync(long viewerId, List<ViewerAchievement> existing, CancellationToken ct)
{
var catalogTypes = await _catalog.GetAllAchievementTypesAsync(ct);
if (catalogTypes.Count == 0) return;
var existingTypes = existing.Select(a => a.AchievementType).ToHashSet();
foreach (var type in catalogTypes)
{
if (existingTypes.Contains(type)) continue;
_viewerRepo.AddAchievement(new ViewerAchievement
{
ViewerId = viewerId,
AchievementType = type,
Level = 1,
AchievementStatus = 0,
NowAchievedLevel = 0,
ResultAnnounceSawLevel = 0,
});
}
}
private async Task EnsureMissionSlotsAsync(long viewerId, List<ViewerMission> existing, CancellationToken ct)
{
var bySlot = existing.ToDictionary(m => m.Slot);
var now = _time.GetUtcNow().ToUnixTimeSeconds();
// Daily slot (slot 0)
if (!bySlot.ContainsKey(DailySlot))
{
var pool = await _catalog.GetByLotTypeAsync(LotTypeDaily, ct);
if (pool.Count > 0)
{
var pick = pool[Random.Shared.Next(pool.Count)];
_viewerRepo.AddMission(new ViewerMission
{
ViewerId = viewerId,
MissionCatalogId = pick.Id,
Slot = DailySlot,
AssignedAt = now,
MissionStatus = 1,
});
}
}
// Weekly slots (1..3) — assign all-or-nothing for simplicity in v1.
bool weeklyNeedsAssignment = Enumerable.Range(WeeklySlotStart, WeeklySlotCount)
.Any(s => !bySlot.ContainsKey(s));
if (weeklyNeedsAssignment)
{
var pool = await _catalog.GetByLotTypeAsync(LotTypeWeekly, ct);
if (pool.Count >= WeeklySlotCount)
{
var alreadyAssigned = existing
.Where(m => m.Slot >= WeeklySlotStart && m.Slot < WeeklySlotStart + WeeklySlotCount)
.Select(m => m.MissionCatalogId).ToHashSet();
var available = pool.Where(p => !alreadyAssigned.Contains(p.Id)).ToList();
var shuffled = available.OrderBy(_ => Random.Shared.Next()).ToList();
int pickIdx = 0;
for (int slot = WeeklySlotStart; slot < WeeklySlotStart + WeeklySlotCount; slot++)
{
if (bySlot.ContainsKey(slot)) continue;
if (pickIdx >= shuffled.Count) break;
_viewerRepo.AddMission(new ViewerMission
{
ViewerId = viewerId,
MissionCatalogId = shuffled[pickIdx++].Id,
Slot = slot,
AssignedAt = now,
MissionStatus = 1,
});
}
}
}
}
}