diff --git a/SVSim.EmulatedEntrypoint/Controllers/AdminController.cs b/SVSim.EmulatedEntrypoint/Controllers/AdminController.cs index 1bee862c..254e7974 100644 --- a/SVSim.EmulatedEntrypoint/Controllers/AdminController.cs +++ b/SVSim.EmulatedEntrypoint/Controllers/AdminController.cs @@ -10,6 +10,7 @@ using SVSim.EmulatedEntrypoint.Extensions; using SVSim.EmulatedEntrypoint.Infrastructure; using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Admin; using SVSim.EmulatedEntrypoint.Models.Dtos.Responses.Admin; +using SVSim.EmulatedEntrypoint.Services; namespace SVSim.EmulatedEntrypoint.Controllers; @@ -79,6 +80,7 @@ public class AdminController : SVSimController .Include(v => v.Info).ThenInclude(i => i.SelectedDegree) .Include(v => v.Currency) .Include(v => v.MissionData) + .Include(v => v.Missions) .Include(v => v.Classes).ThenInclude(c => c.Class) .Include(v => v.Sleeves) .Include(v => v.Emblems) @@ -265,6 +267,60 @@ public class AdminController : SVSimController viewer.MissionData.MissionChangeTime = DateTimeOffset.FromUnixTimeSeconds(mct).UtcDateTime; } + // Pass B: Missions + ViewerEventCounter + var skippedMissionIds = new HashSet(); + var skippedMissionCounterIds = new HashSet(); + if (request.Missions is { } missions) + { + viewer.Missions.Clear(); + var missionIds = missions.Select(m => m.MissionId).Distinct().ToList(); + var catalogs = await _dbContext.MissionCatalog + .Where(c => missionIds.Contains(c.Id)) + .ToDictionaryAsync(c => c.Id); + var nowUtc = DateTimeOffset.UtcNow; + + foreach (var m in missions) + { + if (!catalogs.TryGetValue(m.MissionId, out var cat)) + { + skippedMissionIds.Add(m.MissionId); + continue; + } + viewer.Missions.Add(new ViewerMission + { + MissionCatalogId = cat.Id, + Slot = cat.LotType == 6 ? 0 : 1, + MissionStatus = m.MissionStatus, + AssignedAt = DateTimeOffset.UtcNow.ToUnixTimeSeconds(), + ClaimedAt = null + }); + + var resolved = ResolveMissionCounter(cat, nowUtc); + if (resolved is null) + { + skippedMissionCounterIds.Add(m.MissionId); + continue; + } + var (eventKey, period) = resolved.Value; + var existingCounter = await _dbContext.ViewerEventCounters + .FirstOrDefaultAsync(c => c.ViewerId == viewer.Id && c.EventKey == eventKey && c.Period == period); + if (existingCounter is null) + { + _dbContext.ViewerEventCounters.Add(new ViewerEventCounter + { + ViewerId = viewer.Id, + EventKey = eventKey, + Period = period, + Count = m.TotalCount + }); + } + else + { + existingCounter.Count = m.TotalCount; + } + } + } + await _dbContext.SaveChangesAsync(); if (skippedCardIds.Count > 0) @@ -275,6 +331,20 @@ public class AdminController : SVSimController request.SteamId, viewer.Id, skippedCardIds.Count, string.Join(", ", skippedCardIds.Take(20))); } + if (skippedMissionIds.Count > 0) + { + _logger.LogWarning( + "ImportViewer (steam_id={SteamId}, viewer_id={ViewerId}): skipped {Count} unknown mission_id(s). Sample: [{Sample}]", + request.SteamId, viewer.Id, skippedMissionIds.Count, + string.Join(", ", skippedMissionIds.Take(10))); + } + if (skippedMissionCounterIds.Count > 0) + { + _logger.LogWarning( + "ImportViewer (steam_id={SteamId}, viewer_id={ViewerId}): {Count} mission(s) had unresolvable EventKey, counter not written. Sample: [{Sample}]", + request.SteamId, viewer.Id, skippedMissionCounterIds.Count, + string.Join(", ", skippedMissionCounterIds.Take(10))); + } return new ImportViewerResponse { @@ -282,6 +352,8 @@ public class AdminController : SVSimController ShortUdid = viewer.ShortUdid, WasCreated = wasCreated, SkippedCardCount = skippedCardIds.Count, + SkippedMissionCount = skippedMissionIds.Count, + SkippedMissionCounterCount = skippedMissionCounterIds.Count, }; } @@ -300,6 +372,20 @@ public class AdminController : SVSimController owned.AddRange(rows); } + // TODO: unify with MissionAssembler.cs — same logic duplicated here. + private static (string EventKey, string Period)? ResolveMissionCounter(MissionCatalogEntry catalog, DateTimeOffset nowUtc) + { + if (string.IsNullOrEmpty(catalog.EventType)) return null; + var period = catalog.LotType switch + { + 6 => JstPeriod.DayKey(nowUtc), + 2 => JstPeriod.WeekKey(nowUtc), + _ => null + }; + if (period is null) return null; + return (catalog.EventType!, period); + } + /// /// Fallback sleeve id used when an imported deck has no resolvable sleeve_id. /// 3000011 is prod's default deck sleeve. diff --git a/SVSim.EmulatedEntrypoint/Models/Dtos/Requests/Admin/ImportViewerRequest.cs b/SVSim.EmulatedEntrypoint/Models/Dtos/Requests/Admin/ImportViewerRequest.cs index 340ec114..3f3a2bfa 100644 --- a/SVSim.EmulatedEntrypoint/Models/Dtos/Requests/Admin/ImportViewerRequest.cs +++ b/SVSim.EmulatedEntrypoint/Models/Dtos/Requests/Admin/ImportViewerRequest.cs @@ -35,6 +35,8 @@ public class ImportViewerRequest [JsonPropertyName("decks")] public List? Decks { get; set; } [JsonPropertyName("mission_meta")] public ImportMissionMeta? MissionMeta { get; set; } + + [JsonPropertyName("missions")] public List? Missions { get; set; } } public class ImportDeck @@ -81,6 +83,18 @@ public class ImportItem [JsonPropertyName("count")] public int Count { get; set; } } +public class ImportMission +{ + [JsonPropertyName("mission_id")] + public int MissionId { get; set; } + + [JsonPropertyName("mission_status")] + public int MissionStatus { get; set; } + + [JsonPropertyName("total_count")] + public int TotalCount { get; set; } +} + public class ImportMissionMeta { [JsonPropertyName("has_received_pick_two_mission")] diff --git a/SVSim.UnitTests/Controllers/AdminControllerTests.cs b/SVSim.UnitTests/Controllers/AdminControllerTests.cs index b8051d7d..8476344a 100644 --- a/SVSim.UnitTests/Controllers/AdminControllerTests.cs +++ b/SVSim.UnitTests/Controllers/AdminControllerTests.cs @@ -5,6 +5,7 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using SVSim.Database; using SVSim.Database.Enums; +using SVSim.Database.Models; using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Admin; using SVSim.EmulatedEntrypoint.Models.Dtos.Responses.Admin; using SVSim.UnitTests.Infrastructure; @@ -484,4 +485,104 @@ public class AdminControllerTests Assert.That(viewer.MissionData.MissionChangeTime, Is.EqualTo(DateTimeOffset.FromUnixTimeSeconds(1_700_000_000L).UtcDateTime)); } + + [Test] + public async Task ImportViewer_Missions_RoundTripsWithCounter() + { + using var factory = new SVSimTestFactory(); + using (var seedScope = factory.Services.CreateScope()) + { + var db = seedScope.ServiceProvider.GetRequiredService(); + db.MissionCatalog.Add(new MissionCatalogEntry + { + Id = 9001, LotType = 6 /* daily */, + EventType = "battle_win_total", EventArg = null + }); + await db.SaveChangesAsync(); + } + var client = factory.CreateClient(); + ulong steamId = 70000000000000002UL; + + var resp = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest + { + SteamId = steamId, + Missions = new List + { + new() { MissionId = 9001, MissionStatus = 1, TotalCount = 7 } + } + }); + resp.EnsureSuccessStatusCode(); + var body = await resp.Content.ReadFromJsonAsync(JsonOptions); + Assert.That(body!.SkippedMissionCount, Is.EqualTo(0)); + Assert.That(body.SkippedMissionCounterCount, Is.EqualTo(0)); + + using var scope = factory.Services.CreateScope(); + var verifyDb = scope.ServiceProvider.GetRequiredService(); + var viewerId = await verifyDb.Viewers + .Where(v => v.SocialAccountConnections + .Any(s => s.AccountType == SocialAccountType.Steam && s.AccountId == steamId)) + .Select(v => v.Id).SingleAsync(); + Assert.That(verifyDb.ViewerMissions.Count(m => m.ViewerId == viewerId && m.MissionCatalogId == 9001), Is.EqualTo(1)); + Assert.That(verifyDb.ViewerEventCounters + .Count(c => c.ViewerId == viewerId && c.EventKey == "battle_win_total" && c.Count == 7), Is.EqualTo(1)); + } + + [Test] + public async Task ImportViewer_Missions_UnknownMissionIdSkipped() + { + using var factory = new SVSimTestFactory(); + var client = factory.CreateClient(); + ulong steamId = 70000000000000003UL; + + var resp = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest + { + SteamId = steamId, + Missions = new List + { + new() { MissionId = 999999, MissionStatus = 1, TotalCount = 3 } + } + }); + resp.EnsureSuccessStatusCode(); + var body = await resp.Content.ReadFromJsonAsync(JsonOptions); + Assert.That(body!.SkippedMissionCount, Is.EqualTo(1)); + + using var scope = factory.Services.CreateScope(); + var verifyDb2 = scope.ServiceProvider.GetRequiredService(); + Assert.That(verifyDb2.ViewerMissions.Any(), Is.False); + } + + [Test] + public async Task ImportViewer_Missions_CatalogPresentButCounterUnresolvable() + { + using var factory = new SVSimTestFactory(); + using (var seedScope = factory.Services.CreateScope()) + { + var seedDb = seedScope.ServiceProvider.GetRequiredService(); + seedDb.MissionCatalog.Add(new MissionCatalogEntry + { + Id = 9002, LotType = 6, + EventType = null /* unresolvable */, EventArg = null + }); + await seedDb.SaveChangesAsync(); + } + var client = factory.CreateClient(); + ulong steamId = 70000000000000004UL; + + var resp = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest + { + SteamId = steamId, + Missions = new List + { + new() { MissionId = 9002, MissionStatus = 1, TotalCount = 5 } + } + }); + var body = await resp.Content.ReadFromJsonAsync(JsonOptions); + Assert.That(body!.SkippedMissionCount, Is.EqualTo(0)); + Assert.That(body.SkippedMissionCounterCount, Is.EqualTo(1)); + + using var scope = factory.Services.CreateScope(); + var verifyDb3 = scope.ServiceProvider.GetRequiredService(); + Assert.That(verifyDb3.ViewerMissions.Count(m => m.MissionCatalogId == 9002), Is.EqualTo(1)); + Assert.That(verifyDb3.ViewerEventCounters.Any(), Is.False); + } }