diff --git a/SVSim.EmulatedEntrypoint/Program.cs b/SVSim.EmulatedEntrypoint/Program.cs index 8d06003..b730a7e 100644 --- a/SVSim.EmulatedEntrypoint/Program.cs +++ b/SVSim.EmulatedEntrypoint/Program.cs @@ -88,6 +88,11 @@ public class Program builder.Services.AddScoped(); builder.Services.AddScoped(); + 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/IMissionProgressService.cs b/SVSim.EmulatedEntrypoint/Services/IMissionProgressService.cs new file mode 100644 index 0000000..1595435 --- /dev/null +++ b/SVSim.EmulatedEntrypoint/Services/IMissionProgressService.cs @@ -0,0 +1,17 @@ +namespace SVSim.EmulatedEntrypoint.Services; + +/// +/// Single primitive for "something happened that mission/achievement progress should react to." +/// Emitters at battle/story finish call this with the FULL list of event keys their event matches +/// at all granularity levels (broad → narrow). Service does dumb exact-match against catalog. +/// +public interface IMissionProgressService +{ + /// Viewer the event applies to. + /// Broad-to-narrow keys. Example: a swordcraft ranked win + /// passes ["ranked_win", "ranked_win:swordcraft"]. The order doesn't matter for counter + /// increments (each key gets its own counter) but the order is the conventional one used + /// by emitters for readability. + /// Count delta per key (default 1). + Task RecordEventAsync(long viewerId, IReadOnlyList eventKeys, int delta = 1, CancellationToken ct = default); +} diff --git a/SVSim.EmulatedEntrypoint/Services/MissionProgressService.cs b/SVSim.EmulatedEntrypoint/Services/MissionProgressService.cs new file mode 100644 index 0000000..8181dec --- /dev/null +++ b/SVSim.EmulatedEntrypoint/Services/MissionProgressService.cs @@ -0,0 +1,62 @@ +using SVSim.Database; +using SVSim.Database.Repositories.Mission; + +namespace SVSim.EmulatedEntrypoint.Services; + +public sealed class MissionProgressService : IMissionProgressService +{ + private readonly SVSimDbContext _db; + private readonly IMissionCatalogRepository _catalog; + private readonly IViewerMissionRepository _viewerRepo; + private readonly TimeProvider _time; + + public MissionProgressService( + SVSimDbContext db, + IMissionCatalogRepository catalog, + IViewerMissionRepository viewerRepo, + TimeProvider time) + { + _db = db; + _catalog = catalog; + _viewerRepo = viewerRepo; + _time = time; + } + + public async Task RecordEventAsync(long viewerId, IReadOnlyList eventKeys, int delta = 1, CancellationToken ct = default) + { + if (eventKeys.Count == 0) return; + var now = _time.GetUtcNow(); + var periods = JstPeriod.AllPeriods(now); + + // 1. Increment counters for every (key, period). + foreach (var key in eventKeys) + { + foreach (var period in periods) + { + await _viewerRepo.UpsertCounterAsync(viewerId, key, period, delta, ct); + } + } + await _db.SaveChangesAsync(ct); + + // 2. Find catalog rows referencing any of these event keys; mark claimable on threshold. + var achievements = await _catalog.GetAchievementsByEventTypesAsync(eventKeys, ct); + if (achievements.Count > 0) + { + var byType = achievements.GroupBy(a => a.AchievementType).ToDictionary(g => g.Key, g => g.ToList()); + foreach (var (achType, catalogRows) in byType) + { + var viewerRow = await _viewerRepo.GetAchievementAsync(viewerId, achType, ct); + if (viewerRow is null) continue; + var atLevel = catalogRows.FirstOrDefault(r => r.Level == viewerRow.Level); + if (atLevel is null || atLevel.EventType is null) continue; + + var count = await _viewerRepo.GetCounterAsync(viewerId, atLevel.EventType, JstPeriod.AllTime, ct); + if (count >= atLevel.RequireNumber && viewerRow.AchievementStatus == 0) + { + viewerRow.AchievementStatus = 1; + } + } + await _db.SaveChangesAsync(ct); + } + } +} diff --git a/SVSim.UnitTests/Services/MissionProgressServiceTests.cs b/SVSim.UnitTests/Services/MissionProgressServiceTests.cs new file mode 100644 index 0000000..02ea1ee --- /dev/null +++ b/SVSim.UnitTests/Services/MissionProgressServiceTests.cs @@ -0,0 +1,127 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using SVSim.Database; +using SVSim.Database.Models; +using SVSim.EmulatedEntrypoint.Services; +using SVSim.UnitTests.Infrastructure; + +namespace SVSim.UnitTests.Services; + +public class MissionProgressServiceTests +{ + 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 RecordEvent_increments_all_four_periods() + { + using var factory = new SVSimTestFactory(); + long vid = await SeedViewer(factory.Services); + + using var scope = factory.Services.CreateScope(); + var svc = scope.ServiceProvider.GetRequiredService(); + await svc.RecordEventAsync(vid, new[] { "ranked_win" }); + + var db = scope.ServiceProvider.GetRequiredService(); + var counters = await db.ViewerEventCounters + .Where(c => c.ViewerId == vid && c.EventKey == "ranked_win").ToListAsync(); + Assert.That(counters.Count, Is.EqualTo(4), + "expect day + week + month + all-time rows"); + Assert.That(counters.All(c => c.Count == 1)); + Assert.That(counters.Select(c => c.Period.Split(':')[0]).OrderBy(s => s), + Is.EquivalentTo(new[] { "all-time", "day", "month", "week" })); + } + + [Test] + public async Task RecordEvent_with_multiple_keys_increments_each_independently() + { + using var factory = new SVSimTestFactory(); + long vid = await SeedViewer(factory.Services); + + using var scope = factory.Services.CreateScope(); + var svc = scope.ServiceProvider.GetRequiredService(); + await svc.RecordEventAsync(vid, new[] { "ranked_win", "ranked_win:swordcraft" }); + + var db = scope.ServiceProvider.GetRequiredService(); + int broad = await db.ViewerEventCounters.CountAsync( + c => c.ViewerId == vid && c.EventKey == "ranked_win"); + int narrow = await db.ViewerEventCounters.CountAsync( + c => c.ViewerId == vid && c.EventKey == "ranked_win:swordcraft"); + Assert.That(broad, Is.EqualTo(4)); + Assert.That(narrow, Is.EqualTo(4)); + } + + [Test] + public async Task RecordEvent_increments_existing_counter() + { + using var factory = new SVSimTestFactory(); + long vid = await SeedViewer(factory.Services); + + using (var scope1 = factory.Services.CreateScope()) + { + var svc = scope1.ServiceProvider.GetRequiredService(); + await svc.RecordEventAsync(vid, new[] { "ranked_win" }); + } + using (var scope2 = factory.Services.CreateScope()) + { + var svc = scope2.ServiceProvider.GetRequiredService(); + await svc.RecordEventAsync(vid, new[] { "ranked_win" }); + } + + using var verifyScope = factory.Services.CreateScope(); + var db = verifyScope.ServiceProvider.GetRequiredService(); + var allTime = await db.ViewerEventCounters.FirstAsync( + c => c.ViewerId == vid && c.EventKey == "ranked_win" && c.Period == "all-time"); + Assert.That(allTime.Count, Is.EqualTo(2)); + } + + [Test] + public async Task RecordEvent_marks_achievement_claimable_when_threshold_hit() + { + using var factory = new SVSimTestFactory(); + long vid = await SeedViewer(factory.Services); + + using (var setupScope = factory.Services.CreateScope()) + { + var db = setupScope.ServiceProvider.GetRequiredService(); + // Seed the catalog row the service will look up. (Test factory doesn't run importers.) + db.AchievementCatalog.Add(new AchievementCatalogEntry + { + AchievementType = 31, Level = 3, Name = "Win 50 ranked matches", + RequireNumber = 50, RewardType = 9, RewardDetailId = 0, RewardNumber = 20, + OrderNum = 18, EventType = "ranked_win", EventArg = null, + }); + // Set viewer's current level to 3. + db.ViewerAchievements.Add(new ViewerAchievement + { + ViewerId = vid, AchievementType = 31, Level = 3, AchievementStatus = 0, + NowAchievedLevel = 0, ResultAnnounceSawLevel = 0, + }); + // Pre-set 49 wins so one more hits threshold. + db.ViewerEventCounters.Add(new ViewerEventCounter + { + ViewerId = vid, EventKey = "ranked_win", Period = "all-time", Count = 49, + }); + await db.SaveChangesAsync(); + } + + using (var actScope = factory.Services.CreateScope()) + { + var svc = actScope.ServiceProvider.GetRequiredService(); + await svc.RecordEventAsync(vid, new[] { "ranked_win" }); + } + + using var verifyScope = factory.Services.CreateScope(); + var verifyDb = verifyScope.ServiceProvider.GetRequiredService(); + var refreshed = await verifyDb.ViewerAchievements.FirstAsync( + a => a.ViewerId == vid && a.AchievementType == 31); + Assert.That(refreshed.AchievementStatus, Is.EqualTo(1), "should be claimable after threshold"); + } +}