import_viewer: round-trip ViewerStoryProgress with per-family offsets

This commit is contained in:
gamer147
2026-06-23 20:05:13 -04:00
parent 2cb254553f
commit 79e936dcf2
3 changed files with 206 additions and 0 deletions

View File

@@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using SVSim.Database; using SVSim.Database;
using SVSim.Database.Entities.Story;
using SVSim.Database.Enums; using SVSim.Database.Enums;
using SVSim.Database.Models; using SVSim.Database.Models;
using SVSim.Database.Repositories.Viewer; using SVSim.Database.Repositories.Viewer;
@@ -417,6 +418,52 @@ public class AdminController : SVSimController
} }
} }
// Pass D: StoryProgress with per-family offsets
var skippedStoryIds = new HashSet<long>();
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<int>(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(); await _dbContext.SaveChangesAsync();
if (skippedCardIds.Count > 0) if (skippedCardIds.Count > 0)
@@ -455,6 +502,13 @@ public class AdminController : SVSimController
request.SteamId, viewer.Id, skippedAchievementCounterTypes.Count, request.SteamId, viewer.Id, skippedAchievementCounterTypes.Count,
string.Join(", ", skippedAchievementCounterTypes.Take(10))); 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 return new ImportViewerResponse
{ {
@@ -466,6 +520,7 @@ public class AdminController : SVSimController
SkippedMissionCounterCount = skippedMissionCounterIds.Count, SkippedMissionCounterCount = skippedMissionCounterIds.Count,
SkippedAchievementCount = skippedAchievementTypes.Count, SkippedAchievementCount = skippedAchievementTypes.Count,
SkippedAchievementCounterCount = skippedAchievementCounterTypes.Count, SkippedAchievementCounterCount = skippedAchievementCounterTypes.Count,
SkippedStoryCount = skippedStoryIds.Count,
}; };
} }

View File

@@ -40,6 +40,9 @@ public class ImportViewerRequest
[JsonPropertyName("achievements")] [JsonPropertyName("achievements")]
public List<ImportAchievement>? Achievements { get; set; } public List<ImportAchievement>? Achievements { get; set; }
[JsonPropertyName("story_progress")]
public List<ImportStoryProgress>? StoryProgress { get; set; }
} }
public class ImportDeck public class ImportDeck
@@ -110,6 +113,15 @@ public class ImportAchievement
[JsonPropertyName("total_count")] public int TotalCount { get; set; } [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 public class ImportMissionMeta
{ {
[JsonPropertyName("has_received_pick_two_mission")] [JsonPropertyName("has_received_pick_two_mission")]

View File

@@ -4,6 +4,7 @@ using System.Text.Json;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using SVSim.Database; using SVSim.Database;
using SVSim.Database.Entities.Story;
using SVSim.Database.Enums; using SVSim.Database.Enums;
using SVSim.Database.Models; using SVSim.Database.Models;
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Admin; using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Admin;
@@ -796,4 +797,142 @@ public class AdminControllerTests
.SingleAsync(m => m.ViewerId == viewerId && m.MissionCatalogId == 9005); .SingleAsync(m => m.ViewerId == viewerId && m.MissionCatalogId == 9005);
Assert.That(mission.Slot, Is.EqualTo(3)); 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<SVSimDbContext>();
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<ImportStoryProgress>
{
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<ImportViewerResponse>(JsonOptions);
Assert.That(body!.SkippedStoryCount, Is.EqualTo(0));
using var scope = factory.Services.CreateScope();
var verifyDb = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
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<SVSimDbContext>();
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<ImportStoryProgress>
{
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<SVSimDbContext>();
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<ImportStoryProgress>
{
new() { StoryApiType = 1, StoryId = 9999, IsFinish = true, IsSkipped = false }
}
});
resp.EnsureSuccessStatusCode();
var body = await resp.Content.ReadFromJsonAsync<ImportViewerResponse>(JsonOptions);
Assert.That(body!.SkippedStoryCount, Is.EqualTo(1));
using var scope = factory.Services.CreateScope();
var verifyDb = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
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<SVSimDbContext>();
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<ImportMission> { new() { MissionId = 8001, MissionStatus = 1, TotalCount = 5 } },
Achievements = new List<ImportAchievement> { new() { AchievementType = 800, Level = 1, NowAchievedLevel = 1, ResultAnnounceSawLevel = 0, TotalCount = 9 } },
StoryProgress = new List<ImportStoryProgress> { 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<SVSimDbContext>();
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));
}
} }