Files
SVSimServer/SVSim.Bootstrap/Importers/RotationConfigImporter.cs
gamer147 87d0001569 refactor(bootstrap): add 7 load-index importers (excluding card lists)
Stage 9B of the bootstrap-seed-refactor: add per-domain importer classes
that consume the load-index seed split produced in Stage 9A.

New importers (each in its own file under SVSim.Bootstrap/Importers/):
- RotationConfigImporter: writes Rotation/Challenge/MyRotationSchedule
  GameConfig sections (atomic UpsertSection<T> pattern, copied private-static
  from GlobalsImporter so this importer stands alone post-9C).
- MyRotationImporter: settings + abilities (extractor pre-joins on rotation_id).
- AvatarAbilityImporter: per-leader_skin_id ability rows.
- ArenaSeasonImporter: singleton (Id=1) Take Two arena season.
- BattlePassImporter: per-level reward blobs.
- DailyLoginBonusImporter: per-bonus-id campaign blobs.
- PreReleaseInfoImporter: singleton (Id=1) pre-release window.

Seed DTOs under SVSim.Bootstrap/Models/Seed/ mirror the seed JSON via
[JsonPropertyName] snake_case. Raw-JSON columns (reward_data, format_info,
etc.) use JsonElement on the seed and JsonSerializer.Serialize in the
importer.

Tests: 7 new happy-path tests in LoadIndexImporterTests.cs (idempotency
covered by BattlePass spot-check). Full suite: 382/382 passing (375 + 7).

NOT modifying in this stage: GlobalsImporter.cs (Stage 9C strips the old
methods), Program.cs (Stage 9C wires up all 9 importers), SVSimTestFactory
(Stage 9C). Double-writing on bootstrap is expected and OK during 9B.
2026-05-26 15:29:57 -04:00

103 lines
4.5 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;
});
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)
{
var gBegin = ParseWireDateTime(schedule.Gathering.Begin);
var gEnd = ParseWireDateTime(schedule.Gathering.End);
var fBegin = ParseWireDateTime(schedule.FreeBattle.Begin);
var fEnd = ParseWireDateTime(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;
}
// 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);
}
}