Stage 9C of the bootstrap-seed-refactor: - Add 6 seed DTOs for the card-id-keyed load-index tables (SpotCard, ReprintedCard, UnlimitedRestriction, LoadingExclusionCard, MaintenanceCard, FeatureMaintenance). - Add CardListsImporter: idempotent upsert of the 6 tables, sharing one Cards FK set for orphan-warning. FeatureMaintenances clear-and-rewrites (synthetic ordinal Id; no natural key). - Add RotationFlagUpdater: reads RotationConfig.RotationCardSetIds from the GameConfigs section (populated by RotationConfigImporter) and flips CardSet.IsInRotation to match. - Add RotationConfig.RotationCardSetIds list property + wire it through RotationConfigImporter. No migration needed (sections are JSON blobs). - RotationConfigImporter: use legacy local-kind DateTime parse for schedule windows so the JSON round-trip stays byte-equivalent to GlobalsImporter. - Strip GlobalsImporter down to a no-op stub (Task 10 will delete it). - Wire all 9 new importers into Program.cs and SVSimTestFactory.SeedGlobalsAsync, in the order RotationConfigImporter -> ... -> CardListsImporter -> RotationFlagUpdater. - Delete prod-captures/load-index-2026-05-23.json. - Add CardListsImporterTests covering each sub-table, idempotency, empty-seed handling, orphan-warning, and the clear-and-rewrite path. Tests: 391 passing (382 baseline + 9 new). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
118 lines
5.6 KiB
C#
118 lines
5.6 KiB
C#
using System.Text.Json;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using SVSim.Bootstrap.Models.Seed;
|
|
using SVSim.Database;
|
|
using SVSim.Database.Models;
|
|
using SVSim.Database.Models.Config;
|
|
using static SVSim.Bootstrap.Importers.ImporterBase;
|
|
|
|
namespace SVSim.Bootstrap.Importers;
|
|
|
|
/// <summary>
|
|
/// Writes three <c>GameConfigSection</c> rows from the load-index seed split:
|
|
/// <c>seeds/rotation-config.json</c> → Rotation, <c>seeds/challenge-config.json</c> → Challenge,
|
|
/// <c>seeds/my-rotation-schedule.json</c> → MyRotationSchedule. Atomic section pattern: read the
|
|
/// existing section row (or shipped defaults), mutate the deserialized POCO, write back to
|
|
/// <c>ValueJson</c>. Re-runnable; rows missing from the seed leave the section row untouched.
|
|
/// </summary>
|
|
public class RotationConfigImporter
|
|
{
|
|
public async Task<int> ImportAsync(SVSimDbContext context, string seedDir)
|
|
{
|
|
int touched = 0;
|
|
|
|
var rot = SeedLoader.LoadObject<RotationConfigSeed>(Path.Combine(seedDir, "rotation-config.json"));
|
|
if (rot is not null)
|
|
{
|
|
await UpsertSection<RotationConfig>(context, RotationConfig.ShippedDefaults, c =>
|
|
{
|
|
c.TsRotationId = rot.TsRotationId;
|
|
c.IsBattlePassPeriod = rot.IsBattlePassPeriod;
|
|
c.IsBeginnerMission = rot.IsBeginnerMission;
|
|
c.CardSetIdForResourceDlView = rot.CardSetIdForResourceDlView;
|
|
c.RotationCardSetIds = rot.RotationCardSetIds ?? new List<int>();
|
|
});
|
|
touched++;
|
|
}
|
|
|
|
var cc = SeedLoader.LoadObject<ChallengeConfigSeed>(Path.Combine(seedDir, "challenge-config.json"));
|
|
if (cc is not null)
|
|
{
|
|
await UpsertSection<ChallengeConfig>(context, ChallengeConfig.ShippedDefaults, c =>
|
|
{
|
|
c.UseTwoPickPremiumCard = cc.UseTwoPickPremiumCard;
|
|
c.TwoPickSleeveId = cc.TwoPickSleeveId;
|
|
});
|
|
touched++;
|
|
}
|
|
|
|
var schedule = SeedLoader.LoadObject<MyRotationScheduleSeed>(Path.Combine(seedDir, "my-rotation-schedule.json"));
|
|
if (schedule?.Gathering is not null && schedule.FreeBattle is not null)
|
|
{
|
|
// Schedule windows are intentionally parsed WITHOUT AssumeUniversal because the seed
|
|
// strings ("2024-05-01 20:00:00") are timezone-less and the rest of the pipeline (the
|
|
// [ConfigSection] JSON round-trip + LoadController's wire mapping) treats them as
|
|
// local-kind ticks. Mirrors the legacy GlobalsImporter.TryParseScheduleWindow behavior
|
|
// — see GlobalsRepositoryTests for the round-trip assertion.
|
|
var gBegin = ParseScheduleWireDateTime(schedule.Gathering.Begin);
|
|
var gEnd = ParseScheduleWireDateTime(schedule.Gathering.End);
|
|
var fBegin = ParseScheduleWireDateTime(schedule.FreeBattle.Begin);
|
|
var fEnd = ParseScheduleWireDateTime(schedule.FreeBattle.End);
|
|
// Only commit when both windows parsed to real DateTimes — a malformed/0001 value
|
|
// would silently lock the MyRotation feature off (the original bug the section fixed).
|
|
if (gBegin != DateTime.MinValue && gEnd != DateTime.MinValue
|
|
&& fBegin != DateTime.MinValue && fEnd != DateTime.MinValue)
|
|
{
|
|
await UpsertSection<MyRotationScheduleConfig>(context, MyRotationScheduleConfig.ShippedDefaults, c =>
|
|
{
|
|
c.Gathering = new ScheduleWindow { Begin = gBegin, End = gEnd };
|
|
c.FreeBattle = new ScheduleWindow { Begin = fBegin, End = fEnd };
|
|
});
|
|
touched++;
|
|
}
|
|
else
|
|
{
|
|
Console.Error.WriteLine("[RotationConfigImporter] my-rotation-schedule.json windows malformed — keeping existing/shipped MyRotationSchedule.");
|
|
}
|
|
}
|
|
|
|
await context.SaveChangesAsync();
|
|
Console.WriteLine($"[RotationConfigImporter] sections={touched}");
|
|
return touched;
|
|
}
|
|
|
|
// Legacy schedule-window parse: default styles (AssumeLocal), matching the original
|
|
// GlobalsImporter.TryParseScheduleWindow. The schedule strings are timezone-less; preserving
|
|
// legacy local-kind ticks keeps the wire output byte-equivalent across the migration.
|
|
private static DateTime ParseScheduleWireDateTime(string? s)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(s)) return DateTime.MinValue;
|
|
return DateTime.TryParse(s, out var dt) ? dt : DateTime.MinValue;
|
|
}
|
|
|
|
// Verbatim copy of GlobalsImporter.UpsertSection<T>. Kept private-static here so this
|
|
// importer can stand alone after Stage 9C strips the GlobalsImporter copy.
|
|
private static async Task UpsertSection<T>(SVSimDbContext context, Func<T> shippedDefaults, Action<T> mutate)
|
|
where T : class, new()
|
|
{
|
|
var sectionName = typeof(T).GetCustomAttributes(typeof(ConfigSectionAttribute), inherit: false)
|
|
.Cast<ConfigSectionAttribute>().FirstOrDefault()?.Name
|
|
?? throw new InvalidOperationException($"{typeof(T).Name} is missing [ConfigSection].");
|
|
|
|
var row = await context.GameConfigs.FirstOrDefaultAsync(s => s.SectionName == sectionName);
|
|
T value;
|
|
if (row is null)
|
|
{
|
|
value = shippedDefaults();
|
|
row = new GameConfigSection { SectionName = sectionName };
|
|
context.GameConfigs.Add(row);
|
|
}
|
|
else
|
|
{
|
|
value = JsonSerializer.Deserialize<T>(row.ValueJson) ?? shippedDefaults();
|
|
}
|
|
mutate(value);
|
|
row.ValueJson = JsonSerializer.Serialize(value);
|
|
}
|
|
}
|