From 79e936dcf25c50b9aa43cb36e3826d94de5e3cd8 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Tue, 23 Jun 2026 20:05:13 -0400 Subject: [PATCH] import_viewer: round-trip ViewerStoryProgress with per-family offsets --- .../Controllers/AdminController.cs | 55 +++++++ .../Requests/Admin/ImportViewerRequest.cs | 12 ++ .../Controllers/AdminControllerTests.cs | 139 ++++++++++++++++++ 3 files changed, 206 insertions(+) diff --git a/SVSim.EmulatedEntrypoint/Controllers/AdminController.cs b/SVSim.EmulatedEntrypoint/Controllers/AdminController.cs index 41151ce3..c643c2fa 100644 --- a/SVSim.EmulatedEntrypoint/Controllers/AdminController.cs +++ b/SVSim.EmulatedEntrypoint/Controllers/AdminController.cs @@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using SVSim.Database; +using SVSim.Database.Entities.Story; using SVSim.Database.Enums; using SVSim.Database.Models; using SVSim.Database.Repositories.Viewer; @@ -417,6 +418,52 @@ public class AdminController : SVSimController } } + // Pass D: StoryProgress with per-family offsets + var skippedStoryIds = new HashSet(); + if (request.StoryProgress is { } storyRows) + { + var existingStory = await _dbContext.ViewerStoryProgress + .Where(p => p.ViewerId == viewer.Id).ToListAsync(); + _dbContext.ViewerStoryProgress.RemoveRange(existingStory); + + // Compute effective ids and bulk-load referenced catalog rows. + var effectiveIds = new List(storyRows.Count); + var rowEffective = new List<(ImportStoryProgress row, int effective)>(storyRows.Count); + foreach (var r in storyRows) + { + long offset = r.StoryApiType switch + { + 1 => 0L, + 2 => 10_000_000L, + 3 => 20_000_000L, + _ => -1L + }; + if (offset < 0) { skippedStoryIds.Add(r.StoryId); continue; } + int wireId = r.SubChapterId ?? r.StoryId; + int effective = checked((int)(wireId + offset)); + effectiveIds.Add(effective); + rowEffective.Add((r, effective)); + } + var present = (await _dbContext.StoryWorlds + .Where(s => effectiveIds.Contains(s.Id)) + .Select(s => s.Id).ToListAsync()).ToHashSet(); + + var nowUtc = DateTime.UtcNow; + foreach (var (r, eff) in rowEffective) + { + if (!present.Contains(eff)) { skippedStoryIds.Add(eff); continue; } + _dbContext.ViewerStoryProgress.Add(new ViewerStoryProgress + { + ViewerId = viewer.Id, + StoryId = eff, + IsFinish = r.IsFinish, + IsSkipped = r.IsSkipped, + FinishedAt = r.IsFinish ? nowUtc : null, + SkippedAt = r.IsSkipped ? nowUtc : null + }); + } + } + await _dbContext.SaveChangesAsync(); if (skippedCardIds.Count > 0) @@ -455,6 +502,13 @@ public class AdminController : SVSimController request.SteamId, viewer.Id, skippedAchievementCounterTypes.Count, string.Join(", ", skippedAchievementCounterTypes.Take(10))); } + if (skippedStoryIds.Count > 0) + { + _logger.LogWarning( + "ImportViewer (steam_id={SteamId}, viewer_id={ViewerId}): skipped {Count} unknown story_id(s). Sample: [{Sample}]", + request.SteamId, viewer.Id, skippedStoryIds.Count, + string.Join(", ", skippedStoryIds.Take(10))); + } return new ImportViewerResponse { @@ -466,6 +520,7 @@ public class AdminController : SVSimController SkippedMissionCounterCount = skippedMissionCounterIds.Count, SkippedAchievementCount = skippedAchievementTypes.Count, SkippedAchievementCounterCount = skippedAchievementCounterTypes.Count, + SkippedStoryCount = skippedStoryIds.Count, }; } diff --git a/SVSim.EmulatedEntrypoint/Models/Dtos/Requests/Admin/ImportViewerRequest.cs b/SVSim.EmulatedEntrypoint/Models/Dtos/Requests/Admin/ImportViewerRequest.cs index 64b658b7..5a562aaf 100644 --- a/SVSim.EmulatedEntrypoint/Models/Dtos/Requests/Admin/ImportViewerRequest.cs +++ b/SVSim.EmulatedEntrypoint/Models/Dtos/Requests/Admin/ImportViewerRequest.cs @@ -40,6 +40,9 @@ public class ImportViewerRequest [JsonPropertyName("achievements")] public List? Achievements { get; set; } + + [JsonPropertyName("story_progress")] + public List? StoryProgress { get; set; } } public class ImportDeck @@ -110,6 +113,15 @@ public class ImportAchievement [JsonPropertyName("total_count")] public int TotalCount { get; set; } } +public class ImportStoryProgress +{ + [JsonPropertyName("story_api_type")] public int StoryApiType { get; set; } + [JsonPropertyName("story_id")] public int StoryId { get; set; } + [JsonPropertyName("sub_chapter_id")] public int? SubChapterId { get; set; } + [JsonPropertyName("is_finish")] public bool IsFinish { get; set; } + [JsonPropertyName("is_skipped")] public bool IsSkipped { 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 49eb67b3..3f468b77 100644 --- a/SVSim.UnitTests/Controllers/AdminControllerTests.cs +++ b/SVSim.UnitTests/Controllers/AdminControllerTests.cs @@ -4,6 +4,7 @@ using System.Text.Json; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using SVSim.Database; +using SVSim.Database.Entities.Story; using SVSim.Database.Enums; using SVSim.Database.Models; using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Admin; @@ -796,4 +797,142 @@ public class AdminControllerTests .SingleAsync(m => m.ViewerId == viewerId && m.MissionCatalogId == 9005); Assert.That(mission.Slot, Is.EqualTo(3)); } + + [Test] + public async Task ImportViewer_StoryProgress_OffsetsByFamily() + { + using var factory = new SVSimTestFactory(); + using (var seedScope = factory.Services.CreateScope()) + { + var db = seedScope.ServiceProvider.GetRequiredService(); + db.StoryWorlds.Add(new StoryWorld { Id = 5 }); // Main + db.StoryWorlds.Add(new StoryWorld { Id = 10_000_005 }); // Limited + db.StoryWorlds.Add(new StoryWorld { Id = 20_000_005 }); // Event + await db.SaveChangesAsync(); + } + var client = factory.CreateClient(); + ulong steamId = 70000000000000007UL; + + var resp = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest + { + SteamId = steamId, + StoryProgress = new List + { + new() { StoryApiType = 1, StoryId = 5, IsFinish = true, IsSkipped = false }, + new() { StoryApiType = 2, StoryId = 5, IsFinish = false, IsSkipped = true }, + new() { StoryApiType = 3, StoryId = 5, IsFinish = true, IsSkipped = true } + } + }); + resp.EnsureSuccessStatusCode(); + var body = await resp.Content.ReadFromJsonAsync(JsonOptions); + Assert.That(body!.SkippedStoryCount, 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(); + + var ids = verifyDb.ViewerStoryProgress.Where(p => p.ViewerId == viewerId).Select(p => p.StoryId).ToList(); + Assert.That(ids, Is.EquivalentTo(new[] { 5, 10_000_005, 20_000_005 })); + } + + [Test] + public async Task ImportViewer_StoryProgress_SubChapterUsesSubId() + { + using var factory = new SVSimTestFactory(); + using (var seedScope = factory.Services.CreateScope()) + { + var db = seedScope.ServiceProvider.GetRequiredService(); + db.StoryWorlds.Add(new StoryWorld { Id = 10 }); // parent chapter + db.StoryWorlds.Add(new StoryWorld { Id = 11 }); // sub_chapter (own row) + await db.SaveChangesAsync(); + } + var client = factory.CreateClient(); + ulong steamId = 70000000000000008UL; + + var resp = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest + { + SteamId = steamId, + StoryProgress = new List + { + new() { StoryApiType = 1, StoryId = 10, IsFinish = true, IsSkipped = false }, + new() { StoryApiType = 1, StoryId = 10, SubChapterId = 11, IsFinish = true, IsSkipped = true } + } + }); + resp.EnsureSuccessStatusCode(); + + 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(); + var ids = verifyDb.ViewerStoryProgress.Where(p => p.ViewerId == viewerId).Select(p => p.StoryId).ToList(); + Assert.That(ids, Is.EquivalentTo(new[] { 10, 11 })); + } + + [Test] + public async Task ImportViewer_StoryProgress_UnknownStoryIdSkipped() + { + using var factory = new SVSimTestFactory(); + var client = factory.CreateClient(); + ulong steamId = 70000000000000009UL; + + var resp = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest + { + SteamId = steamId, + StoryProgress = new List + { + new() { StoryApiType = 1, StoryId = 9999, IsFinish = true, IsSkipped = false } + } + }); + resp.EnsureSuccessStatusCode(); + var body = await resp.Content.ReadFromJsonAsync(JsonOptions); + Assert.That(body!.SkippedStoryCount, Is.EqualTo(1)); + + using var scope = factory.Services.CreateScope(); + var verifyDb = scope.ServiceProvider.GetRequiredService(); + Assert.That(verifyDb.ViewerStoryProgress.Any(), Is.False); + } + + [Test] + public async Task ImportViewer_AllNewSections_AreIdempotentOnReImport() + { + using var factory = new SVSimTestFactory(); + using (var seedScope = factory.Services.CreateScope()) + { + var db = seedScope.ServiceProvider.GetRequiredService(); + db.MissionCatalog.Add(new MissionCatalogEntry { Id = 8001, LotType = 6, EventType = "x", EventArg = null }); + db.AchievementCatalog.Add(new AchievementCatalogEntry { AchievementType = 800, Level = 1, EventType = "y", EventArg = null }); + db.StoryWorlds.Add(new StoryWorld { Id = 50 }); + await db.SaveChangesAsync(); + } + var client = factory.CreateClient(); + ulong steamId = 70000000000000010UL; + + var req = new ImportViewerRequest + { + SteamId = steamId, + MissionMeta = new ImportMissionMeta { HasReceivedPickTwoMission = true, MissionReceiveType = 1, MissionChangeTime = 1L }, + Missions = new List { new() { MissionId = 8001, MissionStatus = 1, TotalCount = 5 } }, + Achievements = new List { new() { AchievementType = 800, Level = 1, NowAchievedLevel = 1, ResultAnnounceSawLevel = 0, TotalCount = 9 } }, + StoryProgress = new List { new() { StoryApiType = 1, StoryId = 50, IsFinish = true } } + }; + (await client.PostAsJsonAsync("/admin/import_viewer", req)).EnsureSuccessStatusCode(); + (await client.PostAsJsonAsync("/admin/import_viewer", req)).EnsureSuccessStatusCode(); + + 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), Is.EqualTo(1)); + Assert.That(verifyDb.ViewerAchievements.Count(a => a.ViewerId == viewerId), Is.EqualTo(1)); + Assert.That(verifyDb.ViewerStoryProgress.Count(p => p.ViewerId == viewerId), Is.EqualTo(1)); + Assert.That(verifyDb.ViewerEventCounters.Count(c => c.ViewerId == viewerId), Is.EqualTo(2)); + } }