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:
@@ -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>();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
113
SVSim.EmulatedEntrypoint/Services/ViewerMissionStateService.cs
Normal file
113
SVSim.EmulatedEntrypoint/Services/ViewerMissionStateService.cs
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
129
SVSim.UnitTests/Services/ViewerMissionStateServiceTests.cs
Normal file
129
SVSim.UnitTests/Services/ViewerMissionStateServiceTests.cs
Normal file
@@ -0,0 +1,129 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using SVSim.Bootstrap.Importers;
|
||||
using SVSim.Database;
|
||||
using SVSim.Database.Models;
|
||||
using SVSim.EmulatedEntrypoint.Services;
|
||||
using SVSim.UnitTests.Infrastructure;
|
||||
|
||||
namespace SVSim.UnitTests.Services;
|
||||
|
||||
public class ViewerMissionStateServiceTests
|
||||
{
|
||||
private static string SeedDir => Path.Combine(AppContext.BaseDirectory, "Data", "seeds");
|
||||
|
||||
private static async Task ImportCatalogsAsync(IServiceProvider sp)
|
||||
{
|
||||
using var scope = sp.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
await new MissionCatalogImporter().ImportAsync(db, SeedDir);
|
||||
await new AchievementCatalogImporter().ImportAsync(db, SeedDir);
|
||||
}
|
||||
|
||||
private static async Task<long> SeedViewer(IServiceProvider sp)
|
||||
{
|
||||
using var scope = sp.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
var v = new Viewer { DisplayName = "test", ShortUdid = 1, LastLogin = DateTime.UtcNow };
|
||||
db.Viewers.Add(v);
|
||||
await db.SaveChangesAsync();
|
||||
return v.Id;
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task EnsureCurrent_creates_one_achievement_per_catalog_type()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
await ImportCatalogsAsync(factory.Services);
|
||||
long vid = await SeedViewer(factory.Services);
|
||||
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
var viewer = await db.Viewers
|
||||
.Include(x => x.Achievements).Include(x => x.Missions)
|
||||
.FirstAsync(x => x.Id == vid);
|
||||
|
||||
var svc = scope.ServiceProvider.GetRequiredService<IViewerMissionStateService>();
|
||||
await svc.EnsureCurrentAsync(viewer);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
int catalogTypeCount = await db.AchievementCatalog
|
||||
.Select(e => e.AchievementType).Distinct().CountAsync();
|
||||
int viewerCount = await db.ViewerAchievements.CountAsync(a => a.ViewerId == vid);
|
||||
Assert.That(viewerCount, Is.EqualTo(catalogTypeCount));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task EnsureCurrent_is_idempotent()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
await ImportCatalogsAsync(factory.Services);
|
||||
long vid = await SeedViewer(factory.Services);
|
||||
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
var viewer = await db.Viewers
|
||||
.Include(x => x.Achievements).Include(x => x.Missions)
|
||||
.FirstAsync(x => x.Id == vid);
|
||||
|
||||
var svc = scope.ServiceProvider.GetRequiredService<IViewerMissionStateService>();
|
||||
await svc.EnsureCurrentAsync(viewer);
|
||||
await db.SaveChangesAsync();
|
||||
int after1 = await db.ViewerAchievements.CountAsync(a => a.ViewerId == vid);
|
||||
await svc.EnsureCurrentAsync(viewer);
|
||||
await db.SaveChangesAsync();
|
||||
int after2 = await db.ViewerAchievements.CountAsync(a => a.ViewerId == vid);
|
||||
Assert.That(after2, Is.EqualTo(after1));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task EnsureCurrent_assigns_daily_and_weekly_slots_from_pool()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
await ImportCatalogsAsync(factory.Services);
|
||||
long vid = await SeedViewer(factory.Services);
|
||||
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
var viewer = await db.Viewers
|
||||
.Include(x => x.Missions).Include(x => x.Achievements)
|
||||
.FirstAsync(x => x.Id == vid);
|
||||
|
||||
var svc = scope.ServiceProvider.GetRequiredService<IViewerMissionStateService>();
|
||||
await svc.EnsureCurrentAsync(viewer);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var slots = await db.ViewerMissions
|
||||
.Where(m => m.ViewerId == vid).OrderBy(m => m.Slot).ToListAsync();
|
||||
Assert.That(slots.Count, Is.EqualTo(4), "1 daily + 3 weekly");
|
||||
Assert.That(slots.Select(s => s.Slot), Is.EquivalentTo(new[] { 0, 1, 2, 3 }));
|
||||
|
||||
var dailyCatalogId = slots[0].MissionCatalogId;
|
||||
var dailyCatalog = await db.MissionCatalog.FindAsync(dailyCatalogId);
|
||||
Assert.That(dailyCatalog!.LotType, Is.EqualTo(6), "slot 0 = daily, lot_type 6");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task EnsureCurrent_picks_distinct_weekly_missions()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
await ImportCatalogsAsync(factory.Services);
|
||||
long vid = await SeedViewer(factory.Services);
|
||||
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
var viewer = await db.Viewers
|
||||
.Include(x => x.Missions).Include(x => x.Achievements)
|
||||
.FirstAsync(x => x.Id == vid);
|
||||
|
||||
var svc = scope.ServiceProvider.GetRequiredService<IViewerMissionStateService>();
|
||||
await svc.EnsureCurrentAsync(viewer);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var weeklyIds = await db.ViewerMissions
|
||||
.Where(m => m.ViewerId == vid && m.Slot != 0)
|
||||
.Select(m => m.MissionCatalogId).ToListAsync();
|
||||
Assert.That(weeklyIds.Distinct().Count(), Is.EqualTo(weeklyIds.Count),
|
||||
"weekly slots must have distinct catalog ids");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user