refactor(bootstrap): migrate practice opponents to seed file

Move /practice/info handling out of GlobalsImporter into a dedicated
PracticeOpponentImporter that reads a normalized JSON seed file
generated by data_dumps/extract/extract-practice-opponents.ps1.
This commit is contained in:
gamer147
2026-05-26 13:42:59 -04:00
parent 7ec4892d73
commit 40b0de1d51
9 changed files with 1495 additions and 50 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

View File

@@ -31,7 +31,6 @@ public class GlobalsImporter
JsonElement? mypageIndex = LoadCapture(capturesDir, "mypage-index");
JsonElement? deckInfo = LoadCapture(capturesDir, "deck-info");
JsonElement? paymentItemList = LoadCapture(capturesDir, "payment-item-list");
JsonElement? practiceInfo = LoadCapture(capturesDir, "practice-info");
JsonElement? packInfo = LoadCapture(capturesDir, "pack-info");
JsonElement? basicPuzzleInfo = LoadCapture(capturesDir, "basic-puzzle-info");
JsonElement? basicPuzzleMission = LoadCapture(capturesDir, "basic-puzzle-mission");
@@ -75,11 +74,6 @@ public class GlobalsImporter
total += await ImportPaymentItems(context, paymentItemList.Value);
}
if (practiceInfo.HasValue)
{
total += await ImportPracticeOpponents(context, practiceInfo.Value);
}
if (packInfo.HasValue)
{
total += await ImportPacks(context, packInfo.Value);
@@ -879,47 +873,6 @@ public class GlobalsImporter
return created + updated;
}
// ---------- Practice Opponents ----------
/// <summary>
/// Capture is the full /practice/info envelope; <c>data</c> is a JSON ARRAY (not an object,
/// unlike most endpoints). Each row is one AI opponent row keyed on practice_id. Prod sends
/// numeric fields as strings — GetInt tolerates both. Rows present in the DB but missing
/// from the capture are LEFT INTACT (consistent with the rest of GlobalsImporter; partial
/// captures shouldn't silently delete entries).
/// </summary>
private async Task<int> ImportPracticeOpponents(SVSimDbContext context, JsonElement practiceData)
{
if (practiceData.ValueKind != JsonValueKind.Array) return 0;
var existing = await context.PracticeOpponents.ToDictionaryAsync(e => e.Id);
int created = 0, updated = 0;
foreach (var row in practiceData.EnumerateArray())
{
int practiceId = GetInt(row, "practice_id");
if (practiceId == 0) continue; // malformed row
var entry = existing.TryGetValue(practiceId, out var ex) ? ex : new PracticeOpponentEntry { Id = practiceId };
entry.TextId = GetString(row, "text_id");
entry.ClassId = GetInt(row, "class_id");
entry.CharaId = GetInt(row, "chara_id");
entry.DegreeId = GetInt(row, "degree_id");
entry.AiDeckLevel = GetInt(row, "ai_deck_level");
entry.AiLogicLevel = GetInt(row, "ai_logic_level");
entry.AiMaxLife = GetInt(row, "ai_max_life");
entry.Battle3dFieldId = GetString(row, "battle3dfield_id", "1");
entry.IsMaintenance = GetBool(row, "is_maintenance");
entry.IsCampaignPractice = GetBool(row, "is_campaign_practice");
if (ex is null) { context.PracticeOpponents.Add(entry); created++; }
else updated++;
}
Console.WriteLine($"[GlobalsImporter] PracticeOpponents: +{created}/~{updated}");
return created + updated;
}
// ---------- Basic Puzzle Groups + Puzzles ----------
/// <summary>

View File

@@ -0,0 +1,54 @@
using Microsoft.EntityFrameworkCore;
using SVSim.Bootstrap.Models.Seed;
using SVSim.Database;
using SVSim.Database.Models;
namespace SVSim.Bootstrap.Importers;
/// <summary>
/// Idempotent upsert of practice opponents from <c>seeds/practice-opponents.json</c>.
/// Rows missing from the seed are LEFT INTACT (consistent with the previous import behavior;
/// a partial seed shouldn't silently delete entries).
/// </summary>
public class PracticeOpponentImporter
{
public async Task<int> ImportAsync(SVSimDbContext context, string seedDir)
{
string path = Path.Combine(seedDir, "practice-opponents.json");
var seed = SeedLoader.LoadList<PracticeOpponentSeed>(path);
if (seed.Count == 0)
{
Console.WriteLine("[PracticeOpponentImporter] No seed rows; skipping.");
return 0;
}
var existing = await context.PracticeOpponents.ToDictionaryAsync(e => e.Id);
int created = 0, updated = 0;
foreach (var s in seed)
{
if (s.PracticeId == 0) continue;
var entry = existing.TryGetValue(s.PracticeId, out var ex)
? ex : new PracticeOpponentEntry { Id = s.PracticeId };
entry.TextId = s.TextId;
entry.ClassId = s.ClassId;
entry.CharaId = s.CharaId;
entry.DegreeId = s.DegreeId;
entry.AiDeckLevel = s.AiDeckLevel;
entry.AiLogicLevel = s.AiLogicLevel;
entry.AiMaxLife = s.AiMaxLife;
entry.Battle3dFieldId = s.Battle3dFieldId;
entry.IsMaintenance = s.IsMaintenance;
entry.IsCampaignPractice = s.IsCampaignPractice;
if (ex is null) { context.PracticeOpponents.Add(entry); created++; }
else updated++;
}
await context.SaveChangesAsync();
Console.WriteLine($"[PracticeOpponentImporter] +{created}/~{updated}");
return created + updated;
}
}

View File

@@ -0,0 +1,18 @@
using System.Text.Json.Serialization;
namespace SVSim.Bootstrap.Models.Seed;
public sealed class PracticeOpponentSeed
{
[JsonPropertyName("practice_id")] public int PracticeId { get; set; }
[JsonPropertyName("text_id")] public string TextId { get; set; } = "";
[JsonPropertyName("class_id")] public int ClassId { get; set; }
[JsonPropertyName("chara_id")] public int CharaId { get; set; }
[JsonPropertyName("degree_id")] public int DegreeId { get; set; }
[JsonPropertyName("ai_deck_level")] public int AiDeckLevel { get; set; }
[JsonPropertyName("ai_logic_level")] public int AiLogicLevel { get; set; }
[JsonPropertyName("ai_max_life")] public int AiMaxLife { get; set; }
[JsonPropertyName("battle3dfield_id")] public string Battle3dFieldId { get; set; } = "1";
[JsonPropertyName("is_maintenance")] public bool IsMaintenance { get; set; }
[JsonPropertyName("is_campaign_practice")] public bool IsCampaignPractice { get; set; }
}

View File

@@ -76,6 +76,7 @@ public static class Program
if (!opts.SkipGlobals)
{
await new GlobalsImporter().ImportAllAsync(context, opts.CapturesDir);
await new PracticeOpponentImporter().ImportAsync(context, opts.SeedDir);
// BuildDeck pipeline: series CSV → catalog JSON → package CSV. Catalog must run after
// series CSV (FK on products → series) and before package CSV (so the catalog-side
@@ -152,6 +153,7 @@ public static class Program
string refDir = referenceDataDir ?? shippedDataDir;
string shippedStoryDir = Path.Combine(shippedDataDir, "story");
string storyDir = storyDataDir ?? shippedStoryDir;
string shippedSeedDir = Path.Combine(shippedDataDir, "seeds");
string connStr = connection
?? Environment.GetEnvironmentVariable("NPGSQL_CONNECTION")
@@ -159,7 +161,7 @@ public static class Program
return new BootstrapOptions(
cardsFile, capturesDir, refDir, connStr, skipReference, skipCards, skipGlobals,
skipStory, storyDir);
skipStory, storyDir, shippedSeedDir);
}
private static string NextArg(string[] args, ref int i)
@@ -204,5 +206,6 @@ public static class Program
bool SkipCards,
bool SkipGlobals,
bool SkipStory,
string StoryDataDir);
string StoryDataDir,
string SeedDir);
}

View File

@@ -0,0 +1,41 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using SVSim.Bootstrap.Importers;
using SVSim.Database;
using SVSim.UnitTests.Infrastructure;
namespace SVSim.UnitTests.Importers;
public class PracticeOpponentImporterTests
{
private static string SeedDir => Path.Combine(AppContext.BaseDirectory, "Data", "seeds");
[Test]
public async Task Imports_opponents_from_seed_file()
{
using var factory = new SVSimTestFactory();
using var scope = factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
await new PracticeOpponentImporter().ImportAsync(db, SeedDir);
var opponents = await db.PracticeOpponents.OrderBy(p => p.Id).ToListAsync();
Assert.That(opponents.Count, Is.GreaterThan(0), "seed file must contain opponents");
Assert.That(opponents.All(o => o.ClassId >= 0), Is.True);
}
[Test]
public async Task Is_idempotent_on_rerun()
{
using var factory = new SVSimTestFactory();
using var scope = factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
await new PracticeOpponentImporter().ImportAsync(db, SeedDir);
int before = await db.PracticeOpponents.CountAsync();
await new PracticeOpponentImporter().ImportAsync(db, SeedDir);
int after = await db.PracticeOpponents.CountAsync();
Assert.That(after, Is.EqualTo(before));
}
}

View File

@@ -185,9 +185,14 @@ internal sealed class SVSimTestFactory : WebApplicationFactory<Program>
public async Task SeedGlobalsAsync(string? capturesDir = null)
{
capturesDir ??= Path.Combine(AppContext.BaseDirectory, "Data", "prod-captures");
string seedDir = Path.Combine(AppContext.BaseDirectory, "Data", "seeds");
using var scope = Services.CreateScope();
var ctx = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
await new GlobalsImporter().ImportAllAsync(ctx, capturesDir);
// Per-importer seed pipeline runs alongside GlobalsImporter during the migration.
// Wired here so SeedGlobalsAsync callers (e.g. PracticeControllerTests) still see
// practice-opponent rows after the corresponding block was lifted out of GlobalsImporter.
await new PracticeOpponentImporter().ImportAsync(ctx, seedDir);
}
/// <summary>Convenience: bake the X-Test-Viewer-Id header into a fresh client.</summary>

View File

@@ -42,6 +42,11 @@
<Content Include="..\SVSim.Bootstrap\Data\prod-captures\*.json" Link="Data\prod-captures\%(Filename)%(Extension)">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<!-- Seed JSON files for the per-importer pipeline (replaces prod-captures during migration).
Both globs coexist while individual endpoints are still being converted. -->
<Content Include="..\SVSim.Bootstrap\Data\seeds\**\*.json" Link="Data\seeds\%(RecursiveDir)%(Filename)%(Extension)">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<!-- Test-only fixtures live outside prod-captures so the production bootstrap glob doesn't
pick them up (a fixture-named file would win the importer's reverse-alphabetical sort
against a dated capture). Linked into the same test output dir so SeedGlobalsAsync sees