diff --git a/SVSim.EmulatedEntrypoint/Controllers/AdminController.cs b/SVSim.EmulatedEntrypoint/Controllers/AdminController.cs index 13282750..41151ce3 100644 --- a/SVSim.EmulatedEntrypoint/Controllers/AdminController.cs +++ b/SVSim.EmulatedEntrypoint/Controllers/AdminController.cs @@ -81,6 +81,7 @@ public class AdminController : SVSimController .Include(v => v.Currency) .Include(v => v.MissionData) .Include(v => v.Missions) + .Include(v => v.Achievements) .Include(v => v.Classes).ThenInclude(c => c.Class) .Include(v => v.Sleeves) .Include(v => v.Emblems) @@ -341,6 +342,81 @@ public class AdminController : SVSimController } } + // Pass C: Achievements + ViewerEventCounter + var skippedAchievementTypes = new HashSet(); + var skippedAchievementCounterTypes = new HashSet(); + if (request.Achievements is { } achievements) + { + viewer.Achievements.Clear(); + var types = achievements.Select(a => a.AchievementType).Distinct().ToList(); + var catalogs = await _dbContext.AchievementCatalog + .Where(c => types.Contains(c.AchievementType)) + .GroupBy(c => c.AchievementType) + .ToDictionaryAsync(g => g.Key, g => g.First()); + + // Resolve counter keys for all achievements up-front so we can load existing counters + // in one query and avoid duplicate-Add races when two achievements share the same + // EventType (same (EventKey, AllTime) cache key) within the same import batch. + var resolvedAchievementCounterKeys = new Dictionary(); + foreach (var a in achievements) + { + if (!catalogs.TryGetValue(a.AchievementType, out var cat)) continue; + var r = ResolveAchievementCounter(cat); + if (r is not null) + resolvedAchievementCounterKeys[a.AchievementType] = r.Value; + } + + // Collect all distinct (EventKey, Period) pairs we will touch. + var allAchEventKeys = resolvedAchievementCounterKeys.Values.Select(v => v.EventKey).Distinct().ToList(); + var allAchPeriods = resolvedAchievementCounterKeys.Values.Select(v => v.Period).Distinct().ToList(); + + // Load existing counters in one query. + var existingAchCounters = await _dbContext.ViewerEventCounters + .Where(c => c.ViewerId == viewer.Id + && allAchEventKeys.Contains(c.EventKey) + && allAchPeriods.Contains(c.Period)) + .ToListAsync(); + var achievementCounterCache = existingAchCounters + .ToDictionary(c => (c.EventKey, c.Period)); + + foreach (var a in achievements) + { + if (!catalogs.TryGetValue(a.AchievementType, out var cat)) + { + skippedAchievementTypes.Add(a.AchievementType); + continue; + } + viewer.Achievements.Add(new ViewerAchievement + { + ViewerId = viewer.Id, + AchievementType = a.AchievementType, + Level = a.Level, + NowAchievedLevel = a.NowAchievedLevel, + ResultAnnounceSawLevel = a.ResultAnnounceSawLevel, + AchievementStatus = 0 + }); + + if (!resolvedAchievementCounterKeys.TryGetValue(a.AchievementType, out var resolved)) + { + skippedAchievementCounterTypes.Add(a.AchievementType); + continue; + } + var (eventKey, period) = resolved; + if (!achievementCounterCache.TryGetValue((eventKey, period), out var counter)) + { + counter = new ViewerEventCounter + { + ViewerId = viewer.Id, + EventKey = eventKey, + Period = period, + }; + _dbContext.ViewerEventCounters.Add(counter); + achievementCounterCache[(eventKey, period)] = counter; + } + counter.Count = a.TotalCount; + } + } + await _dbContext.SaveChangesAsync(); if (skippedCardIds.Count > 0) @@ -365,6 +441,20 @@ public class AdminController : SVSimController request.SteamId, viewer.Id, skippedMissionCounterIds.Count, string.Join(", ", skippedMissionCounterIds.Take(10))); } + if (skippedAchievementTypes.Count > 0) + { + _logger.LogWarning( + "ImportViewer (steam_id={SteamId}, viewer_id={ViewerId}): skipped {Count} unknown achievement_type(s). Sample: [{Sample}]", + request.SteamId, viewer.Id, skippedAchievementTypes.Count, + string.Join(", ", skippedAchievementTypes.Take(10))); + } + if (skippedAchievementCounterTypes.Count > 0) + { + _logger.LogWarning( + "ImportViewer (steam_id={SteamId}, viewer_id={ViewerId}): {Count} achievement(s) had unresolvable EventKey, counter not written. Sample: [{Sample}]", + request.SteamId, viewer.Id, skippedAchievementCounterTypes.Count, + string.Join(", ", skippedAchievementCounterTypes.Take(10))); + } return new ImportViewerResponse { @@ -374,6 +464,8 @@ public class AdminController : SVSimController SkippedCardCount = skippedCardIds.Count, SkippedMissionCount = skippedMissionIds.Count, SkippedMissionCounterCount = skippedMissionCounterIds.Count, + SkippedAchievementCount = skippedAchievementTypes.Count, + SkippedAchievementCounterCount = skippedAchievementCounterTypes.Count, }; } @@ -406,6 +498,13 @@ public class AdminController : SVSimController return (catalog.EventType!, period); } + // TODO: unify with MissionAssembler.cs — same logic duplicated here. + private static (string EventKey, string Period)? ResolveAchievementCounter(AchievementCatalogEntry catalog) + { + if (string.IsNullOrEmpty(catalog.EventType)) return null; + return (catalog.EventType!, JstPeriod.AllTime); + } + /// /// 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 d8a027b2..64b658b7 100644 --- a/SVSim.EmulatedEntrypoint/Models/Dtos/Requests/Admin/ImportViewerRequest.cs +++ b/SVSim.EmulatedEntrypoint/Models/Dtos/Requests/Admin/ImportViewerRequest.cs @@ -37,6 +37,9 @@ public class ImportViewerRequest [JsonPropertyName("mission_meta")] public ImportMissionMeta? MissionMeta { get; set; } [JsonPropertyName("missions")] public List? Missions { get; set; } + + [JsonPropertyName("achievements")] + public List? Achievements { get; set; } } public class ImportDeck @@ -98,6 +101,15 @@ public class ImportMission public int? Slot { get; set; } } +public class ImportAchievement +{ + [JsonPropertyName("achievement_type")] public int AchievementType { get; set; } + [JsonPropertyName("level")] public int Level { get; set; } + [JsonPropertyName("now_achieved_level")] public int NowAchievedLevel { get; set; } + [JsonPropertyName("result_announce_saw_level")] public int ResultAnnounceSawLevel { 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 04d412a5..49eb67b3 100644 --- a/SVSim.UnitTests/Controllers/AdminControllerTests.cs +++ b/SVSim.UnitTests/Controllers/AdminControllerTests.cs @@ -637,6 +637,125 @@ public class AdminControllerTests Is.EqualTo(10)); } + [Test] + public async Task ImportViewer_Achievements_RoundTripsWithCounter() + { + using var factory = new SVSimTestFactory(); + using (var seedScope = factory.Services.CreateScope()) + { + var db = seedScope.ServiceProvider.GetRequiredService(); + db.AchievementCatalog.Add(new AchievementCatalogEntry + { + AchievementType = 501, Level = 3, EventType = "cards_owned_total", EventArg = null + }); + await db.SaveChangesAsync(); + } + var client = factory.CreateClient(); + ulong steamId = 70000000000000007UL; + + var resp = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest + { + SteamId = steamId, + Achievements = new List + { + new() { AchievementType = 501, Level = 3, NowAchievedLevel = 3, ResultAnnounceSawLevel = 2, TotalCount = 42 } + } + }); + resp.EnsureSuccessStatusCode(); + var body = await resp.Content.ReadFromJsonAsync(JsonOptions); + Assert.That(body!.SkippedAchievementCount, Is.EqualTo(0)); + Assert.That(body.SkippedAchievementCounterCount, Is.EqualTo(0)); + + using var scope = factory.Services.CreateScope(); + var db2 = scope.ServiceProvider.GetRequiredService(); + var viewerId = await db2.Viewers + .Where(v => v.SocialAccountConnections + .Any(s => s.AccountType == SocialAccountType.Steam && s.AccountId == steamId)) + .Select(v => v.Id).SingleAsync(); + var ach = await db2.ViewerAchievements + .SingleAsync(a => a.ViewerId == viewerId && a.AchievementType == 501); + Assert.That(ach.Level, Is.EqualTo(3)); + Assert.That(ach.NowAchievedLevel, Is.EqualTo(3)); + Assert.That(ach.ResultAnnounceSawLevel, Is.EqualTo(2)); + Assert.That(db2.ViewerEventCounters + .Count(c => c.ViewerId == viewerId && c.EventKey == "cards_owned_total" && c.Count == 42), Is.EqualTo(1)); + } + + [Test] + public async Task ImportViewer_Achievements_UnknownTypeSkipped() + { + using var factory = new SVSimTestFactory(); + var client = factory.CreateClient(); + ulong steamId = 70000000000000008UL; + + var resp = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest + { + SteamId = steamId, + Achievements = new List + { + new() { AchievementType = 999999, Level = 1, NowAchievedLevel = 1, ResultAnnounceSawLevel = 0, TotalCount = 1 } + } + }); + resp.EnsureSuccessStatusCode(); + var body = await resp.Content.ReadFromJsonAsync(JsonOptions); + Assert.That(body!.SkippedAchievementCount, Is.EqualTo(1)); + + using var scope = factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + Assert.That(db.ViewerAchievements.Any(), Is.False); + } + + [Test] + public async Task ImportViewer_Achievements_TwoAchievementsSharingEventType_SingleCounterUpsert() + { + // Two achievements with different (AchievementType, Level) but the same EventType — same + // (EventKey, AllTime) cache key. Without the pre-materialized achievementCounterCache the + // second DB read won't see the first achievement's pending Add → duplicate insert / + // unique-constraint failure. With the fix both achievements upsert the SAME counter, + // resulting in exactly one ViewerEventCounter row. + using var factory = new SVSimTestFactory(); + using (var seedScope = factory.Services.CreateScope()) + { + var seedDb = seedScope.ServiceProvider.GetRequiredService(); + seedDb.AchievementCatalog.AddRange( + new AchievementCatalogEntry { AchievementType = 601, Level = 1, EventType = "battle_win_total", EventArg = null }, + new AchievementCatalogEntry { AchievementType = 602, Level = 1, EventType = "battle_win_total", EventArg = null }); + await seedDb.SaveChangesAsync(); + } + var client = factory.CreateClient(); + ulong steamId = 70000000000000009UL; + + var resp = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest + { + SteamId = steamId, + Achievements = new List + { + new() { AchievementType = 601, Level = 1, NowAchievedLevel = 1, ResultAnnounceSawLevel = 0, TotalCount = 20 }, + new() { AchievementType = 602, Level = 1, NowAchievedLevel = 1, ResultAnnounceSawLevel = 0, TotalCount = 20 }, + } + }); + resp.EnsureSuccessStatusCode(); + var body = await resp.Content.ReadFromJsonAsync(JsonOptions); + Assert.That(body!.SkippedAchievementCount, Is.EqualTo(0)); + Assert.That(body.SkippedAchievementCounterCount, Is.EqualTo(0)); + + using var verifyScope = factory.Services.CreateScope(); + var verifyDb = verifyScope.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(); + + // Must be exactly ONE counter row for (EventKey="battle_win_total"), not two. + Assert.That( + verifyDb.ViewerEventCounters.Count(c => c.ViewerId == viewerId && c.EventKey == "battle_win_total"), + Is.EqualTo(1)); + Assert.That( + verifyDb.ViewerEventCounters + .Single(c => c.ViewerId == viewerId && c.EventKey == "battle_win_total").Count, + Is.EqualTo(20)); + } + [Test] public async Task ImportViewer_Missions_ExplicitSlotRoundTrips() {