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);
}