refactor(bootstrap): finalize load-index migration; GlobalsImporter is now a stub
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>
This commit is contained in:
File diff suppressed because it is too large
Load Diff
166
SVSim.Bootstrap/Importers/CardListsImporter.cs
Normal file
166
SVSim.Bootstrap/Importers/CardListsImporter.cs
Normal file
@@ -0,0 +1,166 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SVSim.Bootstrap.Models.Seed;
|
||||
using SVSim.Database;
|
||||
using SVSim.Database.Models;
|
||||
|
||||
namespace SVSim.Bootstrap.Importers;
|
||||
|
||||
/// <summary>
|
||||
/// Idempotent upsert of the six card-id-keyed tables from load-index seeds:
|
||||
/// SpotCards, ReprintedCards, UnlimitedRestrictions, LoadingExclusionCards,
|
||||
/// MaintenanceCards, FeatureMaintenances. Loads the Cards FK set once for orphan warnings.
|
||||
/// Rows missing from a seed are LEFT INTACT (consistent with prior GlobalsImporter behavior)
|
||||
/// for the five card-id-keyed tables; FeatureMaintenances clears-and-rewrites because its
|
||||
/// synthetic ordinal Id has no natural-key semantics.
|
||||
/// </summary>
|
||||
public class CardListsImporter
|
||||
{
|
||||
public async Task<int> ImportAsync(SVSimDbContext context, string seedDir)
|
||||
{
|
||||
var knownCards = new HashSet<long>(await context.Cards.Select(c => c.Id).ToListAsync());
|
||||
int total = 0;
|
||||
total += await ImportSpot(context, seedDir, knownCards);
|
||||
total += await ImportReprinted(context, seedDir, knownCards);
|
||||
total += await ImportUnlimited(context, seedDir, knownCards);
|
||||
total += await ImportLoadingExclusion(context, seedDir, knownCards);
|
||||
total += await ImportMaintenance(context, seedDir);
|
||||
total += await ImportFeatureMaintenances(context, seedDir);
|
||||
await context.SaveChangesAsync();
|
||||
return total;
|
||||
}
|
||||
|
||||
private async Task<int> ImportSpot(SVSimDbContext context, string seedDir, HashSet<long> knownCards)
|
||||
{
|
||||
var seed = SeedLoader.LoadList<SpotCardSeed>(Path.Combine(seedDir, "spot-cards.json"));
|
||||
if (seed.Count == 0) return 0;
|
||||
var existing = await context.SpotCards.ToDictionaryAsync(e => e.Id);
|
||||
int created = 0, updated = 0, orphans = 0;
|
||||
foreach (var s in seed)
|
||||
{
|
||||
if (s.CardId == 0) continue;
|
||||
if (!knownCards.Contains(s.CardId)) orphans++;
|
||||
var entry = existing.TryGetValue(s.CardId, out var ex) ? ex : new SpotCardEntry { Id = s.CardId };
|
||||
entry.Cost = s.Cost;
|
||||
if (ex is null) { context.SpotCards.Add(entry); existing[s.CardId] = entry; created++; }
|
||||
else updated++;
|
||||
}
|
||||
WarnOrphans("SpotCards", orphans);
|
||||
Console.WriteLine($"[CardListsImporter] SpotCards +{created}/~{updated}");
|
||||
return created + updated;
|
||||
}
|
||||
|
||||
private async Task<int> ImportReprinted(SVSimDbContext context, string seedDir, HashSet<long> knownCards)
|
||||
{
|
||||
var seed = SeedLoader.LoadList<ReprintedCardSeed>(Path.Combine(seedDir, "reprinted-cards.json"));
|
||||
if (seed.Count == 0) return 0;
|
||||
var existing = await context.ReprintedCards.ToDictionaryAsync(e => e.Id);
|
||||
int created = 0, orphans = 0;
|
||||
foreach (var s in seed)
|
||||
{
|
||||
if (s.CardId == 0) continue;
|
||||
if (!knownCards.Contains(s.CardId)) orphans++;
|
||||
if (existing.ContainsKey(s.CardId)) continue;
|
||||
var entry = new ReprintedCardEntry { Id = s.CardId };
|
||||
context.ReprintedCards.Add(entry);
|
||||
existing[s.CardId] = entry;
|
||||
created++;
|
||||
}
|
||||
WarnOrphans("ReprintedCards", orphans);
|
||||
Console.WriteLine($"[CardListsImporter] ReprintedCards +{created}");
|
||||
return created;
|
||||
}
|
||||
|
||||
private async Task<int> ImportUnlimited(SVSimDbContext context, string seedDir, HashSet<long> knownCards)
|
||||
{
|
||||
var seed = SeedLoader.LoadList<UnlimitedRestrictionSeed>(Path.Combine(seedDir, "unlimited-restrictions.json"));
|
||||
if (seed.Count == 0) return 0;
|
||||
var existing = await context.UnlimitedRestrictions.ToDictionaryAsync(e => e.Id);
|
||||
int created = 0, updated = 0, orphans = 0;
|
||||
foreach (var s in seed)
|
||||
{
|
||||
if (s.CardId == 0) continue;
|
||||
if (!knownCards.Contains(s.CardId)) orphans++;
|
||||
var entry = existing.TryGetValue(s.CardId, out var ex) ? ex : new UnlimitedRestrictionEntry { Id = s.CardId };
|
||||
entry.RestrictionValue = s.RestrictionValue;
|
||||
if (ex is null) { context.UnlimitedRestrictions.Add(entry); existing[s.CardId] = entry; created++; }
|
||||
else updated++;
|
||||
}
|
||||
WarnOrphans("UnlimitedRestrictions", orphans);
|
||||
Console.WriteLine($"[CardListsImporter] UnlimitedRestrictions +{created}/~{updated}");
|
||||
return created + updated;
|
||||
}
|
||||
|
||||
private async Task<int> ImportLoadingExclusion(SVSimDbContext context, string seedDir, HashSet<long> knownCards)
|
||||
{
|
||||
var seed = SeedLoader.LoadList<LoadingExclusionCardSeed>(Path.Combine(seedDir, "loading-exclusion-cards.json"));
|
||||
if (seed.Count == 0) return 0;
|
||||
var existing = await context.LoadingExclusionCards.ToDictionaryAsync(e => e.Id);
|
||||
int created = 0, orphans = 0;
|
||||
foreach (var s in seed)
|
||||
{
|
||||
if (s.CardId == 0) continue;
|
||||
if (!knownCards.Contains(s.CardId)) orphans++;
|
||||
if (existing.ContainsKey(s.CardId)) continue;
|
||||
var entry = new LoadingExclusionCardEntry { Id = s.CardId };
|
||||
context.LoadingExclusionCards.Add(entry);
|
||||
existing[s.CardId] = entry;
|
||||
created++;
|
||||
}
|
||||
WarnOrphans("LoadingExclusionCards", orphans);
|
||||
Console.WriteLine($"[CardListsImporter] LoadingExclusionCards +{created}");
|
||||
return created;
|
||||
}
|
||||
|
||||
private async Task<int> ImportMaintenance(SVSimDbContext context, string seedDir)
|
||||
{
|
||||
var seed = SeedLoader.LoadList<MaintenanceCardSeed>(Path.Combine(seedDir, "maintenance-cards.json"));
|
||||
if (seed.Count == 0) return 0;
|
||||
var existing = await context.MaintenanceCards.ToDictionaryAsync(e => e.Id);
|
||||
int created = 0;
|
||||
foreach (var s in seed)
|
||||
{
|
||||
if (s.CardId == 0) continue;
|
||||
if (existing.ContainsKey(s.CardId)) continue;
|
||||
var entry = new MaintenanceCardEntry { Id = s.CardId };
|
||||
context.MaintenanceCards.Add(entry);
|
||||
existing[s.CardId] = entry;
|
||||
created++;
|
||||
}
|
||||
Console.WriteLine($"[CardListsImporter] MaintenanceCards +{created}");
|
||||
return created;
|
||||
}
|
||||
|
||||
private async Task<int> ImportFeatureMaintenances(SVSimDbContext context, string seedDir)
|
||||
{
|
||||
var seed = SeedLoader.LoadList<FeatureMaintenanceSeed>(Path.Combine(seedDir, "feature-maintenances.json"));
|
||||
if (seed.Count == 0) return 0;
|
||||
// FeatureMaintenances has a synthetic int Id assigned by the extractor (1-based ordinal).
|
||||
// The original GlobalsImporter.ImportFeatureMaintenances added rows without dedup; since the
|
||||
// seed is regenerated on every extract, clear-and-rewrite keeps re-runs idempotent and
|
||||
// matches "the latest seed is authoritative". Pre-existing rows with seed-absent ids are
|
||||
// dropped here (acceptable: only synthetic ordinals, no FKs reference this table).
|
||||
var existing = await context.FeatureMaintenances.ToListAsync();
|
||||
context.FeatureMaintenances.RemoveRange(existing);
|
||||
int created = 0;
|
||||
foreach (var s in seed)
|
||||
{
|
||||
if (s.Id == 0) continue;
|
||||
context.FeatureMaintenances.Add(new FeatureMaintenanceEntry
|
||||
{
|
||||
Id = s.Id,
|
||||
FeatureKey = s.FeatureKey,
|
||||
Data = s.Data.ValueKind == JsonValueKind.Undefined ? "{}" : JsonSerializer.Serialize(s.Data),
|
||||
});
|
||||
created++;
|
||||
}
|
||||
Console.WriteLine($"[CardListsImporter] FeatureMaintenances: -{existing.Count}/+{created}");
|
||||
return created;
|
||||
}
|
||||
|
||||
private static void WarnOrphans(string label, int count)
|
||||
{
|
||||
if (count > 0)
|
||||
Console.Error.WriteLine($"[CardListsImporter] Warning: {label} has {count} orphan card_id(s) — run CardImporter first for clean references.");
|
||||
}
|
||||
}
|
||||
@@ -1,508 +1,22 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SVSim.Database;
|
||||
using SVSim.Database.Models;
|
||||
using SVSim.Database.Models.Config;
|
||||
using static SVSim.Bootstrap.Importers.ImporterBase;
|
||||
|
||||
namespace SVSim.Bootstrap.Importers;
|
||||
|
||||
/// <summary>
|
||||
/// Imports prod-captured globals from <c>{capturesDir}/{endpoint}-*.json</c> snapshots into the
|
||||
/// DB via idempotent upserts. Source endpoints: <c>load-index</c>. Per-endpoint seed-file
|
||||
/// importers (DefaultDeckImporter, PackImporter, MyPageGlobalsImporter, etc.) cover the rest.
|
||||
///
|
||||
/// Topological order: GameConfiguration extensions → standalone tables → card-referencing tables →
|
||||
/// rotation CardSet flag update. Card-referencing importers warn on orphans (missing card rows)
|
||||
/// but never fail — CardImporter must have run first for clean output.
|
||||
///
|
||||
/// Re-runnable on the same capture (no-op delta) and on updated captures (creates/updates only).
|
||||
/// Does NOT delete rows missing from the latest capture — that would risk data loss if a capture
|
||||
/// file is partial. Use a fresh DB for snapshot-only state.
|
||||
/// Stub remaining after Stage 9C: the entire load-index → DB pipeline has been replaced by
|
||||
/// per-domain importers in this folder (RotationConfigImporter, MyRotationImporter,
|
||||
/// AvatarAbilityImporter, ArenaSeasonImporter, BattlePassImporter, DailyLoginBonusImporter,
|
||||
/// PreReleaseInfoImporter, CardListsImporter, RotationFlagUpdater). Task 10 will delete this
|
||||
/// class entirely; until then this stub keeps existing call sites compiling.
|
||||
/// </summary>
|
||||
public class GlobalsImporter
|
||||
{
|
||||
public async Task<int> ImportAllAsync(SVSimDbContext context, string capturesDir)
|
||||
public Task<int> ImportAllAsync(SVSimDbContext context, string capturesDir)
|
||||
{
|
||||
Console.WriteLine($"[GlobalsImporter] Loading captures from {capturesDir}...");
|
||||
|
||||
JsonElement? loadIndex = LoadCapture(capturesDir, "load-index");
|
||||
|
||||
int total = 0;
|
||||
|
||||
if (loadIndex.HasValue)
|
||||
{
|
||||
total += await ImportGameConfigurationExtensions(context, loadIndex.Value);
|
||||
total += await ImportMyRotation(context, loadIndex.Value);
|
||||
total += await ImportAvatarAbilities(context, loadIndex.Value);
|
||||
total += await ImportArenaSeason(context, loadIndex.Value);
|
||||
total += await ImportBattlePassLevels(context, loadIndex.Value);
|
||||
total += await ImportDailyLoginBonus(context, loadIndex.Value);
|
||||
total += await ImportPreReleaseInfo(context, loadIndex.Value);
|
||||
total += await ImportSpotCards(context, loadIndex.Value);
|
||||
total += await ImportReprintedCards(context, loadIndex.Value);
|
||||
total += await ImportUnlimitedRestrictions(context, loadIndex.Value);
|
||||
total += await ImportLoadingExclusionCards(context, loadIndex.Value);
|
||||
total += await ImportMaintenanceCards(context, loadIndex.Value);
|
||||
total += await ImportFeatureMaintenances(context, loadIndex.Value);
|
||||
total += await UpdateRotationCardSetFlags(context, loadIndex.Value);
|
||||
}
|
||||
|
||||
await context.SaveChangesAsync();
|
||||
Console.WriteLine($"[GlobalsImporter] Done: {total} total rows changed.");
|
||||
return total;
|
||||
}
|
||||
|
||||
// ---------- GameConfig sections ----------
|
||||
|
||||
private async Task<int> ImportGameConfigurationExtensions(SVSimDbContext context, JsonElement loadIndex)
|
||||
{
|
||||
// Reads the prod capture and overwrites the Rotation and (optionally) Challenge sections
|
||||
// in GameConfigs. Sections are atomic — we read the existing row (or shipped defaults if
|
||||
// none), mutate, then serialize back to ValueJson. Each section is one row in GameConfigs.
|
||||
int touched = 0;
|
||||
|
||||
await UpsertSection<RotationConfig>(context, RotationConfig.ShippedDefaults, rot =>
|
||||
{
|
||||
rot.TsRotationId = GetString(loadIndex, "ts_rotation_id");
|
||||
rot.IsBattlePassPeriod = GetBool(loadIndex, "is_battle_pass_period");
|
||||
rot.IsBeginnerMission = GetBool(loadIndex, "is_beginner_mission");
|
||||
rot.CardSetIdForResourceDlView = GetInt(loadIndex, "card_set_id_for_resource_dl_view");
|
||||
Console.WriteLine($"[GlobalsImporter] GameConfigs/Rotation: ts_rotation_id={rot.TsRotationId}");
|
||||
});
|
||||
touched++;
|
||||
|
||||
if (loadIndex.TryGetProperty("challenge_config", out var cc))
|
||||
{
|
||||
await UpsertSection<ChallengeConfig>(context, ChallengeConfig.ShippedDefaults, ch =>
|
||||
{
|
||||
ch.UseTwoPickPremiumCard = GetBool(cc, "use_challenge_two_pick_premium_card");
|
||||
ch.TwoPickSleeveId = GetLong(cc, "challenge_two_pick_sleeve_id");
|
||||
});
|
||||
touched++;
|
||||
}
|
||||
|
||||
// my_rotation_info.schedules → MyRotationSchedule section. Two named windows, hard-typed
|
||||
// on both wire and client (Wizard/MyRotationAllInfo.cs:178-192 reads "gathering" and
|
||||
// "free_battle" by name and binds them to typed fields). Only upsert when both windows
|
||||
// parse to real DateTimes — a missing or 0001-01-01 capture would lock the feature off,
|
||||
// which is exactly the bug the section was added to fix.
|
||||
if (loadIndex.TryGetProperty("my_rotation_info", out var mri)
|
||||
&& mri.TryGetProperty("schedules", out var schedules))
|
||||
{
|
||||
bool gOk = TryParseScheduleWindow(schedules, "gathering", out var gBegin, out var gEnd);
|
||||
bool fOk = TryParseScheduleWindow(schedules, "free_battle", out var fBegin, out var fEnd);
|
||||
if (gOk && fOk)
|
||||
{
|
||||
await UpsertSection<MyRotationScheduleConfig>(context, MyRotationScheduleConfig.ShippedDefaults, mr =>
|
||||
{
|
||||
mr.Gathering = new ScheduleWindow { Begin = gBegin, End = gEnd };
|
||||
mr.FreeBattle = new ScheduleWindow { Begin = fBegin, End = fEnd };
|
||||
Console.WriteLine($"[GlobalsImporter] GameConfigs/MyRotationSchedule: free_battle {fBegin:u} → {fEnd:u}");
|
||||
});
|
||||
touched++;
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.Error.WriteLine("[GlobalsImporter] my_rotation_info.schedules missing or malformed — keeping existing/shipped MyRotationSchedule.");
|
||||
}
|
||||
}
|
||||
|
||||
return touched;
|
||||
}
|
||||
|
||||
private static bool TryParseScheduleWindow(JsonElement schedules, string key, out DateTime begin, out DateTime end)
|
||||
{
|
||||
begin = default;
|
||||
end = default;
|
||||
if (!schedules.TryGetProperty(key, out var window) || window.ValueKind != JsonValueKind.Object) return false;
|
||||
if (!DateTime.TryParse(GetString(window, "begin_time"), out begin)) return false;
|
||||
if (!DateTime.TryParse(GetString(window, "end_time"), out end)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
// ---------- My Rotation ----------
|
||||
|
||||
private async Task<int> ImportMyRotation(SVSimDbContext context, JsonElement loadIndex)
|
||||
{
|
||||
if (!loadIndex.TryGetProperty("my_rotation_info", out var info)) return 0;
|
||||
|
||||
// Settings — join setting + reprinted + restricted dicts on rotation_id.
|
||||
var settingsDict = info.TryGetProperty("setting", out var s) ? s : default;
|
||||
var reprintedDict = info.TryGetProperty("reprinted_base_card_ids", out var r) ? r : default;
|
||||
var restrictedDict = info.TryGetProperty("restricted_base_card_id_list", out var rs) ? rs : default;
|
||||
|
||||
var existingSettings = await context.MyRotationSettings.ToDictionaryAsync(e => e.Id);
|
||||
int setCreated = 0, setUpdated = 0;
|
||||
if (settingsDict.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
foreach (var kv in settingsDict.EnumerateObject())
|
||||
{
|
||||
if (!int.TryParse(kv.Name, out int rid)) continue;
|
||||
var entry = existingSettings.TryGetValue(rid, out var ex) ? ex : new MyRotationSettingEntry { Id = rid };
|
||||
entry.CardSetIdsCsv = GetString(kv.Value, "card_set_ids");
|
||||
entry.AbilitiesCsv = GetString(kv.Value, "abilities");
|
||||
entry.ReprintedCardIds = reprintedDict.ValueKind == JsonValueKind.Object && reprintedDict.TryGetProperty(kv.Name, out var rep)
|
||||
? Serialize(rep) : "[]";
|
||||
entry.RestrictedCardIds = restrictedDict.ValueKind == JsonValueKind.Object && restrictedDict.TryGetProperty(kv.Name, out var res)
|
||||
? Serialize(res) : "[]";
|
||||
if (ex is null) { context.MyRotationSettings.Add(entry); setCreated++; }
|
||||
else setUpdated++;
|
||||
}
|
||||
}
|
||||
|
||||
// Abilities
|
||||
int abilityCreated = 0, abilityUpdated = 0;
|
||||
if (info.TryGetProperty("abilities", out var abilities) && abilities.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
var existingAbilities = await context.MyRotationAbilities.ToDictionaryAsync(e => e.Id);
|
||||
foreach (var kv in abilities.EnumerateObject())
|
||||
{
|
||||
if (!int.TryParse(kv.Name, out int aid)) continue;
|
||||
var entry = existingAbilities.TryGetValue(aid, out var ex) ? ex : new MyRotationAbilityEntry { Id = aid };
|
||||
entry.Data = Serialize(kv.Value);
|
||||
if (ex is null) { context.MyRotationAbilities.Add(entry); abilityCreated++; }
|
||||
else abilityUpdated++;
|
||||
}
|
||||
}
|
||||
|
||||
Console.WriteLine($"[GlobalsImporter] MyRotation: settings +{setCreated}/~{setUpdated}, abilities +{abilityCreated}/~{abilityUpdated}");
|
||||
return setCreated + setUpdated + abilityCreated + abilityUpdated;
|
||||
}
|
||||
|
||||
// ---------- Avatar Abilities ----------
|
||||
|
||||
private async Task<int> ImportAvatarAbilities(SVSimDbContext context, JsonElement loadIndex)
|
||||
{
|
||||
if (!loadIndex.TryGetProperty("avatar_info", out var info)) return 0;
|
||||
if (!info.TryGetProperty("abilities", out var abilities) || abilities.ValueKind != JsonValueKind.Object) return 0;
|
||||
|
||||
var existing = await context.AvatarAbilities.ToDictionaryAsync(e => e.Id);
|
||||
int created = 0, updated = 0;
|
||||
|
||||
foreach (var kv in abilities.EnumerateObject())
|
||||
{
|
||||
if (!int.TryParse(kv.Name, out int leaderSkinId)) continue;
|
||||
var v = kv.Value;
|
||||
var entry = existing.TryGetValue(leaderSkinId, out var ex) ? ex : new AvatarAbilityEntry { Id = leaderSkinId };
|
||||
entry.BattleStartFirstPlayerTurnBp = GetInt(v, "battle_start_firstplayerturn_bp");
|
||||
entry.BattleStartSecondPlayerTurnBp = GetInt(v, "battle_start_secondplayerturn_bp");
|
||||
entry.BattleStartMaxLife = GetInt(v, "battle_start_max_life");
|
||||
entry.AbilityCost = GetString(v, "ability_cost");
|
||||
entry.Ability = GetString(v, "ability");
|
||||
entry.PassiveAbility = GetString(v, "passive_ability");
|
||||
entry.AbilityDesc = GetString(v, "ability_desc");
|
||||
entry.PassiveAbilityDesc = GetString(v, "passive_ability_desc");
|
||||
if (ex is null) { context.AvatarAbilities.Add(entry); created++; }
|
||||
else updated++;
|
||||
}
|
||||
|
||||
Console.WriteLine($"[GlobalsImporter] AvatarAbilities: +{created}/~{updated}");
|
||||
return created + updated;
|
||||
}
|
||||
|
||||
// ---------- Arena Season (singleton) ----------
|
||||
|
||||
private async Task<int> ImportArenaSeason(SVSimDbContext context, JsonElement loadIndex)
|
||||
{
|
||||
if (!loadIndex.TryGetProperty("arena_info", out var arr) || arr.ValueKind != JsonValueKind.Array || arr.GetArrayLength() == 0) return 0;
|
||||
var first = arr[0];
|
||||
|
||||
var existing = await context.ArenaSeasons.FirstOrDefaultAsync(e => e.Id == 1);
|
||||
var entry = existing ?? new ArenaSeasonConfig { Id = 1 };
|
||||
entry.Mode = GetInt(first, "mode");
|
||||
entry.Enable = GetInt(first, "enable");
|
||||
entry.Cost = GetULong(first, "cost");
|
||||
entry.RupyCost = GetULong(first, "rupy_cost");
|
||||
entry.TicketCost = GetInt(first, "ticket_cost");
|
||||
entry.IsJoin = GetBool(first, "is_join");
|
||||
entry.FormatInfo = first.TryGetProperty("format_info", out var fi) ? Serialize(fi) : "{}";
|
||||
if (existing is null) context.ArenaSeasons.Add(entry);
|
||||
|
||||
Console.WriteLine($"[GlobalsImporter] ArenaSeason: {(existing is null ? "+1" : "~1")}");
|
||||
return 1;
|
||||
}
|
||||
|
||||
// ---------- Battle Pass Levels ----------
|
||||
|
||||
private async Task<int> ImportBattlePassLevels(SVSimDbContext context, JsonElement loadIndex)
|
||||
{
|
||||
if (!loadIndex.TryGetProperty("battle_pass_level_info", out var info) || info.ValueKind != JsonValueKind.Object) return 0;
|
||||
|
||||
var existing = await context.BattlePassLevels.ToDictionaryAsync(e => e.Id);
|
||||
int created = 0, updated = 0;
|
||||
foreach (var kv in info.EnumerateObject())
|
||||
{
|
||||
if (!int.TryParse(kv.Name, out int level)) continue;
|
||||
var entry = existing.TryGetValue(level, out var ex) ? ex : new BattlePassLevelEntry { Id = level };
|
||||
entry.RewardData = Serialize(kv.Value);
|
||||
if (ex is null) { context.BattlePassLevels.Add(entry); created++; }
|
||||
else updated++;
|
||||
}
|
||||
Console.WriteLine($"[GlobalsImporter] BattlePassLevels: +{created}/~{updated}");
|
||||
return created + updated;
|
||||
}
|
||||
|
||||
// ---------- Daily Login Bonus ----------
|
||||
|
||||
private async Task<int> ImportDailyLoginBonus(SVSimDbContext context, JsonElement loadIndex)
|
||||
{
|
||||
if (!loadIndex.TryGetProperty("daily_login_bonus", out var info) || info.ValueKind != JsonValueKind.Object) return 0;
|
||||
|
||||
var existing = await context.DailyLoginBonuses.ToDictionaryAsync(e => e.Id);
|
||||
int created = 0, updated = 0;
|
||||
foreach (var kv in info.EnumerateObject())
|
||||
{
|
||||
if (!int.TryParse(kv.Name, out int bonusId)) continue;
|
||||
var entry = existing.TryGetValue(bonusId, out var ex) ? ex : new DailyLoginBonusEntry { Id = bonusId };
|
||||
entry.BonusData = Serialize(kv.Value);
|
||||
if (ex is null) { context.DailyLoginBonuses.Add(entry); created++; }
|
||||
else updated++;
|
||||
}
|
||||
Console.WriteLine($"[GlobalsImporter] DailyLoginBonus: +{created}/~{updated}");
|
||||
return created + updated;
|
||||
}
|
||||
|
||||
// ---------- Pre-release Info (singleton) ----------
|
||||
|
||||
private async Task<int> ImportPreReleaseInfo(SVSimDbContext context, JsonElement loadIndex)
|
||||
{
|
||||
if (!loadIndex.TryGetProperty("pre_release_info", out var info) || info.ValueKind != JsonValueKind.Object) return 0;
|
||||
|
||||
var existing = await context.PreReleaseInfos.FirstOrDefaultAsync(e => e.Id == 1);
|
||||
var entry = existing ?? new PreReleaseInfo { Id = 1 };
|
||||
entry.PreReleaseId = GetString(info, "id");
|
||||
entry.NextCardSetId = GetString(info, "next_card_set_id");
|
||||
entry.StartTime = ParseWireDateTime(GetString(info, "start_time"));
|
||||
entry.EndTime = ParseWireDateTime(GetString(info, "end_time"));
|
||||
entry.DisplayEndTime = ParseWireDateTime(GetString(info, "display_end_time"));
|
||||
entry.FreeMatchStartTime = ParseWireDateTime(GetString(info, "free_match_start_time"));
|
||||
entry.CardMasterId = GetInt(info, "card_master_id");
|
||||
entry.DefaultCardMasterId = GetString(info, "default_card_master_id");
|
||||
entry.PreReleaseCardMasterId = GetString(info, "pre_release_card_master_id");
|
||||
entry.IsPreRotationFreeMatchTerm = GetBool(info, "is_pre_rotation_free_match_term");
|
||||
entry.RotationCardSetIdList = info.TryGetProperty("rotation_card_set_id_list", out var rcs) ? Serialize(rcs) : "[]";
|
||||
entry.ReprintedBaseCardIds = info.TryGetProperty("reprinted_base_card_ids", out var rep) ? Serialize(rep) : "{}";
|
||||
entry.LatestReprintedBaseCardIds = info.TryGetProperty("latest_reprinted_base_card_ids", out var lrep) ? Serialize(lrep) : "{}";
|
||||
if (existing is null) context.PreReleaseInfos.Add(entry);
|
||||
Console.WriteLine($"[GlobalsImporter] PreReleaseInfo: {(existing is null ? "+1" : "~1")}");
|
||||
return 1;
|
||||
}
|
||||
|
||||
// ---------- Spot Cards (card-referencing) ----------
|
||||
|
||||
private async Task<int> ImportSpotCards(SVSimDbContext context, JsonElement loadIndex)
|
||||
{
|
||||
if (!loadIndex.TryGetProperty("spot_cards", out var info) || info.ValueKind != JsonValueKind.Object) return 0;
|
||||
|
||||
var existing = await context.SpotCards.ToDictionaryAsync(e => e.Id);
|
||||
var knownCards = await context.Cards.Select(c => c.Id).ToListAsync();
|
||||
var knownSet = new HashSet<long>(knownCards);
|
||||
int created = 0, updated = 0, orphans = 0;
|
||||
|
||||
foreach (var kv in info.EnumerateObject())
|
||||
{
|
||||
if (!long.TryParse(kv.Name, out long cardId)) continue;
|
||||
if (!knownSet.Contains(cardId)) orphans++;
|
||||
int cost = kv.Value.ValueKind == JsonValueKind.Number ? kv.Value.GetInt32() : GetInt(kv.Value, "cost");
|
||||
var entry = existing.TryGetValue(cardId, out var ex) ? ex : new SpotCardEntry { Id = cardId };
|
||||
entry.Cost = cost;
|
||||
if (ex is null) { context.SpotCards.Add(entry); created++; }
|
||||
else updated++;
|
||||
}
|
||||
WarnOrphans("SpotCards", orphans);
|
||||
Console.WriteLine($"[GlobalsImporter] SpotCards: +{created}/~{updated}");
|
||||
return created + updated;
|
||||
}
|
||||
|
||||
// ---------- Reprinted Cards ----------
|
||||
|
||||
private async Task<int> ImportReprintedCards(SVSimDbContext context, JsonElement loadIndex)
|
||||
{
|
||||
if (!loadIndex.TryGetProperty("reprinted_base_card_ids", out var info)) return 0;
|
||||
|
||||
var existing = await context.ReprintedCards.ToDictionaryAsync(e => e.Id);
|
||||
var knownSet = new HashSet<long>(await context.Cards.Select(c => c.Id).ToListAsync());
|
||||
int created = 0, orphans = 0;
|
||||
IEnumerable<long> ids;
|
||||
if (info.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
ids = info.EnumerateObject().Select(kv => long.TryParse(kv.Name, out var n) ? n : 0L).Where(n => n != 0);
|
||||
}
|
||||
else if (info.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
ids = info.EnumerateArray().Select(e => e.ValueKind == JsonValueKind.Number ? e.GetInt64() : (long.TryParse(e.GetString(), out var n) ? n : 0L)).Where(n => n != 0);
|
||||
}
|
||||
else return 0;
|
||||
|
||||
foreach (var id in ids)
|
||||
{
|
||||
if (!knownSet.Contains(id)) orphans++;
|
||||
if (existing.ContainsKey(id)) continue;
|
||||
context.ReprintedCards.Add(new ReprintedCardEntry { Id = id });
|
||||
existing[id] = null!;
|
||||
created++;
|
||||
}
|
||||
WarnOrphans("ReprintedCards", orphans);
|
||||
Console.WriteLine($"[GlobalsImporter] ReprintedCards: +{created}");
|
||||
return created;
|
||||
}
|
||||
|
||||
// ---------- Unlimited Restrictions ----------
|
||||
|
||||
private async Task<int> ImportUnlimitedRestrictions(SVSimDbContext context, JsonElement loadIndex)
|
||||
{
|
||||
if (!loadIndex.TryGetProperty("unlimited_restricted_base_card_id_list", out var info) || info.ValueKind != JsonValueKind.Object) return 0;
|
||||
|
||||
var existing = await context.UnlimitedRestrictions.ToDictionaryAsync(e => e.Id);
|
||||
var knownSet = new HashSet<long>(await context.Cards.Select(c => c.Id).ToListAsync());
|
||||
int created = 0, updated = 0, orphans = 0;
|
||||
|
||||
foreach (var kv in info.EnumerateObject())
|
||||
{
|
||||
if (!long.TryParse(kv.Name, out long cardId)) continue;
|
||||
if (!knownSet.Contains(cardId)) orphans++;
|
||||
int val = kv.Value.ValueKind == JsonValueKind.Number ? kv.Value.GetInt32()
|
||||
: (int.TryParse(kv.Value.GetString(), out var n) ? n : 0);
|
||||
var entry = existing.TryGetValue(cardId, out var ex) ? ex : new UnlimitedRestrictionEntry { Id = cardId };
|
||||
entry.RestrictionValue = val;
|
||||
if (ex is null) { context.UnlimitedRestrictions.Add(entry); created++; }
|
||||
else updated++;
|
||||
}
|
||||
WarnOrphans("UnlimitedRestrictions", orphans);
|
||||
Console.WriteLine($"[GlobalsImporter] UnlimitedRestrictions: +{created}/~{updated}");
|
||||
return created + updated;
|
||||
}
|
||||
|
||||
// ---------- Loading Exclusion Cards ----------
|
||||
|
||||
private async Task<int> ImportLoadingExclusionCards(SVSimDbContext context, JsonElement loadIndex)
|
||||
{
|
||||
if (!loadIndex.TryGetProperty("loading_exclusion_card_list", out var arr) || arr.ValueKind != JsonValueKind.Array) return 0;
|
||||
|
||||
var existing = await context.LoadingExclusionCards.ToDictionaryAsync(e => e.Id);
|
||||
var knownSet = new HashSet<long>(await context.Cards.Select(c => c.Id).ToListAsync());
|
||||
int created = 0, orphans = 0;
|
||||
|
||||
foreach (var el in arr.EnumerateArray())
|
||||
{
|
||||
long id = el.ValueKind == JsonValueKind.Number ? el.GetInt64() : (long.TryParse(el.GetString(), out var n) ? n : 0);
|
||||
if (id == 0) continue;
|
||||
if (!knownSet.Contains(id)) orphans++;
|
||||
if (existing.ContainsKey(id)) continue;
|
||||
context.LoadingExclusionCards.Add(new LoadingExclusionCardEntry { Id = id });
|
||||
existing[id] = null!;
|
||||
created++;
|
||||
}
|
||||
WarnOrphans("LoadingExclusionCards", orphans);
|
||||
Console.WriteLine($"[GlobalsImporter] LoadingExclusionCards: +{created}");
|
||||
return created;
|
||||
}
|
||||
|
||||
// ---------- Maintenance Cards (skeleton-seedable) ----------
|
||||
|
||||
private async Task<int> ImportMaintenanceCards(SVSimDbContext context, JsonElement loadIndex)
|
||||
{
|
||||
if (!loadIndex.TryGetProperty("maintenance_card_list", out var arr) || arr.ValueKind != JsonValueKind.Array) return 0;
|
||||
if (arr.GetArrayLength() == 0) return 0;
|
||||
|
||||
var existing = await context.MaintenanceCards.ToDictionaryAsync(e => e.Id);
|
||||
int created = 0;
|
||||
foreach (var el in arr.EnumerateArray())
|
||||
{
|
||||
long id = el.ValueKind == JsonValueKind.Number ? el.GetInt64() : (long.TryParse(el.GetString(), out var n) ? n : 0);
|
||||
if (id == 0 || existing.ContainsKey(id)) continue;
|
||||
context.MaintenanceCards.Add(new MaintenanceCardEntry { Id = id });
|
||||
existing[id] = null!;
|
||||
created++;
|
||||
}
|
||||
Console.WriteLine($"[GlobalsImporter] MaintenanceCards: +{created}");
|
||||
return created;
|
||||
}
|
||||
|
||||
// ---------- Feature Maintenances (skeleton-seedable) ----------
|
||||
|
||||
private async Task<int> ImportFeatureMaintenances(SVSimDbContext context, JsonElement loadIndex)
|
||||
{
|
||||
if (!loadIndex.TryGetProperty("feature_maintenance_list", out var arr) || arr.ValueKind != JsonValueKind.Array) return 0;
|
||||
if (arr.GetArrayLength() == 0) return 0;
|
||||
|
||||
// Schema uses synthetic int Id; preserve raw blob per index.
|
||||
int created = 0;
|
||||
int idx = 1;
|
||||
foreach (var el in arr.EnumerateArray())
|
||||
{
|
||||
context.FeatureMaintenances.Add(new FeatureMaintenanceEntry
|
||||
{
|
||||
Id = idx++,
|
||||
FeatureKey = GetString(el, "feature_key"),
|
||||
Data = Serialize(el)
|
||||
});
|
||||
created++;
|
||||
}
|
||||
Console.WriteLine($"[GlobalsImporter] FeatureMaintenances: +{created}");
|
||||
return created;
|
||||
}
|
||||
|
||||
// ---------- Rotation CardSet flag update ----------
|
||||
|
||||
private async Task<int> UpdateRotationCardSetFlags(SVSimDbContext context, JsonElement loadIndex)
|
||||
{
|
||||
if (!loadIndex.TryGetProperty("rotation_card_set_id_list", out var arr) || arr.ValueKind != JsonValueKind.Array) return 0;
|
||||
|
||||
var rotationIds = arr.EnumerateArray()
|
||||
.Select(e => e.TryGetProperty("card_set_id", out var v) && v.ValueKind == JsonValueKind.Number ? v.GetInt32() : 0)
|
||||
.Where(n => n != 0)
|
||||
.ToHashSet();
|
||||
|
||||
if (rotationIds.Count == 0) return 0;
|
||||
|
||||
var allSets = await context.CardSets.ToListAsync();
|
||||
int updated = 0, missing = 0;
|
||||
foreach (var rid in rotationIds)
|
||||
{
|
||||
var set = allSets.FirstOrDefault(s => s.Id == rid);
|
||||
if (set is null) { missing++; continue; }
|
||||
if (!set.IsInRotation) { set.IsInRotation = true; updated++; }
|
||||
}
|
||||
// Demote sets not in the current rotation
|
||||
foreach (var s in allSets.Where(s => s.IsInRotation && !rotationIds.Contains(s.Id)))
|
||||
{
|
||||
s.IsInRotation = false;
|
||||
updated++;
|
||||
}
|
||||
if (missing > 0) Console.Error.WriteLine($"[GlobalsImporter] Warning: {missing} rotation card_set_id(s) missing from CardSets — run CardImporter first.");
|
||||
Console.WriteLine($"[GlobalsImporter] RotationCardSets: ~{updated} flag changes");
|
||||
return updated;
|
||||
}
|
||||
|
||||
// ---------- Helpers ----------
|
||||
|
||||
private static void WarnOrphans(string label, int count)
|
||||
{
|
||||
if (count > 0) Console.Error.WriteLine($"[GlobalsImporter] Warning: {label} has {count} orphan card_id(s) — run CardImporter first for clean references.");
|
||||
// All work migrated to per-domain importers wired in Program.cs and
|
||||
// SVSimTestFactory.SeedGlobalsAsync. Intentionally a no-op.
|
||||
_ = context;
|
||||
_ = capturesDir;
|
||||
return Task.FromResult(0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ public class RotationConfigImporter
|
||||
c.IsBattlePassPeriod = rot.IsBattlePassPeriod;
|
||||
c.IsBeginnerMission = rot.IsBeginnerMission;
|
||||
c.CardSetIdForResourceDlView = rot.CardSetIdForResourceDlView;
|
||||
c.RotationCardSetIds = rot.RotationCardSetIds ?? new List<int>();
|
||||
});
|
||||
touched++;
|
||||
}
|
||||
@@ -48,10 +49,15 @@ public class RotationConfigImporter
|
||||
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);
|
||||
// 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
|
||||
@@ -75,6 +81,15 @@ public class RotationConfigImporter
|
||||
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)
|
||||
|
||||
64
SVSim.Bootstrap/Importers/RotationFlagUpdater.cs
Normal file
64
SVSim.Bootstrap/Importers/RotationFlagUpdater.cs
Normal file
@@ -0,0 +1,64 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SVSim.Database;
|
||||
using SVSim.Database.Models.Config;
|
||||
|
||||
namespace SVSim.Bootstrap.Importers;
|
||||
|
||||
/// <summary>
|
||||
/// Reads <see cref="RotationConfig"/> from the GameConfigs table (populated by
|
||||
/// <see cref="RotationConfigImporter"/>) and flips <c>CardSet.IsInRotation</c> to match.
|
||||
/// Must run after RotationConfigImporter and CardImporter — CardSets missing from the DB
|
||||
/// can't be promoted (the original GlobalsImporter behavior; we log a warning instead of failing).
|
||||
/// </summary>
|
||||
public class RotationFlagUpdater
|
||||
{
|
||||
public async Task<int> UpdateAsync(SVSimDbContext context)
|
||||
{
|
||||
var sectionName = typeof(RotationConfig).GetCustomAttributes(typeof(ConfigSectionAttribute), inherit: false)
|
||||
.Cast<ConfigSectionAttribute>().FirstOrDefault()?.Name
|
||||
?? throw new InvalidOperationException("RotationConfig missing [ConfigSection]");
|
||||
|
||||
var row = await context.GameConfigs.FirstOrDefaultAsync(s => s.SectionName == sectionName);
|
||||
if (row is null)
|
||||
{
|
||||
Console.WriteLine("[RotationFlagUpdater] No Rotation section in GameConfigs; skipping.");
|
||||
return 0;
|
||||
}
|
||||
|
||||
var cfg = JsonSerializer.Deserialize<RotationConfig>(row.ValueJson);
|
||||
if (cfg is null)
|
||||
{
|
||||
Console.WriteLine("[RotationFlagUpdater] Failed to deserialize RotationConfig; skipping.");
|
||||
return 0;
|
||||
}
|
||||
|
||||
var rotationSet = (cfg.RotationCardSetIds ?? new List<int>()).ToHashSet();
|
||||
if (rotationSet.Count == 0)
|
||||
{
|
||||
Console.WriteLine("[RotationFlagUpdater] RotationCardSetIds empty; no flag changes.");
|
||||
return 0;
|
||||
}
|
||||
|
||||
var allSets = await context.CardSets.ToListAsync();
|
||||
int updated = 0, missing = 0;
|
||||
foreach (var rid in rotationSet)
|
||||
{
|
||||
var set = allSets.FirstOrDefault(s => s.Id == rid);
|
||||
if (set is null) { missing++; continue; }
|
||||
if (!set.IsInRotation) { set.IsInRotation = true; updated++; }
|
||||
}
|
||||
// Demote sets not in the current rotation.
|
||||
foreach (var s in allSets.Where(s => s.IsInRotation && !rotationSet.Contains(s.Id)))
|
||||
{
|
||||
s.IsInRotation = false;
|
||||
updated++;
|
||||
}
|
||||
if (missing > 0)
|
||||
Console.Error.WriteLine($"[RotationFlagUpdater] Warning: {missing} rotation card_set_id(s) missing from CardSets — run CardImporter first.");
|
||||
|
||||
await context.SaveChangesAsync();
|
||||
Console.WriteLine($"[RotationFlagUpdater] CardSet.IsInRotation ~{updated}");
|
||||
return updated;
|
||||
}
|
||||
}
|
||||
16
SVSim.Bootstrap/Models/Seed/FeatureMaintenanceSeed.cs
Normal file
16
SVSim.Bootstrap/Models/Seed/FeatureMaintenanceSeed.cs
Normal file
@@ -0,0 +1,16 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace SVSim.Bootstrap.Models.Seed;
|
||||
|
||||
/// <summary>
|
||||
/// Mirrors one entry of <c>seeds/feature-maintenances.json</c>. Source: <c>/load/index
|
||||
/// data.feature_maintenance_list</c> (array of dicts; usually empty). <see cref="Data"/> is
|
||||
/// the raw element so it round-trips verbatim into the entity's jsonb column.
|
||||
/// </summary>
|
||||
public sealed class FeatureMaintenanceSeed
|
||||
{
|
||||
[JsonPropertyName("id")] public int Id { get; set; }
|
||||
[JsonPropertyName("feature_key")] public string FeatureKey { get; set; } = "";
|
||||
[JsonPropertyName("data")] public JsonElement Data { get; set; }
|
||||
}
|
||||
12
SVSim.Bootstrap/Models/Seed/LoadingExclusionCardSeed.cs
Normal file
12
SVSim.Bootstrap/Models/Seed/LoadingExclusionCardSeed.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace SVSim.Bootstrap.Models.Seed;
|
||||
|
||||
/// <summary>
|
||||
/// Mirrors one entry of <c>seeds/loading-exclusion-cards.json</c>. Source: <c>/load/index
|
||||
/// data.loading_exclusion_card_list</c> (array of card_ids).
|
||||
/// </summary>
|
||||
public sealed class LoadingExclusionCardSeed
|
||||
{
|
||||
[JsonPropertyName("card_id")] public long CardId { get; set; }
|
||||
}
|
||||
12
SVSim.Bootstrap/Models/Seed/MaintenanceCardSeed.cs
Normal file
12
SVSim.Bootstrap/Models/Seed/MaintenanceCardSeed.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace SVSim.Bootstrap.Models.Seed;
|
||||
|
||||
/// <summary>
|
||||
/// Mirrors one entry of <c>seeds/maintenance-cards.json</c>. Source: <c>/load/index
|
||||
/// data.maintenance_card_list</c> (array of card_ids; usually empty).
|
||||
/// </summary>
|
||||
public sealed class MaintenanceCardSeed
|
||||
{
|
||||
[JsonPropertyName("card_id")] public long CardId { get; set; }
|
||||
}
|
||||
12
SVSim.Bootstrap/Models/Seed/ReprintedCardSeed.cs
Normal file
12
SVSim.Bootstrap/Models/Seed/ReprintedCardSeed.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace SVSim.Bootstrap.Models.Seed;
|
||||
|
||||
/// <summary>
|
||||
/// Mirrors one entry of <c>seeds/reprinted-cards.json</c>. Source: <c>/load/index
|
||||
/// data.reprinted_base_card_ids</c> (dict or list of card_ids).
|
||||
/// </summary>
|
||||
public sealed class ReprintedCardSeed
|
||||
{
|
||||
[JsonPropertyName("card_id")] public long CardId { get; set; }
|
||||
}
|
||||
13
SVSim.Bootstrap/Models/Seed/SpotCardSeed.cs
Normal file
13
SVSim.Bootstrap/Models/Seed/SpotCardSeed.cs
Normal file
@@ -0,0 +1,13 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace SVSim.Bootstrap.Models.Seed;
|
||||
|
||||
/// <summary>
|
||||
/// Mirrors one entry of <c>seeds/spot-cards.json</c>. Source: <c>/load/index data.spot_cards</c>
|
||||
/// — extractor reshapes the wire dict {card_id: cost} into a list of {card_id, cost} rows.
|
||||
/// </summary>
|
||||
public sealed class SpotCardSeed
|
||||
{
|
||||
[JsonPropertyName("card_id")] public long CardId { get; set; }
|
||||
[JsonPropertyName("cost")] public int Cost { get; set; }
|
||||
}
|
||||
13
SVSim.Bootstrap/Models/Seed/UnlimitedRestrictionSeed.cs
Normal file
13
SVSim.Bootstrap/Models/Seed/UnlimitedRestrictionSeed.cs
Normal file
@@ -0,0 +1,13 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace SVSim.Bootstrap.Models.Seed;
|
||||
|
||||
/// <summary>
|
||||
/// Mirrors one entry of <c>seeds/unlimited-restrictions.json</c>. Source: <c>/load/index
|
||||
/// data.unlimited_restricted_base_card_id_list</c> (dict {card_id: restriction_value}).
|
||||
/// </summary>
|
||||
public sealed class UnlimitedRestrictionSeed
|
||||
{
|
||||
[JsonPropertyName("card_id")] public long CardId { get; set; }
|
||||
[JsonPropertyName("restriction_value")] public int RestrictionValue { get; set; }
|
||||
}
|
||||
@@ -76,6 +76,20 @@ public static class Program
|
||||
if (!opts.SkipGlobals)
|
||||
{
|
||||
await new GlobalsImporter().ImportAllAsync(context, opts.CapturesDir);
|
||||
|
||||
// Load-index seed pipeline (Stage 9C replaced the old in-GlobalsImporter capture-parsing).
|
||||
// RotationConfigImporter writes the Rotation GameConfig section that RotationFlagUpdater
|
||||
// reads; CardImporter ran earlier in the !SkipCards block so CardSets are populated.
|
||||
await new RotationConfigImporter().ImportAsync(context, opts.SeedDir);
|
||||
await new MyRotationImporter().ImportAsync(context, opts.SeedDir);
|
||||
await new AvatarAbilityImporter().ImportAsync(context, opts.SeedDir);
|
||||
await new ArenaSeasonImporter().ImportAsync(context, opts.SeedDir);
|
||||
await new BattlePassImporter().ImportAsync(context, opts.SeedDir);
|
||||
await new DailyLoginBonusImporter().ImportAsync(context, opts.SeedDir);
|
||||
await new PreReleaseInfoImporter().ImportAsync(context, opts.SeedDir);
|
||||
await new CardListsImporter().ImportAsync(context, opts.SeedDir);
|
||||
await new RotationFlagUpdater().UpdateAsync(context);
|
||||
|
||||
await new PracticeOpponentImporter().ImportAsync(context, opts.SeedDir);
|
||||
await new PaymentItemImporter().ImportAsync(context, opts.SeedDir);
|
||||
var puzzleImporter = new PuzzleImporter();
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
namespace SVSim.Database.Models.Config;
|
||||
|
||||
/// <summary>
|
||||
/// Time-varying season/rotation state, populated by GlobalsImporter from prod captures.
|
||||
/// Time-varying season/rotation state, populated by RotationConfigImporter from seed files.
|
||||
/// <see cref="RotationCardSetIds"/> drives <c>CardSet.IsInRotation</c> via RotationFlagUpdater.
|
||||
/// </summary>
|
||||
[ConfigSection("Rotation")]
|
||||
public class RotationConfig
|
||||
@@ -10,6 +11,7 @@ public class RotationConfig
|
||||
public bool IsBattlePassPeriod { get; set; }
|
||||
public bool IsBeginnerMission { get; set; }
|
||||
public int CardSetIdForResourceDlView { get; set; }
|
||||
public List<int> RotationCardSetIds { get; set; } = new();
|
||||
|
||||
public static RotationConfig ShippedDefaults() => new();
|
||||
}
|
||||
|
||||
180
SVSim.UnitTests/Importers/CardListsImporterTests.cs
Normal file
180
SVSim.UnitTests/Importers/CardListsImporterTests.cs
Normal file
@@ -0,0 +1,180 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using SVSim.Bootstrap.Importers;
|
||||
using SVSim.Database;
|
||||
using SVSim.Database.Models;
|
||||
using SVSim.UnitTests.Infrastructure;
|
||||
|
||||
namespace SVSim.UnitTests.Importers;
|
||||
|
||||
/// <summary>
|
||||
/// Coverage for CardListsImporter (Stage 9C): one happy-path test per card-list sub-table plus
|
||||
/// idempotency and orphan-warning behavior. Production seeds reference cards that don't exist in
|
||||
/// the minimal 3-card test set, so the importer must complete without failing on FK orphans.
|
||||
/// </summary>
|
||||
public class CardListsImporterTests
|
||||
{
|
||||
private static string SeedDir => Path.Combine(AppContext.BaseDirectory, "Data", "seeds");
|
||||
|
||||
[Test]
|
||||
public async Task ImportAsync_writes_spot_cards_from_seed()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
|
||||
await new CardListsImporter().ImportAsync(db, SeedDir);
|
||||
|
||||
var rows = await db.SpotCards.ToListAsync();
|
||||
Assert.That(rows.Count, Is.GreaterThan(0), "spot-cards.json must produce rows");
|
||||
Assert.That(rows.All(r => r.Cost >= 0), Is.True, "Cost must be >= 0");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ImportAsync_writes_reprinted_cards_from_seed()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
|
||||
await new CardListsImporter().ImportAsync(db, SeedDir);
|
||||
|
||||
Assert.That(await db.ReprintedCards.CountAsync(), Is.GreaterThan(0),
|
||||
"reprinted-cards.json must produce rows");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ImportAsync_writes_unlimited_restrictions_with_values()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
|
||||
await new CardListsImporter().ImportAsync(db, SeedDir);
|
||||
|
||||
var rows = await db.UnlimitedRestrictions.ToListAsync();
|
||||
Assert.That(rows.Count, Is.GreaterThan(0), "unlimited-restrictions.json must produce rows");
|
||||
// RestrictionValue field must survive the import (e.g. 0 or 1).
|
||||
Assert.That(rows.All(r => r.RestrictionValue >= 0), Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ImportAsync_writes_loading_exclusion_cards_from_seed()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
|
||||
await new CardListsImporter().ImportAsync(db, SeedDir);
|
||||
|
||||
Assert.That(await db.LoadingExclusionCards.CountAsync(), Is.GreaterThan(0),
|
||||
"loading-exclusion-cards.json must produce rows");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ImportAsync_handles_empty_maintenance_card_seed()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
|
||||
// The shipped maintenance-cards.json is `[]` — confirm no rows created and no crash.
|
||||
await new CardListsImporter().ImportAsync(db, SeedDir);
|
||||
|
||||
Assert.That(await db.MaintenanceCards.CountAsync(), Is.EqualTo(0),
|
||||
"Empty maintenance seed should leave the table empty");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ImportAsync_handles_empty_feature_maintenance_seed()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
|
||||
await new CardListsImporter().ImportAsync(db, SeedDir);
|
||||
|
||||
Assert.That(await db.FeatureMaintenances.CountAsync(), Is.EqualTo(0),
|
||||
"Empty feature-maintenances seed should leave the table empty");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ImportAsync_is_idempotent()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
|
||||
await new CardListsImporter().ImportAsync(db, SeedDir);
|
||||
int spots1 = await db.SpotCards.CountAsync();
|
||||
int reprinted1 = await db.ReprintedCards.CountAsync();
|
||||
int unlimited1 = await db.UnlimitedRestrictions.CountAsync();
|
||||
int excl1 = await db.LoadingExclusionCards.CountAsync();
|
||||
|
||||
await new CardListsImporter().ImportAsync(db, SeedDir);
|
||||
|
||||
int spots2 = await db.SpotCards.CountAsync();
|
||||
int reprinted2 = await db.ReprintedCards.CountAsync();
|
||||
int unlimited2 = await db.UnlimitedRestrictions.CountAsync();
|
||||
int excl2 = await db.LoadingExclusionCards.CountAsync();
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(spots2, Is.EqualTo(spots1));
|
||||
Assert.That(reprinted2, Is.EqualTo(reprinted1));
|
||||
Assert.That(unlimited2, Is.EqualTo(unlimited1));
|
||||
Assert.That(excl2, Is.EqualTo(excl1));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ImportAsync_completes_when_seed_card_ids_are_orphans()
|
||||
{
|
||||
// The shipped seeds reference card_ids that DON'T exist in SVSimTestFactory's minimal
|
||||
// 3-card set — the orphan-warning path should log to stderr without throwing.
|
||||
using var factory = new SVSimTestFactory();
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
|
||||
Assert.That(await db.Cards.CountAsync(), Is.EqualTo(3),
|
||||
"Test factory should seed exactly 3 cards (orphan-warning precondition)");
|
||||
|
||||
Assert.DoesNotThrowAsync(async () =>
|
||||
{
|
||||
await new CardListsImporter().ImportAsync(db, SeedDir);
|
||||
});
|
||||
|
||||
// Importer still wrote rows despite orphans.
|
||||
Assert.That(await db.SpotCards.CountAsync(), Is.GreaterThan(0));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ImportAsync_writes_feature_maintenances_from_tiny_fixture()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
|
||||
// Build a temp seed dir with just feature-maintenances.json populated so we can exercise
|
||||
// the FeatureMaintenances clear-and-rewrite path without polluting the shipped seeds.
|
||||
string tmp = Path.Combine(Path.GetTempPath(), $"seed-{Guid.NewGuid()}");
|
||||
Directory.CreateDirectory(tmp);
|
||||
try
|
||||
{
|
||||
File.WriteAllText(Path.Combine(tmp, "feature-maintenances.json"),
|
||||
"[{\"id\":1,\"feature_key\":\"test_feature\",\"data\":{\"foo\":\"bar\"}}]");
|
||||
|
||||
await new CardListsImporter().ImportAsync(db, tmp);
|
||||
|
||||
var rows = await db.FeatureMaintenances.ToListAsync();
|
||||
Assert.That(rows.Count, Is.EqualTo(1));
|
||||
Assert.That(rows[0].FeatureKey, Is.EqualTo("test_feature"));
|
||||
Assert.That(rows[0].Data, Does.Contain("foo"));
|
||||
|
||||
// Rerun: clear-and-rewrite should keep the table at 1 row (same data).
|
||||
await new CardListsImporter().ImportAsync(db, tmp);
|
||||
Assert.That(await db.FeatureMaintenances.CountAsync(), Is.EqualTo(1));
|
||||
}
|
||||
finally { Directory.Delete(tmp, true); }
|
||||
}
|
||||
}
|
||||
@@ -189,9 +189,21 @@ internal sealed class SVSimTestFactory : WebApplicationFactory<Program>
|
||||
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.
|
||||
|
||||
// Load-index seed pipeline (Stage 9C). Mirrors the wiring in SVSim.Bootstrap.Program.cs:
|
||||
// RotationConfigImporter must precede RotationFlagUpdater; CardListsImporter is
|
||||
// ordered after the GameConfig importers for tidiness (no FK dependency).
|
||||
await new RotationConfigImporter().ImportAsync(ctx, seedDir);
|
||||
await new MyRotationImporter().ImportAsync(ctx, seedDir);
|
||||
await new AvatarAbilityImporter().ImportAsync(ctx, seedDir);
|
||||
await new ArenaSeasonImporter().ImportAsync(ctx, seedDir);
|
||||
await new BattlePassImporter().ImportAsync(ctx, seedDir);
|
||||
await new DailyLoginBonusImporter().ImportAsync(ctx, seedDir);
|
||||
await new PreReleaseInfoImporter().ImportAsync(ctx, seedDir);
|
||||
await new CardListsImporter().ImportAsync(ctx, seedDir);
|
||||
await new RotationFlagUpdater().UpdateAsync(ctx);
|
||||
|
||||
// Per-importer seed pipeline for the rest of the load-index split.
|
||||
await new PracticeOpponentImporter().ImportAsync(ctx, seedDir);
|
||||
await new PaymentItemImporter().ImportAsync(ctx, seedDir);
|
||||
var puzzleImporter = new PuzzleImporter();
|
||||
|
||||
Reference in New Issue
Block a user