From c9534d8facc029c2919847379c8b3c36cf047dc2 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Wed, 27 May 2026 10:29:30 -0400 Subject: [PATCH] =?UTF-8?q?feat(missions):=20ViewerMissionStateService=20?= =?UTF-8?q?=E2=80=94=20lazy=20materialize=20achievements=20+=20assign=20sl?= =?UTF-8?q?ots?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- SVSim.EmulatedEntrypoint/Program.cs | 1 + .../Services/IViewerMissionStateService.cs | 12 ++ .../Services/ViewerMissionStateService.cs | 113 +++++++++++++++ .../ViewerMissionStateServiceTests.cs | 129 ++++++++++++++++++ 4 files changed, 255 insertions(+) create mode 100644 SVSim.EmulatedEntrypoint/Services/IViewerMissionStateService.cs create mode 100644 SVSim.EmulatedEntrypoint/Services/ViewerMissionStateService.cs create mode 100644 SVSim.UnitTests/Services/ViewerMissionStateServiceTests.cs diff --git a/SVSim.EmulatedEntrypoint/Program.cs b/SVSim.EmulatedEntrypoint/Program.cs index b730a7e..19c19a0 100644 --- a/SVSim.EmulatedEntrypoint/Program.cs +++ b/SVSim.EmulatedEntrypoint/Program.cs @@ -93,6 +93,7 @@ public class Program builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddSingleton(TimeProvider.System); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/SVSim.EmulatedEntrypoint/Services/IViewerMissionStateService.cs b/SVSim.EmulatedEntrypoint/Services/IViewerMissionStateService.cs new file mode 100644 index 0000000..7acc73a --- /dev/null +++ b/SVSim.EmulatedEntrypoint/Services/IViewerMissionStateService.cs @@ -0,0 +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. +/// +public interface IViewerMissionStateService +{ + Task EnsureCurrentAsync(Viewer viewer, CancellationToken ct = default); +} diff --git a/SVSim.EmulatedEntrypoint/Services/ViewerMissionStateService.cs b/SVSim.EmulatedEntrypoint/Services/ViewerMissionStateService.cs new file mode 100644 index 0000000..26c27bd --- /dev/null +++ b/SVSim.EmulatedEntrypoint/Services/ViewerMissionStateService.cs @@ -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 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 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, + }); + } + } + } + } +} diff --git a/SVSim.UnitTests/Services/ViewerMissionStateServiceTests.cs b/SVSim.UnitTests/Services/ViewerMissionStateServiceTests.cs new file mode 100644 index 0000000..67814e2 --- /dev/null +++ b/SVSim.UnitTests/Services/ViewerMissionStateServiceTests.cs @@ -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(); + await new MissionCatalogImporter().ImportAsync(db, SeedDir); + await new AchievementCatalogImporter().ImportAsync(db, SeedDir); + } + + private static async Task SeedViewer(IServiceProvider sp) + { + using var scope = sp.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + 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(); + var viewer = await db.Viewers + .Include(x => x.Achievements).Include(x => x.Missions) + .FirstAsync(x => x.Id == vid); + + var svc = scope.ServiceProvider.GetRequiredService(); + 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(); + var viewer = await db.Viewers + .Include(x => x.Achievements).Include(x => x.Missions) + .FirstAsync(x => x.Id == vid); + + var svc = scope.ServiceProvider.GetRequiredService(); + 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(); + var viewer = await db.Viewers + .Include(x => x.Missions).Include(x => x.Achievements) + .FirstAsync(x => x.Id == vid); + + var svc = scope.ServiceProvider.GetRequiredService(); + 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(); + var viewer = await db.Viewers + .Include(x => x.Missions).Include(x => x.Achievements) + .FirstAsync(x => x.Id == vid); + + var svc = scope.ServiceProvider.GetRequiredService(); + 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"); + } +}