Seeding reorg
This commit is contained in:
78
SVSim.Bootstrap/Importers/CardCosmeticRewardImporter.cs
Normal file
78
SVSim.Bootstrap/Importers/CardCosmeticRewardImporter.cs
Normal file
@@ -0,0 +1,78 @@
|
||||
using System.Globalization;
|
||||
using CsvHelper;
|
||||
using CsvHelper.Configuration;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SVSim.Database;
|
||||
using SVSim.Database.Enums;
|
||||
using SVSim.Database.Models;
|
||||
|
||||
namespace SVSim.Bootstrap.Importers;
|
||||
|
||||
/// <summary>
|
||||
/// Reads <c>card_cosmetic_rewards.csv</c> and upserts <see cref="CardCosmeticReward"/> rows.
|
||||
/// MUST run after <see cref="CardImporter"/> — the table has an FK to <c>Cards.Id</c>, so any
|
||||
/// reward whose CardId isn't in the freshly-imported cards table is skipped with a warning.
|
||||
/// </summary>
|
||||
public class CardCosmeticRewardImporter
|
||||
{
|
||||
public async Task ImportAsync(SVSimDbContext context, string dataDir)
|
||||
{
|
||||
string path = Path.Combine(dataDir, "card_cosmetic_rewards.csv");
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
Console.Error.WriteLine($"[CardCosmeticRewardImporter] Missing CSV: {path}");
|
||||
return;
|
||||
}
|
||||
|
||||
Console.WriteLine($"[CardCosmeticRewardImporter] Reading {path}...");
|
||||
|
||||
List<CardCosmeticReward> rows;
|
||||
using (var reader = new StreamReader(path))
|
||||
using (var csv = new CsvReader(reader, CultureInfo.InvariantCulture))
|
||||
{
|
||||
csv.Context.RegisterClassMap<CardCosmeticRewardMap>();
|
||||
rows = csv.GetRecords<CardCosmeticReward>().ToList();
|
||||
}
|
||||
|
||||
var validCardIds = (await context.Cards.Select(c => c.Id).ToListAsync()).ToHashSet();
|
||||
var existing = (await context.CardCosmeticRewards.ToListAsync())
|
||||
.ToDictionary(r => (r.CardId, r.Type, r.CosmeticId));
|
||||
|
||||
int created = 0, updated = 0, skipped = 0;
|
||||
foreach (var r in rows)
|
||||
{
|
||||
if (!validCardIds.Contains(r.CardId))
|
||||
{
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
var key = (r.CardId, r.Type, r.CosmeticId);
|
||||
if (existing.TryGetValue(key, out var e))
|
||||
{
|
||||
if (e.Quantity != r.Quantity) { e.Quantity = r.Quantity; updated++; }
|
||||
}
|
||||
else
|
||||
{
|
||||
context.CardCosmeticRewards.Add(r);
|
||||
created++;
|
||||
}
|
||||
}
|
||||
await context.SaveChangesAsync();
|
||||
Console.WriteLine(
|
||||
$"[CardCosmeticRewardImporter] Done: +{created} / ~{updated}, " +
|
||||
$"skipped {skipped} (no matching card row).");
|
||||
}
|
||||
|
||||
private sealed class CardCosmeticRewardMap : ClassMap<CardCosmeticReward>
|
||||
{
|
||||
public CardCosmeticRewardMap()
|
||||
{
|
||||
Map(m => m.CardId).Name("card_id");
|
||||
Map(m => m.Type).Name("type");
|
||||
Map(m => m.CosmeticId).Name("cosmetic_id");
|
||||
Map(m => m.Quantity).Name("quantity").Default(1);
|
||||
Map(m => m.Card).Ignore();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,8 +9,7 @@ namespace SVSim.Bootstrap.Importers;
|
||||
|
||||
/// <summary>
|
||||
/// Reads the loader's card dump (LitJson array of CardCSVData) and upserts ShadowverseCardEntry +
|
||||
/// ShadowverseCardSetEntry rows. Lifted unchanged from the original SVSim.CardImport.Program.Main —
|
||||
/// only the orchestration was moved into <see cref="Program"/>.
|
||||
/// ShadowverseCardSetEntry rows. Idempotent.
|
||||
/// </summary>
|
||||
public class CardImporter
|
||||
{
|
||||
|
||||
@@ -3,6 +3,7 @@ using Microsoft.EntityFrameworkCore;
|
||||
using SVSim.Database;
|
||||
using SVSim.Database.Enums;
|
||||
using SVSim.Database.Models;
|
||||
using SVSim.Database.Models.Config;
|
||||
using static SVSim.Bootstrap.Importers.ImporterBase;
|
||||
|
||||
namespace SVSim.Bootstrap.Importers;
|
||||
@@ -87,32 +88,95 @@ public class GlobalsImporter
|
||||
return total;
|
||||
}
|
||||
|
||||
// ---------- GameConfiguration ----------
|
||||
// ---------- GameConfig sections ----------
|
||||
|
||||
private async Task<int> ImportGameConfigurationExtensions(SVSimDbContext context, JsonElement loadIndex)
|
||||
{
|
||||
var cfg = await context.GameConfigurations.FirstOrDefaultAsync(g => g.Id == "default");
|
||||
if (cfg is null)
|
||||
{
|
||||
Console.Error.WriteLine("[GlobalsImporter] GameConfigurations 'default' row missing; " +
|
||||
"DefaultSettingsSeeder should have created it. Skipping extensions.");
|
||||
return 0;
|
||||
}
|
||||
// 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;
|
||||
|
||||
// TODO: fixed in Task 6 — writes through Config tree after RefactorGameConfigurationToJsonb
|
||||
cfg.Config.Rotation.TsRotationId = GetString(loadIndex, "ts_rotation_id");
|
||||
cfg.Config.Rotation.IsBattlePassPeriod = GetBool(loadIndex, "is_battle_pass_period");
|
||||
cfg.Config.Rotation.IsBeginnerMission = GetBool(loadIndex, "is_beginner_mission");
|
||||
cfg.Config.Rotation.CardSetIdForResourceDlView = GetInt(loadIndex, "card_set_id_for_resource_dl_view");
|
||||
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))
|
||||
{
|
||||
cfg.Config.Challenge.UseTwoPickPremiumCard = GetBool(cc, "use_challenge_two_pick_premium_card");
|
||||
cfg.Config.Challenge.TwoPickSleeveId = GetLong(cc, "challenge_two_pick_sleeve_id");
|
||||
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++;
|
||||
}
|
||||
|
||||
Console.WriteLine($"[GlobalsImporter] GameConfiguration extensions: ts_rotation_id={cfg.Config.Rotation.TsRotationId}");
|
||||
return 1;
|
||||
// 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 ----------
|
||||
|
||||
307
SVSim.Bootstrap/Importers/ReferenceDataImporter.cs
Normal file
307
SVSim.Bootstrap/Importers/ReferenceDataImporter.cs
Normal file
@@ -0,0 +1,307 @@
|
||||
using System.Globalization;
|
||||
using CsvHelper;
|
||||
using CsvHelper.Configuration;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SVSim.Database;
|
||||
using SVSim.Database.Models;
|
||||
|
||||
namespace SVSim.Bootstrap.Importers;
|
||||
|
||||
/// <summary>
|
||||
/// Idempotently upserts the 9 reference-data tables (classes, leader skins, sleeves, emblems,
|
||||
/// degrees, battlefields, my-page backgrounds, ranks, class-XP) from CSVs under
|
||||
/// <c>{AppContext.BaseDirectory}/Data/</c>. Order within ImportAllAsync respects FK
|
||||
/// dependencies (Classes before LeaderSkins).
|
||||
/// </summary>
|
||||
public class ReferenceDataImporter
|
||||
{
|
||||
public async Task ImportAllAsync(SVSimDbContext context, string dataDir)
|
||||
{
|
||||
if (!Directory.Exists(dataDir))
|
||||
{
|
||||
Console.Error.WriteLine($"[ReferenceDataImporter] Data dir missing: {dataDir}");
|
||||
return;
|
||||
}
|
||||
Console.WriteLine($"[ReferenceDataImporter] Reading CSVs from {dataDir}...");
|
||||
|
||||
await ImportClasses(context, dataDir);
|
||||
await ImportLeaderSkins(context, dataDir);
|
||||
await ImportSleeves(context, dataDir);
|
||||
await ImportEmblems(context, dataDir);
|
||||
await ImportDegrees(context, dataDir);
|
||||
await ImportBattlefields(context, dataDir);
|
||||
await ImportMyPageBackgrounds(context, dataDir);
|
||||
await ImportRankInfo(context, dataDir);
|
||||
await ImportClassExp(context, dataDir);
|
||||
|
||||
Console.WriteLine("[ReferenceDataImporter] Done.");
|
||||
}
|
||||
|
||||
private static async Task ImportClasses(SVSimDbContext ctx, string dir)
|
||||
{
|
||||
var rows = ReadCsv<ClassEntry, ClassEntryMap>(dir, "classes.csv");
|
||||
var existing = await ctx.Classes.ToDictionaryAsync(c => c.Id);
|
||||
int created = 0, updated = 0;
|
||||
foreach (var r in rows)
|
||||
{
|
||||
if (existing.TryGetValue(r.Id, out var e))
|
||||
{
|
||||
if (e.Name != r.Name) { e.Name = r.Name; updated++; }
|
||||
}
|
||||
else { ctx.Classes.Add(r); created++; }
|
||||
}
|
||||
await ctx.SaveChangesAsync();
|
||||
Console.WriteLine($"[ReferenceDataImporter] Classes: +{created} / ~{updated}");
|
||||
}
|
||||
|
||||
private static async Task ImportLeaderSkins(SVSimDbContext ctx, string dir)
|
||||
{
|
||||
var rows = ReadCsv<LeaderSkinEntry, LeaderSkinEntryMap>(dir, "leaderskins.csv");
|
||||
// CSV writes class_chara_id=0 for neutral/unassigned; the FK column is nullable.
|
||||
foreach (var r in rows) if (r.ClassId == 0) r.ClassId = null;
|
||||
|
||||
var existing = await ctx.LeaderSkins.ToDictionaryAsync(s => s.Id);
|
||||
int created = 0, updated = 0;
|
||||
foreach (var r in rows)
|
||||
{
|
||||
if (existing.TryGetValue(r.Id, out var e))
|
||||
{
|
||||
bool changed = false;
|
||||
if (e.Name != r.Name) { e.Name = r.Name; changed = true; }
|
||||
if (e.ClassId != r.ClassId) { e.ClassId = r.ClassId; changed = true; }
|
||||
if (changed) updated++;
|
||||
}
|
||||
else { ctx.LeaderSkins.Add(r); created++; }
|
||||
}
|
||||
await ctx.SaveChangesAsync();
|
||||
Console.WriteLine($"[ReferenceDataImporter] LeaderSkins: +{created} / ~{updated}");
|
||||
}
|
||||
|
||||
private static async Task ImportSleeves(SVSimDbContext ctx, string dir)
|
||||
{
|
||||
var rows = ReadCsv<SleeveEntry, SleeveEntryMap>(dir, "sleeves.csv");
|
||||
var existing = (await ctx.Sleeves.ToListAsync()).ToHashSet();
|
||||
int created = 0;
|
||||
foreach (var r in rows)
|
||||
{
|
||||
if (existing.Any(e => e.Id == r.Id)) continue;
|
||||
ctx.Sleeves.Add(r); created++;
|
||||
}
|
||||
await ctx.SaveChangesAsync();
|
||||
Console.WriteLine($"[ReferenceDataImporter] Sleeves: +{created}");
|
||||
}
|
||||
|
||||
private static async Task ImportEmblems(SVSimDbContext ctx, string dir)
|
||||
{
|
||||
var rows = ReadCsv<EmblemEntry, EmblemEntryMap>(dir, "emblems.csv");
|
||||
var existing = (await ctx.Emblems.Select(e => e.Id).ToListAsync()).ToHashSet();
|
||||
int created = 0;
|
||||
foreach (var r in rows)
|
||||
{
|
||||
if (existing.Contains(r.Id)) continue;
|
||||
ctx.Emblems.Add(r); created++;
|
||||
}
|
||||
await ctx.SaveChangesAsync();
|
||||
Console.WriteLine($"[ReferenceDataImporter] Emblems: +{created}");
|
||||
}
|
||||
|
||||
private static async Task ImportDegrees(SVSimDbContext ctx, string dir)
|
||||
{
|
||||
var rows = ReadCsv<DegreeEntry, DegreeEntryMap>(dir, "degrees.csv");
|
||||
var existing = (await ctx.Degrees.Select(e => e.Id).ToListAsync()).ToHashSet();
|
||||
int created = 0;
|
||||
foreach (var r in rows)
|
||||
{
|
||||
if (existing.Contains(r.Id)) continue;
|
||||
ctx.Degrees.Add(r); created++;
|
||||
}
|
||||
await ctx.SaveChangesAsync();
|
||||
Console.WriteLine($"[ReferenceDataImporter] Degrees: +{created}");
|
||||
}
|
||||
|
||||
private static async Task ImportBattlefields(SVSimDbContext ctx, string dir)
|
||||
{
|
||||
var rows = ReadCsv<BattlefieldEntry, BattlefieldEntryMap>(dir, "battlefields.csv");
|
||||
var existing = await ctx.Battlefields.ToDictionaryAsync(b => b.Id);
|
||||
int created = 0, updated = 0;
|
||||
foreach (var r in rows)
|
||||
{
|
||||
if (existing.TryGetValue(r.Id, out var e))
|
||||
{
|
||||
if (e.IsOpen != r.IsOpen) { e.IsOpen = r.IsOpen; updated++; }
|
||||
}
|
||||
else { ctx.Battlefields.Add(r); created++; }
|
||||
}
|
||||
await ctx.SaveChangesAsync();
|
||||
Console.WriteLine($"[ReferenceDataImporter] Battlefields: +{created} / ~{updated}");
|
||||
}
|
||||
|
||||
private static async Task ImportMyPageBackgrounds(SVSimDbContext ctx, string dir)
|
||||
{
|
||||
var rows = ReadCsv<MyPageBackgroundEntry, MyPageBackgroundEntryMap>(dir, "mypagebackgrounds.csv");
|
||||
var existing = (await ctx.MyPageBackgrounds.Select(e => e.Id).ToListAsync()).ToHashSet();
|
||||
int created = 0;
|
||||
foreach (var r in rows)
|
||||
{
|
||||
if (existing.Contains(r.Id)) continue;
|
||||
ctx.MyPageBackgrounds.Add(r); created++;
|
||||
}
|
||||
await ctx.SaveChangesAsync();
|
||||
Console.WriteLine($"[ReferenceDataImporter] MyPageBackgrounds: +{created}");
|
||||
}
|
||||
|
||||
private static async Task ImportRankInfo(SVSimDbContext ctx, string dir)
|
||||
{
|
||||
var rows = ReadCsv<RankInfoEntry, RankInfoEntryMap>(dir, "ranks.csv");
|
||||
var existing = await ctx.RankInfo.ToDictionaryAsync(r => r.Id);
|
||||
int created = 0, updated = 0;
|
||||
foreach (var r in rows)
|
||||
{
|
||||
if (existing.TryGetValue(r.Id, out var e))
|
||||
{
|
||||
if (ApplyRankUpdates(e, r)) updated++;
|
||||
}
|
||||
else { ctx.RankInfo.Add(r); created++; }
|
||||
}
|
||||
await ctx.SaveChangesAsync();
|
||||
Console.WriteLine($"[ReferenceDataImporter] RankInfo: +{created} / ~{updated}");
|
||||
}
|
||||
|
||||
private static bool ApplyRankUpdates(RankInfoEntry e, RankInfoEntry r)
|
||||
{
|
||||
bool changed = false;
|
||||
if (e.Name != r.Name) { e.Name = r.Name; changed = true; }
|
||||
if (e.NecessaryPoint != r.NecessaryPoint) { e.NecessaryPoint = r.NecessaryPoint; changed = true; }
|
||||
if (e.AccumulatePoint != r.AccumulatePoint) { e.AccumulatePoint = r.AccumulatePoint; changed = true; }
|
||||
if (e.LowerLimitPoint != r.LowerLimitPoint) { e.LowerLimitPoint = r.LowerLimitPoint; changed = true; }
|
||||
if (e.BaseAddBp != r.BaseAddBp) { e.BaseAddBp = r.BaseAddBp; changed = true; }
|
||||
if (e.BaseDropBp != r.BaseDropBp) { e.BaseDropBp = r.BaseDropBp; changed = true; }
|
||||
if (e.StreakBonusPt != r.StreakBonusPt) { e.StreakBonusPt = r.StreakBonusPt; changed = true; }
|
||||
if (e.WinBonus != r.WinBonus) { e.WinBonus = r.WinBonus; changed = true; }
|
||||
if (e.LoseBonus != r.LoseBonus) { e.LoseBonus = r.LoseBonus; changed = true; }
|
||||
if (e.MaxWinBonus != r.MaxWinBonus) { e.MaxWinBonus = r.MaxWinBonus; changed = true; }
|
||||
if (e.MaxLoseBonus != r.MaxLoseBonus) { e.MaxLoseBonus = r.MaxLoseBonus; changed = true; }
|
||||
if (e.IsPromotionWar != r.IsPromotionWar) { e.IsPromotionWar = r.IsPromotionWar; changed = true; }
|
||||
if (e.MatchCount != r.MatchCount) { e.MatchCount = r.MatchCount; changed = true; }
|
||||
if (e.NecessaryWin != r.NecessaryWin) { e.NecessaryWin = r.NecessaryWin; changed = true; }
|
||||
if (e.ResetLose != r.ResetLose) { e.ResetLose = r.ResetLose; changed = true; }
|
||||
if (e.AccumulateMasterPoint != r.AccumulateMasterPoint) { e.AccumulateMasterPoint = r.AccumulateMasterPoint; changed = true; }
|
||||
return changed;
|
||||
}
|
||||
|
||||
private static async Task ImportClassExp(SVSimDbContext ctx, string dir)
|
||||
{
|
||||
var rows = ReadCsv<ClassExpEntry, ClassExpEntryMap>(dir, "classexp.csv");
|
||||
var existing = await ctx.ClassExpCurve.ToDictionaryAsync(c => c.Id);
|
||||
int created = 0, updated = 0;
|
||||
foreach (var r in rows)
|
||||
{
|
||||
if (existing.TryGetValue(r.Id, out var e))
|
||||
{
|
||||
if (e.NecessaryExp != r.NecessaryExp) { e.NecessaryExp = r.NecessaryExp; updated++; }
|
||||
}
|
||||
else { ctx.ClassExpCurve.Add(r); created++; }
|
||||
}
|
||||
await ctx.SaveChangesAsync();
|
||||
Console.WriteLine($"[ReferenceDataImporter] ClassExp: +{created} / ~{updated}");
|
||||
}
|
||||
|
||||
private static List<T> ReadCsv<T, TMap>(string dir, string fileName) where TMap : ClassMap<T>, new()
|
||||
{
|
||||
string path = Path.Combine(dir, fileName);
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
Console.Error.WriteLine($"[ReferenceDataImporter] Missing CSV: {path}");
|
||||
return new List<T>();
|
||||
}
|
||||
using var reader = new StreamReader(path);
|
||||
using var csv = new CsvReader(reader, CultureInfo.InvariantCulture);
|
||||
csv.Context.RegisterClassMap<TMap>();
|
||||
return csv.GetRecords<T>().ToList();
|
||||
}
|
||||
|
||||
private sealed class ClassEntryMap : ClassMap<ClassEntry>
|
||||
{
|
||||
public ClassEntryMap()
|
||||
{
|
||||
Map(m => m.Id).Name("id");
|
||||
Map(m => m.Name).Name("name");
|
||||
Map(m => m.DefaultLeaderSkin).Ignore();
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class LeaderSkinEntryMap : ClassMap<LeaderSkinEntry>
|
||||
{
|
||||
public LeaderSkinEntryMap()
|
||||
{
|
||||
Map(m => m.Id).Name("class_chara_id");
|
||||
Map(m => m.Name).Name("class_chara_name");
|
||||
Map(m => m.ClassId).Name("clan");
|
||||
Map(m => m.Class).Ignore();
|
||||
Map(m => m.Viewers).Ignore();
|
||||
Map(m => m.EmoteId).Ignore();
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class EmblemEntryMap : ClassMap<EmblemEntry>
|
||||
{
|
||||
public EmblemEntryMap() { Map(m => m.Id).Name("emblem_id"); }
|
||||
}
|
||||
|
||||
private sealed class SleeveEntryMap : ClassMap<SleeveEntry>
|
||||
{
|
||||
public SleeveEntryMap() { Map(m => m.Id).Name("sleeve_id"); }
|
||||
}
|
||||
|
||||
private sealed class DegreeEntryMap : ClassMap<DegreeEntry>
|
||||
{
|
||||
public DegreeEntryMap() { Map(m => m.Id).Name("degree_id"); }
|
||||
}
|
||||
|
||||
private sealed class BattlefieldEntryMap : ClassMap<BattlefieldEntry>
|
||||
{
|
||||
public BattlefieldEntryMap()
|
||||
{
|
||||
Map(m => m.Id).Name("value");
|
||||
Map(m => m.IsOpen).Name("is_open");
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class MyPageBackgroundEntryMap : ClassMap<MyPageBackgroundEntry>
|
||||
{
|
||||
public MyPageBackgroundEntryMap() { Map(m => m.Id).Name("id"); }
|
||||
}
|
||||
|
||||
private sealed class ClassExpEntryMap : ClassMap<ClassExpEntry>
|
||||
{
|
||||
public ClassExpEntryMap()
|
||||
{
|
||||
Map(m => m.Id).Name("level");
|
||||
Map(m => m.NecessaryExp).Name("necessary_exp");
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class RankInfoEntryMap : ClassMap<RankInfoEntry>
|
||||
{
|
||||
public RankInfoEntryMap()
|
||||
{
|
||||
Map(m => m.Id).Name("rank_id");
|
||||
Map(m => m.Name).Name("rank_name");
|
||||
Map(m => m.NecessaryPoint).Name("necessary_point");
|
||||
Map(m => m.AccumulatePoint).Name("accumulate_point");
|
||||
Map(m => m.LowerLimitPoint).Name("lower_limit_point");
|
||||
Map(m => m.BaseAddBp).Name("base_add_bp");
|
||||
Map(m => m.BaseDropBp).Name("base_drop_bp");
|
||||
Map(m => m.StreakBonusPt).Name("streak_bonus_pt");
|
||||
Map(m => m.WinBonus).Name("win_bonus");
|
||||
Map(m => m.LoseBonus).Name("lose_bonus");
|
||||
Map(m => m.MaxWinBonus).Name("max_win_bonus");
|
||||
Map(m => m.MaxLoseBonus).Name("max_lose_bonus");
|
||||
Map(m => m.IsPromotionWar).Name("is_promotion_war");
|
||||
Map(m => m.MatchCount).Name("match_count");
|
||||
Map(m => m.NecessaryWin).Name("necessary_win");
|
||||
Map(m => m.ResetLose).Name("reset_lose");
|
||||
Map(m => m.AccumulateMasterPoint).Name("accumulate_master_point");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -25,15 +25,16 @@ public static class Program
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (opts.SkipCards && opts.SkipGlobals)
|
||||
if (opts.SkipReference && opts.SkipCards && opts.SkipGlobals)
|
||||
{
|
||||
Console.Error.WriteLine("Both --skip-cards and --skip-globals set; nothing to do.");
|
||||
Console.Error.WriteLine("All --skip-* flags set; nothing to do.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
Console.WriteLine($"[Bootstrap] Connection: {RedactPassword(opts.ConnectionString)}");
|
||||
Console.WriteLine($"[Bootstrap] Cards file: {opts.CardsFile}");
|
||||
Console.WriteLine($"[Bootstrap] Captures: {opts.CapturesDir}");
|
||||
Console.WriteLine($"[Bootstrap] Connection: {RedactPassword(opts.ConnectionString)}");
|
||||
Console.WriteLine($"[Bootstrap] Reference CSVs: {opts.ReferenceDataDir}");
|
||||
Console.WriteLine($"[Bootstrap] Cards file: {opts.CardsFile}");
|
||||
Console.WriteLine($"[Bootstrap] Captures: {opts.CapturesDir}");
|
||||
|
||||
var dbOptions = new DbContextOptionsBuilder<SVSimDbContext>()
|
||||
.UseNpgsql(opts.ConnectionString)
|
||||
@@ -41,18 +42,35 @@ public static class Program
|
||||
|
||||
await using var context = new SVSimDbContext(NullLogger<SVSimDbContext>.Instance, dbOptions);
|
||||
|
||||
// Bootstrap applies pending migrations first so it can be the very first thing run after
|
||||
// `dotnet ef migrations add` — no need to start the server too.
|
||||
// Bootstrap applies pending migrations first — migrations are now DDL-only, all data
|
||||
// (reference tables, cards, card cosmetic rewards, prod-captured globals, game config)
|
||||
// is loaded by importers below. This means a freshly migrated DB is structure-only;
|
||||
// every importer is idempotent so re-running is safe.
|
||||
Console.WriteLine("[Bootstrap] Applying pending migrations...");
|
||||
await context.Database.MigrateAsync();
|
||||
|
||||
// GameConfigSection rows for every [ConfigSection] type — runtime seed (HasData doesn't
|
||||
// play well with OwnsOne+ToJson). Always run; tiers only insert missing sections.
|
||||
await context.EnsureSeedDataAsync();
|
||||
|
||||
if (!opts.SkipReference)
|
||||
{
|
||||
await new ReferenceDataImporter().ImportAllAsync(context, opts.ReferenceDataDir);
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine("[Bootstrap] --skip-reference set; skipping reference data import.");
|
||||
}
|
||||
|
||||
if (!opts.SkipCards)
|
||||
{
|
||||
await new CardImporter().ImportAsync(context, opts.CardsFile);
|
||||
// Card cosmetic rewards FK to Cards; piggy-back on --skip-cards.
|
||||
await new CardCosmeticRewardImporter().ImportAsync(context, opts.ReferenceDataDir);
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine("[Bootstrap] --skip-cards set; skipping card import.");
|
||||
Console.WriteLine("[Bootstrap] --skip-cards set; skipping card + cosmetic-reward import.");
|
||||
}
|
||||
|
||||
if (!opts.SkipGlobals)
|
||||
@@ -73,7 +91,9 @@ public static class Program
|
||||
string? dataDir = null;
|
||||
string? cards = null;
|
||||
string? captures = null;
|
||||
string? referenceDataDir = null;
|
||||
string? connection = null;
|
||||
bool skipReference = false;
|
||||
bool skipCards = false;
|
||||
bool skipGlobals = false;
|
||||
string? positionalCards = null;
|
||||
@@ -86,7 +106,9 @@ public static class Program
|
||||
case "--data-dir": dataDir = NextArg(args, ref i); break;
|
||||
case "--cards": cards = NextArg(args, ref i); break;
|
||||
case "--captures": captures = NextArg(args, ref i); break;
|
||||
case "--reference-data-dir": referenceDataDir = NextArg(args, ref i); break;
|
||||
case "--connection-string": connection = NextArg(args, ref i); break;
|
||||
case "--skip-reference": skipReference = true; break;
|
||||
case "--skip-cards": skipCards = true; break;
|
||||
case "--skip-globals": skipGlobals = true; break;
|
||||
default:
|
||||
@@ -100,9 +122,11 @@ public static class Program
|
||||
|
||||
// Resolution order:
|
||||
// --cards beats --data-dir/cards.json beats legacy positional;
|
||||
// --captures beats --data-dir/prod-captures beats Bootstrap/Data/prod-captures (shipped default).
|
||||
// --captures beats --data-dir/prod-captures beats Bootstrap/Data/prod-captures (shipped default);
|
||||
// --reference-data-dir beats shipped Bootstrap/Data (the CSVs always ship next to the binary).
|
||||
string baseDir = AppContext.BaseDirectory;
|
||||
string shippedCaptures = Path.Combine(baseDir, "Data", "prod-captures");
|
||||
string shippedDataDir = Path.Combine(baseDir, "Data");
|
||||
string shippedCaptures = Path.Combine(shippedDataDir, "prod-captures");
|
||||
|
||||
string cardsFile = cards
|
||||
?? (dataDir is not null ? Path.Combine(dataDir, "cards.json") : null)
|
||||
@@ -118,11 +142,14 @@ public static class Program
|
||||
? capturesCandidate
|
||||
: shippedCaptures;
|
||||
|
||||
string refDir = referenceDataDir ?? shippedDataDir;
|
||||
|
||||
string connStr = connection
|
||||
?? Environment.GetEnvironmentVariable("NPGSQL_CONNECTION")
|
||||
?? DefaultConnectionString;
|
||||
|
||||
return new BootstrapOptions(cardsFile, capturesDir, connStr, skipCards, skipGlobals);
|
||||
return new BootstrapOptions(
|
||||
cardsFile, capturesDir, refDir, connStr, skipReference, skipCards, skipGlobals);
|
||||
}
|
||||
|
||||
private static string NextArg(string[] args, ref int i)
|
||||
@@ -139,15 +166,18 @@ public static class Program
|
||||
Console.Error.WriteLine(
|
||||
"Usage: svsim-bootstrap [options]\n" +
|
||||
"\n" +
|
||||
" --data-dir <path> Directory containing cards.json and prod-captures/\n" +
|
||||
" (default: ./data_dumps relative to working dir)\n" +
|
||||
" --cards <file> Override path to cards.json\n" +
|
||||
" --captures <dir> Override path to prod-captures directory\n" +
|
||||
" (default: shipped Data/prod-captures next to the binary)\n" +
|
||||
" --connection-string <conn> Postgres connection (or NPGSQL_CONNECTION env var,\n" +
|
||||
$" then \"{DefaultConnectionString}\")\n" +
|
||||
" --skip-cards Skip card import (re-run globals only)\n" +
|
||||
" --skip-globals Skip globals import (cards only — legacy behavior)\n" +
|
||||
" --data-dir <path> Directory containing cards.json and prod-captures/\n" +
|
||||
" (default: ./data_dumps relative to working dir)\n" +
|
||||
" --cards <file> Override path to cards.json\n" +
|
||||
" --captures <dir> Override path to prod-captures directory\n" +
|
||||
" (default: shipped Data/prod-captures next to the binary)\n" +
|
||||
" --reference-data-dir <dir> Override reference CSV directory\n" +
|
||||
" (default: shipped Data/ next to the binary)\n" +
|
||||
" --connection-string <conn> Postgres connection (or NPGSQL_CONNECTION env var,\n" +
|
||||
$" then \"{DefaultConnectionString}\")\n" +
|
||||
" --skip-reference Skip reference-data import (classes, sleeves, ranks, ...)\n" +
|
||||
" --skip-cards Skip card + card-cosmetic-reward import\n" +
|
||||
" --skip-globals Skip prod-captured globals import\n" +
|
||||
"\n" +
|
||||
"Back-compat: `svsim-bootstrap <cards.json> [connection]` still works (positional).");
|
||||
}
|
||||
@@ -155,7 +185,9 @@ public static class Program
|
||||
private sealed record BootstrapOptions(
|
||||
string CardsFile,
|
||||
string CapturesDir,
|
||||
string ReferenceDataDir,
|
||||
string ConnectionString,
|
||||
bool SkipReference,
|
||||
bool SkipCards,
|
||||
bool SkipGlobals);
|
||||
}
|
||||
|
||||
@@ -13,18 +13,13 @@
|
||||
<Content Include="Data\prod-captures\*.json">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
<!--
|
||||
Seed CSVs live in SVSim.EmulatedEntrypoint/Data — link them here so `dotnet ef migrations add`
|
||||
(which uses Bootstrap as the startup project) finds the same files at AppContext.BaseDirectory.
|
||||
Otherwise BaseDataSeeder.Seed short-circuits, the design-time model has no HasData rows, and
|
||||
every migration diff wants to DeleteData/InsertData for all of them.
|
||||
-->
|
||||
<Content Include="..\SVSim.EmulatedEntrypoint\Data\*.csv" Link="Data\%(Filename)%(Extension)">
|
||||
<Content Include="Data\*.csv">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="CsvHelper" Version="33.0.1" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.8">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace SVSim.Database.Common;
|
||||
|
||||
public interface IDataSeeder
|
||||
{
|
||||
void Seed(ModelBuilder builder);
|
||||
}
|
||||
@@ -1,174 +0,0 @@
|
||||
using System.Globalization;
|
||||
using CsvHelper;
|
||||
using CsvHelper.Configuration;
|
||||
using SVSim.Database.Common;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SVSim.Database.Models;
|
||||
|
||||
namespace SVSim.Database.DataSeeders;
|
||||
|
||||
/// <summary>
|
||||
/// Loads base data aside from cards into the DB. Cards excluded due to how many of them there are.
|
||||
/// </summary>
|
||||
public class BaseDataSeeder : IDataSeeder
|
||||
{
|
||||
private static string DataPath(string fileName) =>
|
||||
Path.Combine(AppContext.BaseDirectory, "Data", fileName);
|
||||
|
||||
private static List<T> ReadCsv<T, TMap>(string fileName) where TMap : ClassMap<T>, new()
|
||||
{
|
||||
using StreamReader reader = new(DataPath(fileName));
|
||||
using CsvReader csv = new(reader, CultureInfo.InvariantCulture);
|
||||
csv.Context.RegisterClassMap<TMap>();
|
||||
return csv.GetRecords<T>().ToList();
|
||||
}
|
||||
|
||||
private class ClassEntryMap : ClassMap<ClassEntry>
|
||||
{
|
||||
public ClassEntryMap()
|
||||
{
|
||||
Map(m => m.Id).Name("id");
|
||||
Map(m => m.Name).Name("name");
|
||||
Map(m => m.DefaultLeaderSkin).Ignore();
|
||||
}
|
||||
}
|
||||
private class LeaderSkinEntryMap : ClassMap<LeaderSkinEntry>
|
||||
{
|
||||
public LeaderSkinEntryMap()
|
||||
{
|
||||
Map(m => m.Id).Name("class_chara_id");
|
||||
Map(m => m.Name).Name("class_chara_name");
|
||||
Map(m => m.ClassId).Name("clan");
|
||||
Map(m => m.Class).Ignore();
|
||||
Map(m => m.Viewers).Ignore();
|
||||
Map(m => m.EmoteId).Ignore();
|
||||
}
|
||||
}
|
||||
private class EmblemEntryMap : ClassMap<EmblemEntry>
|
||||
{
|
||||
public EmblemEntryMap()
|
||||
{
|
||||
Map(m => m.Id).Name("emblem_id");
|
||||
}
|
||||
}
|
||||
private class SleeveEntryMap : ClassMap<SleeveEntry>
|
||||
{
|
||||
public SleeveEntryMap()
|
||||
{
|
||||
Map(m => m.Id).Name("sleeve_id");
|
||||
}
|
||||
}
|
||||
|
||||
private class DegreeEntryMap : ClassMap<DegreeEntry>
|
||||
{
|
||||
public DegreeEntryMap()
|
||||
{
|
||||
Map(m => m.Id).Name("degree_id");
|
||||
}
|
||||
}
|
||||
|
||||
private class BattlefieldEntryMap : ClassMap<BattlefieldEntry>
|
||||
{
|
||||
public BattlefieldEntryMap()
|
||||
{
|
||||
Map(m => m.Id).Name("value");
|
||||
Map(m => m.IsOpen).Name("is_open");
|
||||
}
|
||||
}
|
||||
|
||||
private class MyPageBackgroundEntryMap : ClassMap<MyPageBackgroundEntry>
|
||||
{
|
||||
public MyPageBackgroundEntryMap()
|
||||
{
|
||||
Map(m => m.Id).Name("id");
|
||||
}
|
||||
}
|
||||
|
||||
private class ClassExpEntryMap : ClassMap<ClassExpEntry>
|
||||
{
|
||||
public ClassExpEntryMap()
|
||||
{
|
||||
Map(m => m.Id).Name("level");
|
||||
Map(m => m.NecessaryExp).Name("necessary_exp");
|
||||
}
|
||||
}
|
||||
|
||||
private class RankInfoEntryMap : ClassMap<RankInfoEntry>
|
||||
{
|
||||
public RankInfoEntryMap()
|
||||
{
|
||||
Map(m => m.Id).Name("rank_id");
|
||||
Map(m => m.Name).Name("rank_name");
|
||||
Map(m => m.NecessaryPoint).Name("necessary_point");
|
||||
Map(m => m.AccumulatePoint).Name("accumulate_point");
|
||||
Map(m => m.LowerLimitPoint).Name("lower_limit_point");
|
||||
Map(m => m.BaseAddBp).Name("base_add_bp");
|
||||
Map(m => m.BaseDropBp).Name("base_drop_bp");
|
||||
Map(m => m.StreakBonusPt).Name("streak_bonus_pt");
|
||||
Map(m => m.WinBonus).Name("win_bonus");
|
||||
Map(m => m.LoseBonus).Name("lose_bonus");
|
||||
Map(m => m.MaxWinBonus).Name("max_win_bonus");
|
||||
Map(m => m.MaxLoseBonus).Name("max_lose_bonus");
|
||||
Map(m => m.IsPromotionWar).Name("is_promotion_war");
|
||||
Map(m => m.MatchCount).Name("match_count");
|
||||
Map(m => m.NecessaryWin).Name("necessary_win");
|
||||
Map(m => m.ResetLose).Name("reset_lose");
|
||||
Map(m => m.AccumulateMasterPoint).Name("accumulate_master_point");
|
||||
}
|
||||
}
|
||||
|
||||
private class CardCosmeticRewardMap : ClassMap<CardCosmeticReward>
|
||||
{
|
||||
public CardCosmeticRewardMap()
|
||||
{
|
||||
Map(m => m.CardId).Name("card_id");
|
||||
Map(m => m.Type).Name("type");
|
||||
Map(m => m.CosmeticId).Name("cosmetic_id");
|
||||
Map(m => m.Quantity).Name("quantity").Default(1);
|
||||
Map(m => m.Card).Ignore();
|
||||
}
|
||||
}
|
||||
|
||||
public void Seed(ModelBuilder builder)
|
||||
{
|
||||
// Migrations bake the HasData rows into InsertData calls — once the migration is
|
||||
// generated, runtime model-creation no longer needs the CSVs. Tools that only query
|
||||
// an already-migrated DB (e.g. SVSim.CardImport) don't ship the Data folder; skip
|
||||
// gracefully so DbContext construction succeeds for them.
|
||||
if (!File.Exists(DataPath("classes.csv")))
|
||||
{
|
||||
Console.Error.WriteLine($"[BaseDataSeeder] Skipping seed: Data folder not found at {DataPath("")}");
|
||||
return;
|
||||
}
|
||||
|
||||
List<ClassEntry> classes = ReadCsv<ClassEntry, ClassEntryMap>("classes.csv");
|
||||
List<LeaderSkinEntry> leaderSkins = ReadCsv<LeaderSkinEntry, LeaderSkinEntryMap>("leaderskins.csv");
|
||||
leaderSkins.ForEach(skin =>
|
||||
{
|
||||
if (skin.ClassId == 0)
|
||||
{
|
||||
skin.ClassId = null;
|
||||
}
|
||||
});
|
||||
|
||||
List<EmblemEntry> emblems = ReadCsv<EmblemEntry, EmblemEntryMap>("emblems.csv");
|
||||
List<DegreeEntry> degrees = ReadCsv<DegreeEntry, DegreeEntryMap>("degrees.csv");
|
||||
List<SleeveEntry> sleeves = ReadCsv<SleeveEntry, SleeveEntryMap>("sleeves.csv");
|
||||
List<BattlefieldEntry> battlefields = ReadCsv<BattlefieldEntry, BattlefieldEntryMap>("battlefields.csv");
|
||||
List<MyPageBackgroundEntry> myPageBackgrounds = ReadCsv<MyPageBackgroundEntry, MyPageBackgroundEntryMap>("mypagebackgrounds.csv");
|
||||
List<RankInfoEntry> rankinfos = ReadCsv<RankInfoEntry, RankInfoEntryMap>("ranks.csv");
|
||||
List<ClassExpEntry> classexp = ReadCsv<ClassExpEntry, ClassExpEntryMap>("classexp.csv");
|
||||
List<CardCosmeticReward> cardCosmeticRewards = ReadCsv<CardCosmeticReward, CardCosmeticRewardMap>("card_cosmetic_rewards.csv");
|
||||
|
||||
builder.Entity<ClassEntry>().HasData(classes);
|
||||
builder.Entity<LeaderSkinEntry>().HasData(leaderSkins);
|
||||
builder.Entity<EmblemEntry>().HasData(emblems);
|
||||
builder.Entity<SleeveEntry>().HasData(sleeves);
|
||||
builder.Entity<DegreeEntry>().HasData(degrees);
|
||||
builder.Entity<BattlefieldEntry>().HasData(battlefields);
|
||||
builder.Entity<MyPageBackgroundEntry>().HasData(myPageBackgrounds);
|
||||
builder.Entity<RankInfoEntry>().HasData(rankinfos);
|
||||
builder.Entity<ClassExpEntry>().HasData(classexp);
|
||||
builder.Entity<CardCosmeticReward>().HasData(cardCosmeticRewards);
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SVSim.Database.Common;
|
||||
|
||||
namespace SVSim.Database.DataSeeders;
|
||||
|
||||
public class DefaultSettingsSeeder : IDataSeeder
|
||||
{
|
||||
public void Seed(ModelBuilder builder)
|
||||
{
|
||||
// GameConfiguration is seeded at runtime (see SVSimDbContext.EnsureSeedDataAsync),
|
||||
// NOT via HasData — EF Core 8's HasData+OwnsOne(...).ToJson combo doesn't reliably
|
||||
// populate the jsonb cell, leading to NOT NULL violations on a fresh DB. Runtime
|
||||
// seeding writes a full `GameConfigRoot()` (all sub-config POCO initialisers
|
||||
// produce the canonical defaults).
|
||||
}
|
||||
}
|
||||
33857
SVSim.Database/Migrations/20260523152741_Initial.Designer.cs
generated
33857
SVSim.Database/Migrations/20260523152741_Initial.Designer.cs
generated
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,489 +0,0 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace SVSim.Database.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class ProdContentTables : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "CardSetIdForResourceDlView",
|
||||
table: "GameConfigurations",
|
||||
type: "integer",
|
||||
nullable: false,
|
||||
defaultValue: 0);
|
||||
|
||||
migrationBuilder.AddColumn<long>(
|
||||
name: "ChallengeTwoPickSleeveId",
|
||||
table: "GameConfigurations",
|
||||
type: "bigint",
|
||||
nullable: false,
|
||||
defaultValue: 0L);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "ChallengeUseTwoPickPremiumCard",
|
||||
table: "GameConfigurations",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "IsBattlePassPeriod",
|
||||
table: "GameConfigurations",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "IsBeginnerMission",
|
||||
table: "GameConfigurations",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "TsRotationId",
|
||||
table: "GameConfigurations",
|
||||
type: "text",
|
||||
nullable: false,
|
||||
defaultValue: "");
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ArenaSeasons",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "integer", nullable: false),
|
||||
Mode = table.Column<int>(type: "integer", nullable: false),
|
||||
Enable = table.Column<int>(type: "integer", nullable: false),
|
||||
Cost = table.Column<decimal>(type: "numeric(20,0)", nullable: false),
|
||||
RupyCost = table.Column<decimal>(type: "numeric(20,0)", nullable: false),
|
||||
TicketCost = table.Column<int>(type: "integer", nullable: false),
|
||||
IsJoin = table.Column<bool>(type: "boolean", nullable: false),
|
||||
FormatInfo = table.Column<string>(type: "jsonb", nullable: false),
|
||||
DateCreated = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
DateUpdated = table.Column<DateTime>(type: "timestamp with time zone", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_ArenaSeasons", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "AvatarAbilities",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "integer", nullable: false),
|
||||
LeaderSkinId = table.Column<int>(type: "integer", nullable: false),
|
||||
BattleStartFirstPlayerTurnBp = table.Column<int>(type: "integer", nullable: false),
|
||||
BattleStartSecondPlayerTurnBp = table.Column<int>(type: "integer", nullable: false),
|
||||
BattleStartMaxLife = table.Column<int>(type: "integer", nullable: false),
|
||||
AbilityCost = table.Column<string>(type: "text", nullable: false),
|
||||
Ability = table.Column<string>(type: "text", nullable: false),
|
||||
PassiveAbility = table.Column<string>(type: "text", nullable: false),
|
||||
AbilityDesc = table.Column<string>(type: "text", nullable: false),
|
||||
PassiveAbilityDesc = table.Column<string>(type: "text", nullable: false),
|
||||
DateCreated = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
DateUpdated = table.Column<DateTime>(type: "timestamp with time zone", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_AvatarAbilities", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Banners",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "integer", nullable: false),
|
||||
ImageName = table.Column<string>(type: "text", nullable: false),
|
||||
Click = table.Column<string>(type: "text", nullable: false),
|
||||
Status = table.Column<string>(type: "text", nullable: false),
|
||||
ChangeTime = table.Column<int>(type: "integer", nullable: false),
|
||||
RemainingTime = table.Column<int>(type: "integer", nullable: false),
|
||||
ImagePaths = table.Column<string>(type: "jsonb", nullable: false),
|
||||
DateCreated = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
DateUpdated = table.Column<DateTime>(type: "timestamp with time zone", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Banners", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "BattlePassLevels",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "integer", nullable: false),
|
||||
Level = table.Column<int>(type: "integer", nullable: false),
|
||||
RewardData = table.Column<string>(type: "jsonb", nullable: false),
|
||||
DateCreated = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
DateUpdated = table.Column<DateTime>(type: "timestamp with time zone", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_BattlePassLevels", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Colosseums",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "integer", nullable: false),
|
||||
ColosseumId = table.Column<string>(type: "text", nullable: false),
|
||||
ColosseumName = table.Column<string>(type: "text", nullable: false),
|
||||
CardPoolName = table.Column<string>(type: "text", nullable: false),
|
||||
DeckFormat = table.Column<string>(type: "text", nullable: false),
|
||||
StartTime = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
EndTime = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
NowRound = table.Column<string>(type: "text", nullable: false),
|
||||
IsDisplayTips = table.Column<string>(type: "text", nullable: false),
|
||||
TipsId = table.Column<string>(type: "text", nullable: false),
|
||||
IsColosseumPeriod = table.Column<bool>(type: "boolean", nullable: false),
|
||||
IsRoundPeriod = table.Column<bool>(type: "boolean", nullable: false),
|
||||
IsNormalTwoPick = table.Column<string>(type: "text", nullable: false),
|
||||
IsSpecialMode = table.Column<string>(type: "text", nullable: false),
|
||||
IsAllCardEnabled = table.Column<int>(type: "integer", nullable: false),
|
||||
SalesPeriodInfo = table.Column<string>(type: "jsonb", nullable: false),
|
||||
DateCreated = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
DateUpdated = table.Column<DateTime>(type: "timestamp with time zone", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Colosseums", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "DailyLoginBonuses",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "integer", nullable: false),
|
||||
BonusId = table.Column<int>(type: "integer", nullable: false),
|
||||
BonusData = table.Column<string>(type: "jsonb", nullable: false),
|
||||
DateCreated = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
DateUpdated = table.Column<DateTime>(type: "timestamp with time zone", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_DailyLoginBonuses", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "DefaultDecks",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "integer", nullable: false),
|
||||
DeckNo = table.Column<int>(type: "integer", nullable: false),
|
||||
ClassId = table.Column<int>(type: "integer", nullable: false),
|
||||
SleeveId = table.Column<long>(type: "bigint", nullable: false),
|
||||
LeaderSkinId = table.Column<int>(type: "integer", nullable: false),
|
||||
DeckName = table.Column<string>(type: "text", nullable: false),
|
||||
CardIdArray = table.Column<string>(type: "jsonb", nullable: false),
|
||||
DateCreated = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
DateUpdated = table.Column<DateTime>(type: "timestamp with time zone", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_DefaultDecks", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "DefaultLeaderSkinSettings",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "integer", nullable: false),
|
||||
ClassId = table.Column<int>(type: "integer", nullable: false),
|
||||
IsRandomLeaderSkin = table.Column<int>(type: "integer", nullable: false),
|
||||
LeaderSkinId = table.Column<int>(type: "integer", nullable: false),
|
||||
DateCreated = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
DateUpdated = table.Column<DateTime>(type: "timestamp with time zone", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_DefaultLeaderSkinSettings", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "FeatureMaintenances",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "integer", nullable: false),
|
||||
FeatureKey = table.Column<string>(type: "text", nullable: false),
|
||||
Data = table.Column<string>(type: "jsonb", nullable: false),
|
||||
DateCreated = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
DateUpdated = table.Column<DateTime>(type: "timestamp with time zone", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_FeatureMaintenances", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "LoadingExclusionCards",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<long>(type: "bigint", nullable: false),
|
||||
CardId = table.Column<long>(type: "bigint", nullable: false),
|
||||
DateCreated = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
DateUpdated = table.Column<DateTime>(type: "timestamp with time zone", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_LoadingExclusionCards", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "MaintenanceCards",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<long>(type: "bigint", nullable: false),
|
||||
CardId = table.Column<long>(type: "bigint", nullable: false),
|
||||
DateCreated = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
DateUpdated = table.Column<DateTime>(type: "timestamp with time zone", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_MaintenanceCards", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "MasterPointRankingPeriods",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "integer", nullable: false),
|
||||
PeriodNum = table.Column<int>(type: "integer", nullable: false),
|
||||
NecessaryScore = table.Column<long>(type: "bigint", nullable: false),
|
||||
BeginTime = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
EndTime = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
DateCreated = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
DateUpdated = table.Column<DateTime>(type: "timestamp with time zone", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_MasterPointRankingPeriods", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "MyRotationAbilities",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "integer", nullable: false),
|
||||
AbilityId = table.Column<int>(type: "integer", nullable: false),
|
||||
Data = table.Column<string>(type: "jsonb", nullable: false),
|
||||
DateCreated = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
DateUpdated = table.Column<DateTime>(type: "timestamp with time zone", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_MyRotationAbilities", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "MyRotationSettings",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "integer", nullable: false),
|
||||
RotationId = table.Column<int>(type: "integer", nullable: false),
|
||||
CardSetIdsCsv = table.Column<string>(type: "text", nullable: false),
|
||||
AbilitiesCsv = table.Column<string>(type: "text", nullable: false),
|
||||
ReprintedCardIds = table.Column<string>(type: "jsonb", nullable: false),
|
||||
RestrictedCardIds = table.Column<string>(type: "jsonb", nullable: false),
|
||||
DateCreated = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
DateUpdated = table.Column<DateTime>(type: "timestamp with time zone", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_MyRotationSettings", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "PreReleaseInfos",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "integer", nullable: false),
|
||||
PreReleaseId = table.Column<string>(type: "text", nullable: false),
|
||||
NextCardSetId = table.Column<string>(type: "text", nullable: false),
|
||||
StartTime = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
EndTime = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
DisplayEndTime = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
FreeMatchStartTime = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
CardMasterId = table.Column<int>(type: "integer", nullable: false),
|
||||
DefaultCardMasterId = table.Column<string>(type: "text", nullable: false),
|
||||
PreReleaseCardMasterId = table.Column<string>(type: "text", nullable: false),
|
||||
IsPreRotationFreeMatchTerm = table.Column<bool>(type: "boolean", nullable: false),
|
||||
RotationCardSetIdList = table.Column<string>(type: "jsonb", nullable: false),
|
||||
ReprintedBaseCardIds = table.Column<string>(type: "jsonb", nullable: false),
|
||||
LatestReprintedBaseCardIds = table.Column<string>(type: "jsonb", nullable: false),
|
||||
DateCreated = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
DateUpdated = table.Column<DateTime>(type: "timestamp with time zone", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_PreReleaseInfos", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ReprintedCards",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<long>(type: "bigint", nullable: false),
|
||||
CardId = table.Column<long>(type: "bigint", nullable: false),
|
||||
DateCreated = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
DateUpdated = table.Column<DateTime>(type: "timestamp with time zone", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_ReprintedCards", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "SealedSeasons",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "integer", nullable: false),
|
||||
Enable = table.Column<int>(type: "integer", nullable: false),
|
||||
CrystalCost = table.Column<int>(type: "integer", nullable: false),
|
||||
RupyCost = table.Column<int>(type: "integer", nullable: false),
|
||||
TicketCost = table.Column<int>(type: "integer", nullable: false),
|
||||
DeckUsingNumMin = table.Column<int>(type: "integer", nullable: false),
|
||||
ScheduleId = table.Column<int>(type: "integer", nullable: false),
|
||||
IsJoin = table.Column<bool>(type: "boolean", nullable: false),
|
||||
IsDeckCodeMaintenance = table.Column<bool>(type: "boolean", nullable: false),
|
||||
PackInfo = table.Column<string>(type: "jsonb", nullable: false),
|
||||
SalesPeriodInfo = table.Column<string>(type: "jsonb", nullable: false),
|
||||
DateCreated = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
DateUpdated = table.Column<DateTime>(type: "timestamp with time zone", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_SealedSeasons", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "SpotCards",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<long>(type: "bigint", nullable: false),
|
||||
CardId = table.Column<long>(type: "bigint", nullable: false),
|
||||
Cost = table.Column<int>(type: "integer", nullable: false),
|
||||
DateCreated = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
DateUpdated = table.Column<DateTime>(type: "timestamp with time zone", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_SpotCards", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "UnlimitedRestrictions",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<long>(type: "bigint", nullable: false),
|
||||
CardId = table.Column<long>(type: "bigint", nullable: false),
|
||||
RestrictionValue = table.Column<int>(type: "integer", nullable: false),
|
||||
DateCreated = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
DateUpdated = table.Column<DateTime>(type: "timestamp with time zone", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_UnlimitedRestrictions", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "GameConfigurations",
|
||||
keyColumn: "Id",
|
||||
keyValue: "default",
|
||||
columns: new[] { "CardSetIdForResourceDlView", "ChallengeTwoPickSleeveId", "ChallengeUseTwoPickPremiumCard", "IsBattlePassPeriod", "IsBeginnerMission", "TsRotationId" },
|
||||
values: new object[] { 0, 0L, false, false, false, "" });
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "ArenaSeasons");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "AvatarAbilities");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Banners");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "BattlePassLevels");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Colosseums");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "DailyLoginBonuses");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "DefaultDecks");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "DefaultLeaderSkinSettings");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "FeatureMaintenances");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "LoadingExclusionCards");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "MaintenanceCards");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "MasterPointRankingPeriods");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "MyRotationAbilities");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "MyRotationSettings");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "PreReleaseInfos");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "ReprintedCards");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "SealedSeasons");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "SpotCards");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "UnlimitedRestrictions");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "CardSetIdForResourceDlView",
|
||||
table: "GameConfigurations");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ChallengeTwoPickSleeveId",
|
||||
table: "GameConfigurations");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ChallengeUseTwoPickPremiumCard",
|
||||
table: "GameConfigurations");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "IsBattlePassPeriod",
|
||||
table: "GameConfigurations");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "IsBeginnerMission",
|
||||
table: "GameConfigurations");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "TsRotationId",
|
||||
table: "GameConfigurations");
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,37 +0,0 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace SVSim.Database.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class MypageRoomTypeInSession : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "SpecialDeckFormats",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "integer", nullable: false),
|
||||
DeckFormat = table.Column<string>(type: "text", nullable: false),
|
||||
EndTime = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
DateCreated = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
DateUpdated = table.Column<DateTime>(type: "timestamp with time zone", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_SpecialDeckFormats", x => x.Id);
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "SpecialDeckFormats");
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,50 +0,0 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace SVSim.Database.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class MypagePaymentItems : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "PaymentItems",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "integer", nullable: false),
|
||||
ProductId = table.Column<int>(type: "integer", nullable: false),
|
||||
StoreProductId = table.Column<long>(type: "bigint", nullable: false),
|
||||
Name = table.Column<string>(type: "text", nullable: false),
|
||||
Text = table.Column<string>(type: "text", nullable: false),
|
||||
Price = table.Column<decimal>(type: "numeric", nullable: false),
|
||||
ChargeCrystalNum = table.Column<int>(type: "integer", nullable: false),
|
||||
FreeCrystalNum = table.Column<int>(type: "integer", nullable: false),
|
||||
PurchaseLimit = table.Column<int>(type: "integer", nullable: false),
|
||||
SpecialShopFlag = table.Column<int>(type: "integer", nullable: false),
|
||||
ImageName = table.Column<string>(type: "text", nullable: false),
|
||||
StartTime = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
EndTime = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
RemainingTime = table.Column<int>(type: "integer", nullable: false),
|
||||
IsResaleProduct = table.Column<int>(type: "integer", nullable: false),
|
||||
ResaleStartDate = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||
DateCreated = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
DateUpdated = table.Column<DateTime>(type: "timestamp with time zone", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_PaymentItems", x => x.Id);
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "PaymentItems");
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,46 +0,0 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace SVSim.Database.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class PracticeOpponents : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "PracticeOpponents",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "integer", nullable: false),
|
||||
PracticeId = table.Column<int>(type: "integer", nullable: false),
|
||||
TextId = table.Column<string>(type: "text", nullable: false),
|
||||
ClassId = table.Column<int>(type: "integer", nullable: false),
|
||||
CharaId = table.Column<int>(type: "integer", nullable: false),
|
||||
DegreeId = table.Column<int>(type: "integer", nullable: false),
|
||||
AiDeckLevel = table.Column<int>(type: "integer", nullable: false),
|
||||
AiLogicLevel = table.Column<int>(type: "integer", nullable: false),
|
||||
AiMaxLife = table.Column<int>(type: "integer", nullable: false),
|
||||
Battle3dFieldId = table.Column<string>(type: "text", nullable: false),
|
||||
IsMaintenance = table.Column<bool>(type: "boolean", nullable: false),
|
||||
IsCampaignPractice = table.Column<bool>(type: "boolean", nullable: false),
|
||||
DateCreated = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
DateUpdated = table.Column<DateTime>(type: "timestamp with time zone", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_PracticeOpponents", x => x.Id);
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "PracticeOpponents");
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,135 +0,0 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace SVSim.Database.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddPackCatalog : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Packs",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "integer", nullable: false),
|
||||
BasePackId = table.Column<int>(type: "integer", nullable: false),
|
||||
GachaType = table.Column<int>(type: "integer", nullable: false),
|
||||
PackCategory = table.Column<int>(type: "integer", nullable: false),
|
||||
PosterType = table.Column<int>(type: "integer", nullable: false),
|
||||
CommenceDate = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
CompleteDate = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
SalesPeriodTime = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||
SleeveId = table.Column<int>(type: "integer", nullable: false),
|
||||
SpecialSleeveId = table.Column<int>(type: "integer", nullable: false),
|
||||
OverrideDrawEffectPackId = table.Column<int>(type: "integer", nullable: false),
|
||||
OverrideUiEffectPackId = table.Column<int>(type: "integer", nullable: false),
|
||||
GachaDetail = table.Column<string>(type: "text", nullable: false),
|
||||
IsHide = table.Column<bool>(type: "boolean", nullable: false),
|
||||
IsNew = table.Column<bool>(type: "boolean", nullable: false),
|
||||
IsPreRelease = table.Column<bool>(type: "boolean", nullable: false),
|
||||
OpenCountLimit = table.Column<int>(type: "integer", nullable: false),
|
||||
GachaPointConfig_ExchangeablePoint = table.Column<int>(type: "integer", nullable: true),
|
||||
GachaPointConfig_IncreaseGachaPoint = table.Column<int>(type: "integer", nullable: true),
|
||||
DateCreated = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
DateUpdated = table.Column<DateTime>(type: "timestamp with time zone", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Packs", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ViewerPackOpenCount",
|
||||
columns: table => new
|
||||
{
|
||||
ViewerId = table.Column<long>(type: "bigint", nullable: false),
|
||||
Id = table.Column<int>(type: "integer", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
PackId = table.Column<int>(type: "integer", nullable: false),
|
||||
OpenCount = table.Column<int>(type: "integer", nullable: false),
|
||||
LastDailyFreeAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_ViewerPackOpenCount", x => new { x.ViewerId, x.Id });
|
||||
table.ForeignKey(
|
||||
name: "FK_ViewerPackOpenCount_Viewers_ViewerId",
|
||||
column: x => x.ViewerId,
|
||||
principalTable: "Viewers",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "PackBannerEntry",
|
||||
columns: table => new
|
||||
{
|
||||
PackConfigEntryId = table.Column<int>(type: "integer", nullable: false),
|
||||
Id = table.Column<int>(type: "integer", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
BannerName = table.Column<string>(type: "text", nullable: false),
|
||||
DialogTitle = table.Column<string>(type: "text", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_PackBannerEntry", x => new { x.PackConfigEntryId, x.Id });
|
||||
table.ForeignKey(
|
||||
name: "FK_PackBannerEntry_Packs_PackConfigEntryId",
|
||||
column: x => x.PackConfigEntryId,
|
||||
principalTable: "Packs",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "PackChildGachaEntry",
|
||||
columns: table => new
|
||||
{
|
||||
PackConfigEntryId = table.Column<int>(type: "integer", nullable: false),
|
||||
Id = table.Column<int>(type: "integer", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
GachaId = table.Column<int>(type: "integer", nullable: false),
|
||||
TypeDetail = table.Column<int>(type: "integer", nullable: false),
|
||||
Cost = table.Column<int>(type: "integer", nullable: false),
|
||||
CardCount = table.Column<int>(type: "integer", nullable: false),
|
||||
ItemId = table.Column<long>(type: "bigint", nullable: true),
|
||||
IsDailySingle = table.Column<bool>(type: "boolean", nullable: false),
|
||||
OverrideIncreaseGachaPoint = table.Column<int>(type: "integer", nullable: false),
|
||||
PurchaseLimitCount = table.Column<int>(type: "integer", nullable: false),
|
||||
FreeGachaCampaignId = table.Column<int>(type: "integer", nullable: true),
|
||||
CampaignName = table.Column<string>(type: "text", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_PackChildGachaEntry", x => new { x.PackConfigEntryId, x.Id });
|
||||
table.ForeignKey(
|
||||
name: "FK_PackChildGachaEntry_Packs_PackConfigEntryId",
|
||||
column: x => x.PackConfigEntryId,
|
||||
principalTable: "Packs",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "PackBannerEntry");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "PackChildGachaEntry");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "ViewerPackOpenCount");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Packs");
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,338 +0,0 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace SVSim.Database.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class RefactorGameConfigurationToJsonb : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
// Step 1: Add the new jsonb column FIRST so the backfill SQL can read the old columns.
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "Config",
|
||||
table: "GameConfigurations",
|
||||
type: "jsonb",
|
||||
nullable: false,
|
||||
defaultValue: "");
|
||||
|
||||
// Step 2: Backfill — project old flat columns into the new jsonb document.
|
||||
// For fresh-install databases (no pre-existing rows) this is a no-op; the
|
||||
// DefaultSettingsSeeder InsertData in the previous migration already seeded the
|
||||
// parent row, so the UPDATE below covers it. PackRates defaults are hard-coded
|
||||
// here because the old schema had no equivalent columns.
|
||||
// Use provider-specific SQL: PostgreSQL jsonb functions vs SQLite json functions.
|
||||
migrationBuilder.Sql(
|
||||
"""
|
||||
UPDATE "GameConfigurations"
|
||||
SET "Config" = jsonb_build_object(
|
||||
'DefaultGrants', jsonb_build_object(
|
||||
'Crystals', "DefaultCrystals",
|
||||
'Rupees', "DefaultRupees",
|
||||
'Ether', "DefaultEther"
|
||||
),
|
||||
'Player', jsonb_build_object(
|
||||
'MaxFriends', "MaxFriends"
|
||||
),
|
||||
'DefaultLoadout', jsonb_build_object(
|
||||
'DegreeId', "DefaultDegreeId",
|
||||
'EmblemId', "DefaultEmblemId",
|
||||
'MyPageBackgroundId', "DefaultMyPageBackgroundId",
|
||||
'SleeveId', "DefaultSleeveId"
|
||||
),
|
||||
'Challenge', jsonb_build_object(
|
||||
'UseTwoPickPremiumCard', "ChallengeUseTwoPickPremiumCard",
|
||||
'TwoPickSleeveId', "ChallengeTwoPickSleeveId"
|
||||
),
|
||||
'Rotation', jsonb_build_object(
|
||||
'TsRotationId', "TsRotationId",
|
||||
'IsBattlePassPeriod', "IsBattlePassPeriod",
|
||||
'IsBeginnerMission', "IsBeginnerMission",
|
||||
'CardSetIdForResourceDlView', "CardSetIdForResourceDlView"
|
||||
),
|
||||
'PackRates', jsonb_build_object(
|
||||
'AnimatedRate', 0.08,
|
||||
'Default', jsonb_build_object('Bronze', 0.6744, 'Silver', 0.25, 'Gold', 0.06, 'Legendary', 0.015),
|
||||
'PerSlot', jsonb_build_array(
|
||||
jsonb_build_object('Slot', '8', 'Bronze', 0, 'Silver', 0.7692, 'Gold', 0.1846, 'Legendary', 0.0462)
|
||||
)
|
||||
)
|
||||
)
|
||||
WHERE "Id" = 'default';
|
||||
""",
|
||||
suppressTransaction: true);
|
||||
|
||||
// For SQLite (tests): use json() and json_object() functions.
|
||||
migrationBuilder.Sql(
|
||||
"""
|
||||
UPDATE "GameConfigurations"
|
||||
SET "Config" = json('{"DefaultGrants":{"Crystals":'||COALESCE("DefaultCrystals", 0)||',"Rupees":'||COALESCE("DefaultRupees", 0)||',"Ether":'||COALESCE("DefaultEther", 0)||'},'||
|
||||
'"Player":{"MaxFriends":'||COALESCE("MaxFriends", 0)||'},'||
|
||||
'"DefaultLoadout":{"DegreeId":'||COALESCE("DefaultDegreeId", 0)||',"EmblemId":'||COALESCE("DefaultEmblemId", 0)||',"MyPageBackgroundId":'||COALESCE("DefaultMyPageBackgroundId", 0)||',"SleeveId":'||COALESCE("DefaultSleeveId", 0)||'},'||
|
||||
'"Challenge":{"UseTwoPickPremiumCard":'||(CASE WHEN "ChallengeUseTwoPickPremiumCard" THEN 'true' ELSE 'false' END)||',"TwoPickSleeveId":'||COALESCE("ChallengeTwoPickSleeveId", 0)||'},'||
|
||||
'"Rotation":{"TsRotationId":"'||COALESCE("TsRotationId", '')||'","IsBattlePassPeriod":'||(CASE WHEN "IsBattlePassPeriod" THEN 'true' ELSE 'false' END)||',"IsBeginnerMission":'||(CASE WHEN "IsBeginnerMission" THEN 'true' ELSE 'false' END)||',"CardSetIdForResourceDlView":'||COALESCE("CardSetIdForResourceDlView", 0)||'},'||
|
||||
'"PackRates":{"AnimatedRate":0.08,"Default":{"Bronze":0.6744,"Silver":0.25,"Gold":0.06,"Legendary":0.015},"PerSlot":[{"Slot":"8","Bronze":0,"Silver":0.7692,"Gold":0.1846,"Legendary":0.0462}]}'||
|
||||
'}')
|
||||
WHERE "Id" = 'default';
|
||||
""",
|
||||
suppressTransaction: true);
|
||||
|
||||
|
||||
// Step 3: Drop FK constraints, indexes, and old flat columns.
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_GameConfigurations_Degrees_DefaultDegreeId",
|
||||
table: "GameConfigurations");
|
||||
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_GameConfigurations_Emblems_DefaultEmblemId",
|
||||
table: "GameConfigurations");
|
||||
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_GameConfigurations_MyPageBackgrounds_DefaultMyPageBackgroun~",
|
||||
table: "GameConfigurations");
|
||||
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_GameConfigurations_Sleeves_DefaultSleeveId",
|
||||
table: "GameConfigurations");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_GameConfigurations_DefaultDegreeId",
|
||||
table: "GameConfigurations");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_GameConfigurations_DefaultEmblemId",
|
||||
table: "GameConfigurations");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_GameConfigurations_DefaultMyPageBackgroundId",
|
||||
table: "GameConfigurations");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_GameConfigurations_DefaultSleeveId",
|
||||
table: "GameConfigurations");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "CardSetIdForResourceDlView",
|
||||
table: "GameConfigurations");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ChallengeTwoPickSleeveId",
|
||||
table: "GameConfigurations");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ChallengeUseTwoPickPremiumCard",
|
||||
table: "GameConfigurations");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "DefaultCrystals",
|
||||
table: "GameConfigurations");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "DefaultDegreeId",
|
||||
table: "GameConfigurations");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "DefaultEmblemId",
|
||||
table: "GameConfigurations");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "DefaultEther",
|
||||
table: "GameConfigurations");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "DefaultMyPageBackgroundId",
|
||||
table: "GameConfigurations");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "DefaultRupees",
|
||||
table: "GameConfigurations");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "DefaultSleeveId",
|
||||
table: "GameConfigurations");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "IsBattlePassPeriod",
|
||||
table: "GameConfigurations");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "IsBeginnerMission",
|
||||
table: "GameConfigurations");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "MaxFriends",
|
||||
table: "GameConfigurations");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "TsRotationId",
|
||||
table: "GameConfigurations");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Config",
|
||||
table: "GameConfigurations");
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "CardSetIdForResourceDlView",
|
||||
table: "GameConfigurations",
|
||||
type: "integer",
|
||||
nullable: false,
|
||||
defaultValue: 0);
|
||||
|
||||
migrationBuilder.AddColumn<long>(
|
||||
name: "ChallengeTwoPickSleeveId",
|
||||
table: "GameConfigurations",
|
||||
type: "bigint",
|
||||
nullable: false,
|
||||
defaultValue: 0L);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "ChallengeUseTwoPickPremiumCard",
|
||||
table: "GameConfigurations",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<decimal>(
|
||||
name: "DefaultCrystals",
|
||||
table: "GameConfigurations",
|
||||
type: "numeric(20,0)",
|
||||
nullable: false,
|
||||
defaultValue: 0m);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "DefaultDegreeId",
|
||||
table: "GameConfigurations",
|
||||
type: "integer",
|
||||
nullable: false,
|
||||
defaultValue: 0);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "DefaultEmblemId",
|
||||
table: "GameConfigurations",
|
||||
type: "integer",
|
||||
nullable: false,
|
||||
defaultValue: 0);
|
||||
|
||||
migrationBuilder.AddColumn<decimal>(
|
||||
name: "DefaultEther",
|
||||
table: "GameConfigurations",
|
||||
type: "numeric(20,0)",
|
||||
nullable: false,
|
||||
defaultValue: 0m);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "DefaultMyPageBackgroundId",
|
||||
table: "GameConfigurations",
|
||||
type: "integer",
|
||||
nullable: false,
|
||||
defaultValue: 0);
|
||||
|
||||
migrationBuilder.AddColumn<decimal>(
|
||||
name: "DefaultRupees",
|
||||
table: "GameConfigurations",
|
||||
type: "numeric(20,0)",
|
||||
nullable: false,
|
||||
defaultValue: 0m);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "DefaultSleeveId",
|
||||
table: "GameConfigurations",
|
||||
type: "integer",
|
||||
nullable: false,
|
||||
defaultValue: 0);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "IsBattlePassPeriod",
|
||||
table: "GameConfigurations",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "IsBeginnerMission",
|
||||
table: "GameConfigurations",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "MaxFriends",
|
||||
table: "GameConfigurations",
|
||||
type: "integer",
|
||||
nullable: false,
|
||||
defaultValue: 0);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "TsRotationId",
|
||||
table: "GameConfigurations",
|
||||
type: "text",
|
||||
nullable: false,
|
||||
defaultValue: "");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "GameConfigurations",
|
||||
keyColumn: "Id",
|
||||
keyValue: "default",
|
||||
columns: new[] { "CardSetIdForResourceDlView", "ChallengeTwoPickSleeveId", "ChallengeUseTwoPickPremiumCard", "DefaultCrystals", "DefaultDegreeId", "DefaultEmblemId", "DefaultEther", "DefaultMyPageBackgroundId", "DefaultRupees", "DefaultSleeveId", "IsBattlePassPeriod", "IsBeginnerMission", "MaxFriends", "TsRotationId" },
|
||||
values: new object[] { 0, 0L, false, 50000m, 300003, 100000000, 50000m, 100000000, 50000m, 3000011, false, false, 20, "" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_GameConfigurations_DefaultDegreeId",
|
||||
table: "GameConfigurations",
|
||||
column: "DefaultDegreeId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_GameConfigurations_DefaultEmblemId",
|
||||
table: "GameConfigurations",
|
||||
column: "DefaultEmblemId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_GameConfigurations_DefaultMyPageBackgroundId",
|
||||
table: "GameConfigurations",
|
||||
column: "DefaultMyPageBackgroundId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_GameConfigurations_DefaultSleeveId",
|
||||
table: "GameConfigurations",
|
||||
column: "DefaultSleeveId");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_GameConfigurations_Degrees_DefaultDegreeId",
|
||||
table: "GameConfigurations",
|
||||
column: "DefaultDegreeId",
|
||||
principalTable: "Degrees",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_GameConfigurations_Emblems_DefaultEmblemId",
|
||||
table: "GameConfigurations",
|
||||
column: "DefaultEmblemId",
|
||||
principalTable: "Emblems",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_GameConfigurations_MyPageBackgrounds_DefaultMyPageBackgroun~",
|
||||
table: "GameConfigurations",
|
||||
column: "DefaultMyPageBackgroundId",
|
||||
principalTable: "MyPageBackgrounds",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_GameConfigurations_Sleeves_DefaultSleeveId",
|
||||
table: "GameConfigurations",
|
||||
column: "DefaultSleeveId",
|
||||
principalTable: "Sleeves",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,29 +0,0 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace SVSim.Database.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddIsFoilToCards : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "IsFoil",
|
||||
table: "Cards",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "IsFoil",
|
||||
table: "Cards");
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
2011
SVSim.Database/Migrations/20260524224501_Initial.Designer.cs
generated
Normal file
2011
SVSim.Database/Migrations/20260524224501_Initial.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
1391
SVSim.Database/Migrations/20260524224501_Initial.cs
Normal file
1391
SVSim.Database/Migrations/20260524224501_Initial.cs
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,10 +1,10 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace SVSim.Database.Models.Config;
|
||||
|
||||
[Owned]
|
||||
[ConfigSection("Challenge")]
|
||||
public class ChallengeConfig
|
||||
{
|
||||
public bool UseTwoPickPremiumCard { get; set; }
|
||||
public long TwoPickSleeveId { get; set; }
|
||||
|
||||
public static ChallengeConfig ShippedDefaults() => new();
|
||||
}
|
||||
|
||||
14
SVSim.Database/Models/Config/ConfigSectionAttribute.cs
Normal file
14
SVSim.Database/Models/Config/ConfigSectionAttribute.cs
Normal file
@@ -0,0 +1,14 @@
|
||||
namespace SVSim.Database.Models.Config;
|
||||
|
||||
/// <summary>
|
||||
/// Marks a POCO as a top-level GameConfig section. The <see cref="Name"/> is the storage key —
|
||||
/// it's the primary key in the <c>GameConfigs</c> table and the appsettings.json section name
|
||||
/// under <c>"GameConfig"</c>. Renaming a class is safe; renaming the section name here is a
|
||||
/// breaking change to stored data and config files.
|
||||
/// </summary>
|
||||
[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)]
|
||||
public sealed class ConfigSectionAttribute : Attribute
|
||||
{
|
||||
public string Name { get; }
|
||||
public ConfigSectionAttribute(string name) => Name = name;
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace SVSim.Database.Models.Config;
|
||||
|
||||
/// <summary>Per-viewer-registration default currency grants.</summary>
|
||||
[Owned]
|
||||
[ConfigSection("DefaultGrants")]
|
||||
public class DefaultGrantsConfig
|
||||
{
|
||||
public ulong Crystals { get; set; } = 50000;
|
||||
public ulong Rupees { get; set; } = 50000;
|
||||
public ulong Ether { get; set; } = 50000;
|
||||
|
||||
public static DefaultGrantsConfig ShippedDefaults() => new();
|
||||
}
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace SVSim.Database.Models.Config;
|
||||
|
||||
/// <summary>
|
||||
/// Default cosmetic loadout ids for a newly-registered viewer. These used to be FK columns;
|
||||
/// they're now untyped longs in the jsonb tree. Validation would live in a future config-editing
|
||||
/// UI (see project-wide TODO(config-validation)).
|
||||
/// Default cosmetic loadout ids for a newly-registered viewer. Untyped longs in the jsonb tree
|
||||
/// (FK validation would live in a future config-editing UI — see TODO(config-validation)).
|
||||
/// </summary>
|
||||
[Owned]
|
||||
[ConfigSection("DefaultLoadout")]
|
||||
public class DefaultLoadoutConfig
|
||||
{
|
||||
public int DegreeId { get; set; } = 300003;
|
||||
public int EmblemId { get; set; } = 100000000;
|
||||
public int MyPageBackgroundId { get; set; } = 100000000;
|
||||
public int SleeveId { get; set; } = 3000011;
|
||||
|
||||
public static DefaultLoadoutConfig ShippedDefaults() => new();
|
||||
}
|
||||
|
||||
36
SVSim.Database/Models/Config/MyRotationScheduleConfig.cs
Normal file
36
SVSim.Database/Models/Config/MyRotationScheduleConfig.cs
Normal file
@@ -0,0 +1,36 @@
|
||||
namespace SVSim.Database.Models.Config;
|
||||
|
||||
/// <summary>
|
||||
/// Window-based schedule for the Custom Rotation (a.k.a. MyRotation) feature. Two parallel windows:
|
||||
/// <c>Gathering</c> (deck-building period) and <c>FreeBattle</c> (active play period). The client
|
||||
/// gates the format-selector button on these windows — see Wizard/MyRotationAllInfo.cs:45
|
||||
/// (<c>IsMyRotationEnable => IsWithinPeriod(FreeMatchPeriod)</c>) and Wizard/DeckListUI.cs:92.
|
||||
/// Mapped to the wire-shape <c>SpecialRotationSchedule</c> at the controller seam.
|
||||
/// <para>
|
||||
/// Shipped defaults reproduce the 2026-05-23 prod capture so a fresh install ships with the
|
||||
/// feature enabled. GlobalsImporter overwrites the DB section from any newer capture.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[ConfigSection("MyRotationSchedule")]
|
||||
public class MyRotationScheduleConfig
|
||||
{
|
||||
public ScheduleWindow Gathering { get; set; } = new()
|
||||
{
|
||||
Begin = new DateTime(2024, 5, 1, 20, 0, 0, DateTimeKind.Utc),
|
||||
End = new DateTime(2030, 6, 26, 19, 59, 59, DateTimeKind.Utc),
|
||||
};
|
||||
|
||||
public ScheduleWindow FreeBattle { get; set; } = new()
|
||||
{
|
||||
Begin = new DateTime(2024, 5, 1, 20, 0, 0, DateTimeKind.Utc),
|
||||
End = new DateTime(2030, 6, 26, 19, 59, 59, DateTimeKind.Utc),
|
||||
};
|
||||
|
||||
public static MyRotationScheduleConfig ShippedDefaults() => new();
|
||||
}
|
||||
|
||||
public class ScheduleWindow
|
||||
{
|
||||
public DateTime Begin { get; set; }
|
||||
public DateTime End { get; set; }
|
||||
}
|
||||
@@ -1,12 +1,11 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace SVSim.Database.Models.Config;
|
||||
|
||||
/// <summary>
|
||||
/// Tunables for pack-opening RNG. Defaults reproduce the original Shadowverse Classic rates
|
||||
/// exactly so the cutover from hardcoded magic numbers is zero-behavior-change.
|
||||
/// Tunables for pack-opening RNG. Property initialisers reproduce the original Shadowverse
|
||||
/// Classic main-slot rates exactly. Collection-shaped defaults (slot-8 PerSlot entry) live in
|
||||
/// <see cref="ShippedDefaults"/>, not in the initialiser — see PerSlot docstring.
|
||||
/// </summary>
|
||||
[Owned]
|
||||
[ConfigSection("PackRates")]
|
||||
public class PackRateConfig
|
||||
{
|
||||
/// <summary>
|
||||
@@ -27,21 +26,32 @@ public class PackRateConfig
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Per-slot overrides (1-based slot index) applied to all packs. A missing slot falls back
|
||||
/// to <see cref="Default"/>. Each entry is a FULL OVERRIDE, not a delta — if you change
|
||||
/// <see cref="Default"/>, existing PerSlot entries do NOT auto-recompute. The slot-8 default
|
||||
/// expresses the SV Classic "Silver-or-better guarantee" as data (Bronze=0) instead of a
|
||||
/// separate code path.
|
||||
/// Per-slot overrides keyed by 1-based slot index (stored as a list for json compatibility —
|
||||
/// Dictionary<string,T> of complex owned types is not supported). Look up by
|
||||
/// <see cref="SlotRarityWeights.Slot"/>. A missing slot falls back to <see cref="Default"/>.
|
||||
/// Each entry is a FULL OVERRIDE, not a delta — if you change <see cref="Default"/>, existing
|
||||
/// PerSlot entries do NOT auto-recompute.
|
||||
/// <para>
|
||||
/// MUST default to empty. The original EF Core 8 <c>OwnsMany</c>+<c>ToJson</c> path APPENDED
|
||||
/// jsonb rows onto whatever collection the parent's parameterless ctor produced — a non-empty
|
||||
/// initialiser here meant every config load doubled-up and the original seed silently won the
|
||||
/// <c>FirstOrDefault</c> lookup in <c>PackOpenService.ResolveWeights</c>. The EF path is gone
|
||||
/// now (config goes through <c>IGameConfigService</c> + STJ), but the rule stays: collection
|
||||
/// defaults live in <see cref="ShippedDefaults"/>, not in property initialisers.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public List<SlotRarityWeights> PerSlot { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Per-slot overrides keyed by 1-based slot index (stored as a list for EF Core 8 json
|
||||
/// compatibility — Dictionary<string,T> of complex owned types is not supported).
|
||||
/// Look up by <see cref="SlotRarityWeights.Slot"/>. A missing slot falls back to
|
||||
/// <see cref="Default"/>. Slot-8 entry expresses the SV Classic "Silver-or-better
|
||||
/// guarantee" as data (Bronze=0).
|
||||
/// Canonical SV Classic shipped defaults — what an operator gets if neither the DB nor
|
||||
/// appsettings.json supplies a PackRates section. Source of truth for the fresh-install seeder
|
||||
/// and the <c>IGameConfigService</c> inline-default tier.
|
||||
/// </summary>
|
||||
public List<SlotRarityWeights> PerSlot { get; set; } =
|
||||
[
|
||||
new() { Slot = "8", Bronze = 0, Silver = 0.7692, Gold = 0.1846, Legendary = 0.0462 },
|
||||
];
|
||||
public static PackRateConfig ShippedDefaults() => new()
|
||||
{
|
||||
PerSlot =
|
||||
{
|
||||
new SlotRarityWeights { Slot = "8", Bronze = 0, Silver = 0.7692, Gold = 0.1846, Legendary = 0.0462 },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace SVSim.Database.Models.Config;
|
||||
|
||||
[Owned]
|
||||
[ConfigSection("Player")]
|
||||
public class PlayerConfig
|
||||
{
|
||||
public int MaxFriends { get; set; } = 20;
|
||||
|
||||
public static PlayerConfig ShippedDefaults() => new();
|
||||
}
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace SVSim.Database.Models.Config;
|
||||
|
||||
/// <summary>
|
||||
/// Time-varying season/rotation state, populated by GlobalsImporter from prod captures.
|
||||
/// </summary>
|
||||
[Owned]
|
||||
[ConfigSection("Rotation")]
|
||||
public class RotationConfig
|
||||
{
|
||||
public string TsRotationId { get; set; } = "";
|
||||
public bool IsBattlePassPeriod { get; set; }
|
||||
public bool IsBeginnerMission { get; set; }
|
||||
public int CardSetIdForResourceDlView { get; set; }
|
||||
|
||||
public static RotationConfig ShippedDefaults() => new();
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace SVSim.Database.Models.Config;
|
||||
|
||||
/// <summary>
|
||||
@@ -7,11 +5,10 @@ namespace SVSim.Database.Models.Config;
|
||||
/// remainder absorbs into Bronze via the PickRarity catch-all band.
|
||||
/// <para>
|
||||
/// <see cref="Slot"/> is the 1-based slot index as a string (e.g. "8") and is used as the
|
||||
/// lookup key in <see cref="PackRateConfig.PerSlot"/>. It is empty/null for the global
|
||||
/// lookup key in <see cref="PackRateConfig.PerSlot"/>. It is null/empty for the global
|
||||
/// <see cref="PackRateConfig.Default"/> entry, which has no slot affiliation.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[Owned]
|
||||
public class SlotRarityWeights
|
||||
{
|
||||
/// <summary>1-based slot index (as a string) for entries in PerSlot. Null/empty for the Default entry.</summary>
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SVSim.Database.Models.Config;
|
||||
|
||||
namespace SVSim.Database.Models;
|
||||
|
||||
/// <summary>
|
||||
/// The root of <see cref="GameConfiguration.Config"/>, stored as a single jsonb column.
|
||||
/// Each sub-object defaults to its own initialiser, so `new GameConfigRoot()` is fully populated
|
||||
/// with the canonical SV Classic / DCGEngine defaults.
|
||||
/// </summary>
|
||||
[Owned]
|
||||
public class GameConfigRoot
|
||||
{
|
||||
public DefaultGrantsConfig DefaultGrants { get; set; } = new();
|
||||
public PlayerConfig Player { get; set; } = new();
|
||||
public DefaultLoadoutConfig DefaultLoadout { get; set; } = new();
|
||||
public ChallengeConfig Challenge { get; set; } = new();
|
||||
public RotationConfig Rotation { get; set; } = new();
|
||||
public PackRateConfig PackRates { get; set; } = new();
|
||||
}
|
||||
30
SVSim.Database/Models/GameConfigSection.cs
Normal file
30
SVSim.Database/Models/GameConfigSection.cs
Normal file
@@ -0,0 +1,30 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
using SVSim.Database.Common;
|
||||
|
||||
namespace SVSim.Database.Models;
|
||||
|
||||
/// <summary>
|
||||
/// One row per top-level game-config section. <see cref="SectionName"/> matches the
|
||||
/// <c>ConfigSectionAttribute.Name</c> on the corresponding POCO in <c>Models.Config</c>
|
||||
/// (e.g. <c>"PackRates"</c> → <c>PackRateConfig</c>). <see cref="ValueJson"/> is the section's
|
||||
/// payload, stored as <c>jsonb</c> on Postgres and <c>TEXT</c> on SQLite.
|
||||
/// <para>
|
||||
/// Deserialisation goes through pure System.Text.Json in <c>IGameConfigService</c> — EF doesn't
|
||||
/// know about the section POCOs. Replaces the old single-row <c>GameConfigurations</c> table
|
||||
/// (one wide jsonb document, EF Core 8 <c>OwnsOne</c>+<c>ToJson</c> tree). See ADR-pending /
|
||||
/// 2026-05-24 config-refactor discussion for the why.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public class GameConfigSection : ITimeTrackedEntity
|
||||
{
|
||||
[Key]
|
||||
[DatabaseGenerated(DatabaseGeneratedOption.None)]
|
||||
public string SectionName { get; set; } = "";
|
||||
|
||||
/// <summary>Raw JSON payload for this section. Postgres stores as jsonb; SQLite as TEXT.</summary>
|
||||
public string ValueJson { get; set; } = "{}";
|
||||
|
||||
public DateTime DateCreated { get; set; } = DateTime.MinValue;
|
||||
public DateTime? DateUpdated { get; set; }
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
using SVSim.Database.Common;
|
||||
|
||||
namespace SVSim.Database.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Server-wide tunable config and captured-from-prod state. Singleton (Id = "default") with
|
||||
/// all data living in a single typed jsonb column. See <see cref="GameConfigRoot"/> for the
|
||||
/// schema. Pre-refactor this entity had ~14 flat columns plus 4 FK navs — see migration
|
||||
/// `RefactorGameConfigurationToJsonb` for the cutover.
|
||||
/// </summary>
|
||||
public class GameConfiguration : BaseEntity<string>
|
||||
{
|
||||
public GameConfigRoot Config { get; set; } = new();
|
||||
}
|
||||
@@ -22,13 +22,6 @@ public class GlobalsRepository : IGlobalsRepository
|
||||
return await _dbContext.Set<BattlefieldEntry>().Where(bf => !onlyOpen || bf.IsOpen).ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<GameConfiguration> GetGameConfiguration(string key)
|
||||
{
|
||||
// TODO: fixed in Tasks 6-7 — Include() calls removed because nav props dropped in RefactorGameConfigurationToJsonb
|
||||
return await _dbContext.Set<GameConfiguration>().FirstOrDefaultAsync(gc => gc.Id == key) ??
|
||||
new GameConfiguration();
|
||||
}
|
||||
|
||||
public async Task<List<RankInfoEntry>> GetRankInfo()
|
||||
{
|
||||
return await _dbContext.Set<RankInfoEntry>().ToListAsync();
|
||||
|
||||
@@ -6,7 +6,6 @@ public interface IGlobalsRepository
|
||||
{
|
||||
Task<List<ClassExpEntry>> GetClassExpCurve();
|
||||
Task<List<BattlefieldEntry>> GetBattlefields(bool onlyOpen);
|
||||
Task<GameConfiguration> GetGameConfiguration(string key);
|
||||
Task<List<RankInfoEntry>> GetRankInfo();
|
||||
|
||||
// Prod-captured globals — populated by SVSim.Bootstrap.GlobalsImporter.
|
||||
|
||||
@@ -1,20 +1,22 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SVSim.Database.Enums;
|
||||
using SVSim.Database.Models;
|
||||
using SVSim.Database.Repositories.Card;
|
||||
using SVSim.Database.Repositories.Globals;
|
||||
using SVSim.Database.Models.Config;
|
||||
using SVSim.Database.Services;
|
||||
|
||||
namespace SVSim.Database.Repositories.Viewer;
|
||||
|
||||
public class ViewerRepository : IViewerRepository
|
||||
{
|
||||
protected readonly SVSimDbContext _dbContext;
|
||||
private readonly IGameConfigService _config;
|
||||
|
||||
private const int MaxFriends = 20;
|
||||
|
||||
public ViewerRepository(SVSimDbContext dbContext)
|
||||
public ViewerRepository(SVSimDbContext dbContext, IGameConfigService config)
|
||||
{
|
||||
_dbContext = dbContext;
|
||||
_config = config;
|
||||
}
|
||||
|
||||
public async Task<Models.Viewer?> GetViewerBySocialConnection(SocialAccountType accountType, ulong socialId)
|
||||
@@ -73,7 +75,9 @@ public class ViewerRepository : IViewerRepository
|
||||
{
|
||||
DisplayName = displayName
|
||||
};
|
||||
GameConfiguration gameConfig = await new GlobalsRepository(_dbContext).GetGameConfiguration("default");
|
||||
var player = _config.Get<PlayerConfig>();
|
||||
var grants = _config.Get<DefaultGrantsConfig>();
|
||||
var loadout = _config.Get<DefaultLoadoutConfig>();
|
||||
|
||||
viewer.SocialAccountConnections.Add(new SocialAccountConnection
|
||||
{
|
||||
@@ -81,13 +85,12 @@ public class ViewerRepository : IViewerRepository
|
||||
AccountType = socialType
|
||||
});
|
||||
|
||||
// TODO: fixed in Task 7 — reads via Config tree after RefactorGameConfigurationToJsonb
|
||||
viewer.Info.MaxFriends = gameConfig.Config.Player.MaxFriends;
|
||||
viewer.Info.MaxFriends = player.MaxFriends;
|
||||
viewer.Info.CountryCode = "KOR";
|
||||
viewer.Info.BirthDate = DateTime.UtcNow;
|
||||
viewer.Currency.Crystals = gameConfig.Config.DefaultGrants.Crystals;
|
||||
viewer.Currency.Rupees = gameConfig.Config.DefaultGrants.Rupees;
|
||||
viewer.Currency.RedEther = gameConfig.Config.DefaultGrants.Ether;
|
||||
viewer.Currency.Crystals = grants.Crystals;
|
||||
viewer.Currency.Rupees = grants.Rupees;
|
||||
viewer.Currency.RedEther = grants.Ether;
|
||||
viewer.MissionData.TutorialState = 100; // finishes tutorial for now
|
||||
|
||||
// Load classes WITH their LeaderSkins — DefaultLeaderSkin iterates the nav collection
|
||||
@@ -108,11 +111,10 @@ public class ViewerRepository : IViewerRepository
|
||||
};
|
||||
}).ToList();
|
||||
|
||||
// TODO: fixed in Task 7 — load cosmetics by ID from Config.DefaultLoadout after RefactorGameConfigurationToJsonb
|
||||
var defaultSleeveId = gameConfig.Config.DefaultLoadout.SleeveId;
|
||||
var defaultDegreeId = gameConfig.Config.DefaultLoadout.DegreeId;
|
||||
var defaultEmblemId = gameConfig.Config.DefaultLoadout.EmblemId;
|
||||
var defaultBgId = gameConfig.Config.DefaultLoadout.MyPageBackgroundId;
|
||||
var defaultSleeveId = loadout.SleeveId;
|
||||
var defaultDegreeId = loadout.DegreeId;
|
||||
var defaultEmblemId = loadout.EmblemId;
|
||||
var defaultBgId = loadout.MyPageBackgroundId;
|
||||
var defaultSleeve = await _dbContext.Set<SleeveEntry>().FindAsync(defaultSleeveId);
|
||||
var defaultDegree = await _dbContext.Set<DegreeEntry>().FindAsync(defaultDegreeId);
|
||||
var defaultEmblem = await _dbContext.Set<EmblemEntry>().FindAsync(defaultEmblemId);
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.ChangeTracking;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using SVSim.Database.Common;
|
||||
using SVSim.Database.DataSeeders;
|
||||
using SVSim.Database.Models;
|
||||
using SVSim.Database.Models.Config;
|
||||
|
||||
namespace SVSim.Database;
|
||||
|
||||
@@ -38,7 +35,7 @@ public class SVSimDbContext : DbContext
|
||||
public DbSet<RankInfoEntry> RankInfo => Set<RankInfoEntry>();
|
||||
public DbSet<ItemEntry> Items => Set<ItemEntry>();
|
||||
|
||||
public DbSet<GameConfiguration> GameConfigurations => Set<GameConfiguration>();
|
||||
public DbSet<GameConfigSection> GameConfigs => Set<GameConfigSection>();
|
||||
|
||||
// Prod-captured globals — populated by SVSim.Bootstrap, not HasData. See
|
||||
// docs/audits/prod-data-capture-strategy-2026-05-23.md.
|
||||
@@ -128,57 +125,18 @@ public class SVSimDbContext : DbContext
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
// GameConfiguration.Config: on Postgres use EF Core 8's OwnsOne+ToJson(jsonb column).
|
||||
// On SQLite (tests) ToJson's WriteJson has a known NullReferenceException when owned
|
||||
// collections are present — use a plain TEXT value converter instead so the same
|
||||
// entity shape works for both providers without separate test models.
|
||||
bool isSqlite = Database.ProviderName?.Contains("Sqlite", StringComparison.OrdinalIgnoreCase) == true;
|
||||
if (isSqlite)
|
||||
// GameConfigSection: one row per top-level config section. Postgres stores ValueJson as
|
||||
// jsonb (gives jsonb-side queryability if needed later); SQLite gets a plain TEXT column.
|
||||
// EF never sees the section POCO shapes — IGameConfigService owns deserialisation via STJ.
|
||||
// Replaces the old single-row GameConfigurations table with its EF Core 8 OwnsOne+ToJson
|
||||
// tree; see 2026-05-24 config refactor.
|
||||
bool isPostgres = Database.ProviderName?.Contains("Npgsql", StringComparison.OrdinalIgnoreCase) == true;
|
||||
if (isPostgres)
|
||||
{
|
||||
// Store as JSON text via a value converter; EF treats Config as a single column.
|
||||
var configConverter = new ValueConverter<GameConfigRoot, string>(
|
||||
model => JsonSerializer.Serialize(model, (JsonSerializerOptions?)null),
|
||||
json => JsonSerializer.Deserialize<GameConfigRoot>(json, (JsonSerializerOptions?)null)
|
||||
?? new GameConfigRoot());
|
||||
|
||||
// Deep-equality comparer: serialize both sides and compare strings so that
|
||||
// mutations to nested properties (e.g. Config.Rotation.TsRotationId = "10015")
|
||||
// are detected by EF's snapshot change tracker and written to the DB on SaveChanges.
|
||||
var configComparer = new ValueComparer<GameConfigRoot>(
|
||||
(a, b) => JsonSerializer.Serialize(a, (JsonSerializerOptions?)null)
|
||||
== JsonSerializer.Serialize(b, (JsonSerializerOptions?)null),
|
||||
v => JsonSerializer.Serialize(v, (JsonSerializerOptions?)null).GetHashCode(),
|
||||
v => JsonSerializer.Deserialize<GameConfigRoot>(
|
||||
JsonSerializer.Serialize(v, (JsonSerializerOptions?)null),
|
||||
(JsonSerializerOptions?)null) ?? new GameConfigRoot());
|
||||
|
||||
modelBuilder.Entity<GameConfiguration>()
|
||||
.Property(c => c.Config)
|
||||
.HasColumnName("Config")
|
||||
.HasConversion(configConverter, configComparer);
|
||||
modelBuilder.Entity<GameConfigSection>()
|
||||
.Property(s => s.ValueJson)
|
||||
.HasColumnType("jsonb");
|
||||
}
|
||||
else
|
||||
{
|
||||
// Production path: jsonb column with full OwnsOne tree so EF can filter/project
|
||||
// into sub-properties at the DB level if needed.
|
||||
modelBuilder.Entity<GameConfiguration>().OwnsOne(c => c.Config, b =>
|
||||
{
|
||||
b.ToJson("Config");
|
||||
b.OwnsOne(r => r.DefaultGrants);
|
||||
b.OwnsOne(r => r.Player);
|
||||
b.OwnsOne(r => r.DefaultLoadout);
|
||||
b.OwnsOne(r => r.Challenge);
|
||||
b.OwnsOne(r => r.Rotation);
|
||||
b.OwnsOne(r => r.PackRates, pr =>
|
||||
{
|
||||
pr.OwnsOne(p => p.Default);
|
||||
pr.OwnsMany(p => p.PerSlot);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
new BaseDataSeeder().Seed(modelBuilder);
|
||||
new DefaultSettingsSeeder().Seed(modelBuilder);
|
||||
|
||||
base.OnModelCreating(modelBuilder);
|
||||
}
|
||||
@@ -202,20 +160,60 @@ public class SVSimDbContext : DbContext
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Idempotent runtime seed for entities that can't use HasData (notably GameConfiguration
|
||||
/// because of EF Core 8's HasData+OwnsOne(ToJson) jsonb limitation).
|
||||
/// Idempotent runtime seed for entities that can't use HasData. For GameConfigSection: walks
|
||||
/// every <see cref="ConfigSectionAttribute"/>-marked POCO in the Models.Config namespace and
|
||||
/// inserts a row containing its <c>ShippedDefaults()</c> payload if no row for that section
|
||||
/// name exists. Safe to run on every startup — only missing rows are added; operator-edited
|
||||
/// rows are left alone.
|
||||
/// </summary>
|
||||
public async Task EnsureSeedDataAsync()
|
||||
{
|
||||
if (!await GameConfigurations.AnyAsync(c => c.Id == "default"))
|
||||
var existing = await GameConfigs.Select(s => s.SectionName).ToListAsync();
|
||||
var existingSet = new HashSet<string>(existing, StringComparer.Ordinal);
|
||||
int added = 0;
|
||||
|
||||
foreach (var (name, json) in EnumerateShippedDefaults())
|
||||
{
|
||||
if (existingSet.Contains(name)) continue;
|
||||
GameConfigs.Add(new GameConfigSection { SectionName = name, ValueJson = json });
|
||||
added++;
|
||||
}
|
||||
|
||||
if (added > 0)
|
||||
{
|
||||
GameConfigurations.Add(new Models.GameConfiguration
|
||||
{
|
||||
Id = "default",
|
||||
Config = new Models.GameConfigRoot(),
|
||||
});
|
||||
await SaveChangesAsync();
|
||||
_logger.LogInformation("Seeded default GameConfiguration row.");
|
||||
_logger.LogInformation("Seeded {Count} default GameConfigSection row(s).", added);
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<(string Name, string Json)> EnumerateShippedDefaults()
|
||||
{
|
||||
// Reflect over every [ConfigSection]-marked type in the same assembly as PackRateConfig.
|
||||
// Each type must expose a parameterless `public static T ShippedDefaults()` — see the
|
||||
// POCOs in Models/Config for the convention.
|
||||
var asm = typeof(PackRateConfig).Assembly;
|
||||
var stjOptions = new System.Text.Json.JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = false,
|
||||
};
|
||||
|
||||
foreach (var t in asm.GetTypes())
|
||||
{
|
||||
var attr = t.GetCustomAttributes(typeof(ConfigSectionAttribute), inherit: false)
|
||||
.Cast<ConfigSectionAttribute>().FirstOrDefault();
|
||||
if (attr is null) continue;
|
||||
|
||||
var factory = t.GetMethod("ShippedDefaults",
|
||||
System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Static,
|
||||
binder: null, types: Type.EmptyTypes, modifiers: null);
|
||||
if (factory is null)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"[ConfigSection] type {t.FullName} is missing `public static {t.Name} ShippedDefaults()`.");
|
||||
}
|
||||
var instance = factory.Invoke(null, null)
|
||||
?? throw new InvalidOperationException($"{t.FullName}.ShippedDefaults() returned null.");
|
||||
yield return (attr.Name, System.Text.Json.JsonSerializer.Serialize(instance, t, stjOptions));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
21
SVSim.Database/Services/IGameConfigService.cs
Normal file
21
SVSim.Database/Services/IGameConfigService.cs
Normal file
@@ -0,0 +1,21 @@
|
||||
namespace SVSim.Database.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Read-only access to game-domain configuration. Resolves each section atomically through the
|
||||
/// tier chain: DB row in <c>GameConfigs</c> → <c>appsettings.json</c> section
|
||||
/// <c>"GameConfig:<SectionName>"</c> → <c>T.ShippedDefaults()</c> → <c>new T()</c>.
|
||||
/// <para>
|
||||
/// "Atomic" means: the first tier that has the section wins entirely; tiers are not merged
|
||||
/// per-property. This is deliberate — see 2026-05-24 config refactor discussion. Caching is
|
||||
/// not implemented today (scoped lifetime; one DB read per request); the interface is shaped
|
||||
/// to allow it to be added later without changing call sites.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public interface IGameConfigService
|
||||
{
|
||||
/// <summary>
|
||||
/// Resolves the section identified by <typeparamref name="T"/>'s
|
||||
/// <c>ConfigSectionAttribute</c>. Throws if the type is not annotated.
|
||||
/// </summary>
|
||||
T Get<T>() where T : class, new();
|
||||
}
|
||||
@@ -2,12 +2,14 @@ using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using SVSim.Database.Enums;
|
||||
using SVSim.Database.Models;
|
||||
using SVSim.Database.Models.Config;
|
||||
using PreReleaseInfoEntity = SVSim.Database.Models.PreReleaseInfo;
|
||||
using PreReleaseInfoDto = SVSim.EmulatedEntrypoint.Models.Dtos.PreReleaseInfo;
|
||||
using SVSim.Database.Repositories.Card;
|
||||
using SVSim.Database.Repositories.Collectibles;
|
||||
using SVSim.Database.Repositories.Globals;
|
||||
using SVSim.Database.Repositories.Viewer;
|
||||
using SVSim.Database.Services;
|
||||
using SVSim.EmulatedEntrypoint.Constants;
|
||||
using SVSim.EmulatedEntrypoint.Infrastructure;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos;
|
||||
@@ -43,16 +45,18 @@ public class LoadController : SVSimController
|
||||
private readonly ICollectionRepository _collectionRepository;
|
||||
private readonly IGlobalsRepository _globalsRepository;
|
||||
private readonly ICardAcquisitionService _acquisition;
|
||||
private readonly IGameConfigService _config;
|
||||
|
||||
public LoadController(IViewerRepository viewerRepository, ICardRepository cardRepository,
|
||||
ICollectionRepository collectionRepository, IGlobalsRepository globalsRepository,
|
||||
ICardAcquisitionService acquisition)
|
||||
ICardAcquisitionService acquisition, IGameConfigService config)
|
||||
{
|
||||
_viewerRepository = viewerRepository;
|
||||
_cardRepository = cardRepository;
|
||||
_collectionRepository = collectionRepository;
|
||||
_globalsRepository = globalsRepository;
|
||||
_acquisition = acquisition;
|
||||
_config = config;
|
||||
}
|
||||
|
||||
[HttpPost("index")]
|
||||
@@ -141,10 +145,12 @@ public class LoadController : SVSimController
|
||||
prevNecessaryExp = entry.NecessaryExp;
|
||||
}
|
||||
|
||||
// Globals — one cached fetch per slice. GameConfiguration carries six time-varying scalars
|
||||
// (ts_rotation_id, is_battle_pass_period, etc.) added in the prod-capture migration; the
|
||||
// other repo methods come from SVSim.Bootstrap.GlobalsImporter seeding.
|
||||
GameConfiguration cfg = await _globalsRepository.GetGameConfiguration("default");
|
||||
// Globals — one cached fetch per slice. The Rotation/Challenge/DefaultLoadout sections
|
||||
// come via IGameConfigService (DB → appsettings → ShippedDefaults). Other repo methods
|
||||
// come from SVSim.Bootstrap.GlobalsImporter seeding.
|
||||
var rotation = _config.Get<RotationConfig>();
|
||||
var challenge = _config.Get<ChallengeConfig>();
|
||||
var defaultLoadout = _config.Get<DefaultLoadoutConfig>();
|
||||
|
||||
List<CardSetIdentifier> rotationSets = (await _globalsRepository.GetRotationCardSets())
|
||||
.OrderBy(s => s.Id)
|
||||
@@ -187,7 +193,7 @@ public class LoadController : SVSimController
|
||||
MyPageBackgrounds = viewer.MyPageBackgrounds.Select(mpbg => mpbg.Id.ToString()).ToList(),
|
||||
LootBoxRegulations = new LootBoxRegulations(),
|
||||
GatheringInfo = new GatheringInfo(),
|
||||
IsBattlePassPeriod = cfg.Config.Rotation.IsBattlePassPeriod,
|
||||
IsBattlePassPeriod = rotation.IsBattlePassPeriod,
|
||||
// Optional per spec (load-index.md:228). We have BattlePassLevelEntry rows seeded, but
|
||||
// no per-viewer Battle Pass progression yet — emit null until that subsystem lands.
|
||||
BattlePassLevelInfo = null,
|
||||
@@ -230,19 +236,19 @@ public class LoadController : SVSimController
|
||||
}).ToList(),
|
||||
ArenaConfig = new ArenaConfig
|
||||
{
|
||||
UseChallengePickTwoPremiumCard = cfg.Config.Challenge.UseTwoPickPremiumCard ? 1 : 0,
|
||||
ChallengePickTwoCardSleeve = (int)cfg.Config.Challenge.TwoPickSleeveId,
|
||||
UseChallengePickTwoPremiumCard = challenge.UseTwoPickPremiumCard ? 1 : 0,
|
||||
ChallengePickTwoCardSleeve = (int)challenge.TwoPickSleeveId,
|
||||
},
|
||||
ArenaInfos = await BuildArenaInfosAsync(),
|
||||
RotationSets = rotationSets,
|
||||
UserConfig = new UserConfig(),
|
||||
OpenBattlefieldIds = (await _globalsRepository.GetBattlefields(true))
|
||||
.Select(bf => bf.Id.ToString()).ToList(),
|
||||
DefaultSettings = new DefaultSettings(cfg),
|
||||
DefaultSettings = new DefaultSettings(defaultLoadout),
|
||||
ClassExp = classExps,
|
||||
RankInfo = (await _globalsRepository.GetRankInfo()).Select(ri => new RankInfo(ri)).ToList(),
|
||||
DeckFormat = Format.Rotation,
|
||||
CardSetIdForResourceDlView = cfg.Config.Rotation.CardSetIdForResourceDlView,
|
||||
CardSetIdForResourceDlView = rotation.CardSetIdForResourceDlView,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -314,12 +320,21 @@ public class LoadController : SVSimController
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Schedules are not yet stored per-rotation in the DB; prod's shape (free_battle / gathering
|
||||
/// DateRange) is global. Hardcoded to a default-initialized SpecialRotationSchedule until we
|
||||
/// capture meaningful values — the client's MyRotationAllInfo.Parse audit will tell us where
|
||||
/// these come from.
|
||||
/// Maps the <c>MyRotationSchedule</c> config section to the wire-shape <c>SpecialRotationSchedule</c>.
|
||||
/// The client gates the Custom Rotation format-selector button on <c>FreeBattle</c>'s window
|
||||
/// being currently open (Wizard/MyRotationAllInfo.cs:45), so a default-initialised
|
||||
/// <c>DateTime.MinValue</c> pair here hides the button. Config defaults reproduce the
|
||||
/// 2026-05-23 prod capture; GlobalsImporter overwrites from newer captures.
|
||||
/// </summary>
|
||||
private static SpecialRotationSchedule BuildMyRotationSchedules() => new();
|
||||
private SpecialRotationSchedule BuildMyRotationSchedules()
|
||||
{
|
||||
var cfg = _config.Get<MyRotationScheduleConfig>();
|
||||
return new SpecialRotationSchedule
|
||||
{
|
||||
Gathering = new DateRange { BeginTime = cfg.Gathering.Begin, EndTime = cfg.Gathering.End },
|
||||
FreeBattle = new DateRange { BeginTime = cfg.FreeBattle.Begin, EndTime = cfg.FreeBattle.End },
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds <c>avatar_info</c> from AvatarAbilityEntry rows. Schedules is an empty list per the
|
||||
|
||||
@@ -2,8 +2,10 @@ using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using SVSim.Database.Models;
|
||||
using SVSim.Database.Models.Config;
|
||||
using SVSim.Database.Repositories.Globals;
|
||||
using SVSim.Database.Repositories.Viewer;
|
||||
using SVSim.Database.Services;
|
||||
using SVSim.EmulatedEntrypoint.Constants;
|
||||
using SVSim.EmulatedEntrypoint.Infrastructure;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos;
|
||||
@@ -20,11 +22,13 @@ public class MyPageController : SVSimController
|
||||
|
||||
private readonly IViewerRepository _viewerRepository;
|
||||
private readonly IGlobalsRepository _globalsRepository;
|
||||
private readonly IGameConfigService _config;
|
||||
|
||||
public MyPageController(IViewerRepository viewerRepository, IGlobalsRepository globalsRepository)
|
||||
public MyPageController(IViewerRepository viewerRepository, IGlobalsRepository globalsRepository, IGameConfigService config)
|
||||
{
|
||||
_viewerRepository = viewerRepository;
|
||||
_globalsRepository = globalsRepository;
|
||||
_config = config;
|
||||
}
|
||||
|
||||
[HttpPost("index")]
|
||||
@@ -46,7 +50,7 @@ public class MyPageController : SVSimController
|
||||
int deviceType = int.TryParse(deviceHeader, out int parsed) ? parsed : 0;
|
||||
|
||||
// Hydrate all the globals slices in parallel-ish — they're independent reads.
|
||||
var cfg = await _globalsRepository.GetGameConfiguration("default");
|
||||
var rotation = _config.Get<RotationConfig>();
|
||||
var colosseum = await _globalsRepository.GetCurrentColosseum();
|
||||
var sealedSeason = await _globalsRepository.GetCurrentSealedSeason();
|
||||
var masterPointPeriod = await _globalsRepository.GetCurrentMasterPointPeriod();
|
||||
@@ -95,7 +99,7 @@ public class MyPageController : SVSimController
|
||||
UserMyPageSetting = new MyPageBgSetting(),
|
||||
},
|
||||
BasicPuzzle = new BasicPuzzle { IsDisplayBadge = false }, // TODO(mypage-stub): viewer practice-puzzle progress
|
||||
IsBattlePassPeriod = cfg.Config.Rotation.IsBattlePassPeriod,
|
||||
IsBattlePassPeriod = rotation.IsBattlePassPeriod,
|
||||
SpecialCrystalInfo = new(), // TODO(mypage-stub): same shape/source as /load/index
|
||||
// CompetitionInfo, ShopNotification, StoryNotification, GuildNotification, GatheringInfo,
|
||||
// IsHiddenBossAppeared, SubBanner/SubBannerList/HomeDialogList/UserOfflineEvent/UserItemList,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
using MessagePack;
|
||||
using SVSim.Database.Models;
|
||||
using SVSim.Database.Models.Config;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Models.Dtos;
|
||||
@@ -17,11 +17,11 @@ public class DefaultSettings
|
||||
[Key("default_mypage_id")]
|
||||
public int DefaultMyPageBackground { get; set; }
|
||||
|
||||
public DefaultSettings(GameConfiguration config)
|
||||
public DefaultSettings(DefaultLoadoutConfig loadout)
|
||||
{
|
||||
this.DefaultMyPageBackground = config.Config.DefaultLoadout.MyPageBackgroundId;
|
||||
this.DefaultDegreeId = config.Config.DefaultLoadout.DegreeId;
|
||||
this.DefaultEmblemId = config.Config.DefaultLoadout.EmblemId;
|
||||
this.DefaultMyPageBackground = loadout.MyPageBackgroundId;
|
||||
this.DefaultDegreeId = loadout.DegreeId;
|
||||
this.DefaultEmblemId = loadout.EmblemId;
|
||||
}
|
||||
|
||||
public DefaultSettings()
|
||||
|
||||
@@ -27,8 +27,8 @@ public class UserLeaderSkin
|
||||
{
|
||||
this.Id = leaderSkin.Id;
|
||||
this.Name = leaderSkin.Name;
|
||||
// Class is nullable 窶・BaseDataSeeder maps CSV class_chara_id=0 to null. Fall back to
|
||||
// the FK column (also nullable) and finally 0 for class-agnostic skins.
|
||||
// Class is nullable — class-agnostic skins (CSV class_chara_id=0) come in as null. Fall
|
||||
// back to the FK column (also nullable) and finally 0.
|
||||
this.ClassId = leaderSkin.Class?.Id ?? leaderSkin.ClassId ?? 0;
|
||||
this.EmoteId = leaderSkin.EmoteId;
|
||||
this.IsOwned = isOwned;
|
||||
|
||||
@@ -65,11 +65,9 @@ public class Program
|
||||
builder.Services.AddTransient<IDeckRepository, DeckRepository>();
|
||||
builder.Services.AddTransient<IPackRepository, PackRepository>();
|
||||
// Scoped (not Singleton) to avoid the singleton-depends-on-scoped-DbContext lifecycle
|
||||
// pitfall. Cost: one indexed single-row query per request — trivial. Restart still picks
|
||||
// up DB-edit changes since each new request rebuilds the scope.
|
||||
builder.Services.AddScoped<SVSim.Database.Models.GameConfigRoot>(sp =>
|
||||
sp.GetRequiredService<SVSim.Database.Repositories.Globals.IGlobalsRepository>()
|
||||
.GetGameConfiguration("default").GetAwaiter().GetResult().Config);
|
||||
// pitfall. Cost: one indexed single-row query per section per request — trivial. No
|
||||
// in-process cache today; the IGameConfigService interface is shaped to allow one later.
|
||||
builder.Services.AddScoped<SVSim.Database.Services.IGameConfigService, GameConfigService>();
|
||||
builder.Services.AddScoped<ICardPoolProvider, DbCardPoolProvider>();
|
||||
builder.Services.AddScoped<PackOpenService>();
|
||||
builder.Services.AddScoped<ICardAcquisitionService, CardAcquisitionService>();
|
||||
|
||||
@@ -22,12 +22,6 @@
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.4.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Include="Data\*.csv">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="SVSim.UnitTests" />
|
||||
</ItemGroup>
|
||||
|
||||
74
SVSim.EmulatedEntrypoint/Services/GameConfigService.cs
Normal file
74
SVSim.EmulatedEntrypoint/Services/GameConfigService.cs
Normal file
@@ -0,0 +1,74 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Reflection;
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using SVSim.Database;
|
||||
using SVSim.Database.Models.Config;
|
||||
using SVSim.Database.Services;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Three-tier resolver for <see cref="IGameConfigService"/>: GameConfigs row → IConfiguration
|
||||
/// section under "GameConfig:<name>" → <c>T.ShippedDefaults()</c> (via reflection) → <c>new T()</c>.
|
||||
/// <para>
|
||||
/// Atomic per section: the first tier that has the section wins entirely; no per-property merging.
|
||||
/// Scoped lifetime — one DB read per request — matches today's <c>GameConfigRoot</c> behavior.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public class GameConfigService : IGameConfigService
|
||||
{
|
||||
private static readonly ConcurrentDictionary<Type, SectionMetadata> _metaCache = new();
|
||||
|
||||
private readonly SVSimDbContext _db;
|
||||
private readonly IConfiguration _appSettings;
|
||||
|
||||
public GameConfigService(SVSimDbContext db, IConfiguration appSettings)
|
||||
{
|
||||
_db = db;
|
||||
_appSettings = appSettings;
|
||||
}
|
||||
|
||||
public T Get<T>() where T : class, new()
|
||||
{
|
||||
var meta = GetMeta(typeof(T));
|
||||
|
||||
// Tier 1: DB row
|
||||
var row = _db.GameConfigs.AsNoTracking().FirstOrDefault(s => s.SectionName == meta.SectionName);
|
||||
if (row is not null)
|
||||
{
|
||||
return (T?)JsonSerializer.Deserialize(row.ValueJson, typeof(T))
|
||||
?? throw new InvalidOperationException(
|
||||
$"GameConfigs row '{meta.SectionName}' deserialised to null — corrupt jsonb?");
|
||||
}
|
||||
|
||||
// Tier 2: appsettings.json under "GameConfig:<name>"
|
||||
var configSection = _appSettings.GetSection($"GameConfig:{meta.SectionName}");
|
||||
if (configSection.Exists())
|
||||
{
|
||||
var fromAppsettings = configSection.Get<T>();
|
||||
if (fromAppsettings is not null) return fromAppsettings;
|
||||
}
|
||||
|
||||
// Tier 3: ShippedDefaults() if present, else parameterless ctor
|
||||
if (meta.ShippedDefaultsFactory is not null)
|
||||
{
|
||||
return (T)meta.ShippedDefaultsFactory.Invoke(null, null)!;
|
||||
}
|
||||
return new T();
|
||||
}
|
||||
|
||||
private static SectionMetadata GetMeta(Type t) => _metaCache.GetOrAdd(t, static type =>
|
||||
{
|
||||
var attr = type.GetCustomAttribute<ConfigSectionAttribute>(inherit: false)
|
||||
?? throw new InvalidOperationException(
|
||||
$"{type.FullName} is not marked with [ConfigSection(...)] — IGameConfigService can't resolve it.");
|
||||
var factory = type.GetMethod("ShippedDefaults",
|
||||
BindingFlags.Public | BindingFlags.Static,
|
||||
binder: null, types: Type.EmptyTypes, modifiers: null);
|
||||
return new SectionMetadata(attr.Name, factory);
|
||||
});
|
||||
|
||||
private sealed record SectionMetadata(string SectionName, MethodInfo? ShippedDefaultsFactory);
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
using SVSim.Database.Enums;
|
||||
using SVSim.Database.Models;
|
||||
using SVSim.Database.Models.Config;
|
||||
using SVSim.Database.Services;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Draws cards from a pack's pool using rates from the injected <see cref="GameConfigRoot"/>'s
|
||||
/// Draws cards from a pack's pool using rates from <see cref="IGameConfigService"/>'s
|
||||
/// <see cref="PackRateConfig"/>. Slot rarity selection is unified through one
|
||||
/// <see cref="PickRarity"/> + <see cref="ResolveWeights"/> pair — what was previously a
|
||||
/// hardcoded slot-1-7 vs slot-8 split now reads from <c>PackRateConfig.PerSlot</c>.
|
||||
@@ -19,9 +20,9 @@ public class PackOpenService
|
||||
|
||||
private readonly PackRateConfig _rates;
|
||||
|
||||
public PackOpenService(GameConfigRoot config)
|
||||
public PackOpenService(IGameConfigService config)
|
||||
{
|
||||
_rates = config.PackRates;
|
||||
_rates = config.Get<PackRateConfig>();
|
||||
}
|
||||
|
||||
public DrawResult Draw(
|
||||
|
||||
@@ -277,6 +277,17 @@ public class LoadControllerTests
|
||||
Assert.That(mri.GetProperty("setting").EnumerateObject().Count(), Is.EqualTo(27));
|
||||
Assert.That(mri.GetProperty("abilities").EnumerateObject().Count(), Is.EqualTo(6));
|
||||
|
||||
// my_rotation_info.schedules drives the client's "Custom Rotation" button visibility
|
||||
// (Wizard/MyRotationAllInfo.cs:45 — IsMyRotationEnable). GlobalsImporter sources the
|
||||
// window from the prod capture; default-initialised DateTime.MinValue values would hide
|
||||
// the button. Assert the captured 2024→2030 free_battle window round-trips through the
|
||||
// MyRotationScheduleConfig section.
|
||||
var fb = mri.GetProperty("schedules").GetProperty("free_battle");
|
||||
Assert.That(DateTime.Parse(fb.GetProperty("begin_time").GetString()!),
|
||||
Is.EqualTo(new DateTime(2024, 5, 1, 20, 0, 0, DateTimeKind.Utc)));
|
||||
Assert.That(DateTime.Parse(fb.GetProperty("end_time").GetString()!),
|
||||
Is.EqualTo(new DateTime(2030, 6, 26, 19, 59, 59, DateTimeKind.Utc)));
|
||||
|
||||
// avatar_info: abilities dict has 24 entries; schedules is empty list
|
||||
var ai = root.GetProperty("avatar_info");
|
||||
Assert.That(ai.GetProperty("abilities").EnumerateObject().Count(), Is.EqualTo(24));
|
||||
|
||||
@@ -58,6 +58,12 @@ internal sealed class SVSimTestFactory : WebApplicationFactory<Program>
|
||||
db.Database.EnsureCreated();
|
||||
db.EnsureSeedDataAsync().GetAwaiter().GetResult();
|
||||
|
||||
// Reference data is no longer HasData-seeded; load the CSVs via the same importer
|
||||
// production uses so tests exercise the same code path. CardCosmeticRewards skipped —
|
||||
// FK to Cards would reject every row against the minimal 3-card test seed below.
|
||||
var dataDir = Path.Combine(AppContext.BaseDirectory, "Data");
|
||||
new ReferenceDataImporter().ImportAllAsync(db, dataDir).GetAwaiter().GetResult();
|
||||
|
||||
// Seed a minimal card set so card-pool tests can resolve a non-empty pool without
|
||||
// requiring the full CardImporter tool or a cards.json file. The set is marked
|
||||
// IsInRotation so both standard-pack (by setId) and special-pack (rotation scan)
|
||||
|
||||
@@ -30,34 +30,6 @@ internal class SqliteFriendlyModelCustomizer : ModelCustomizer
|
||||
shortUdidProperty.ValueGenerated = ValueGenerated.Never;
|
||||
|
||||
AssignClientSideKeyGenerators(modelBuilder.Model);
|
||||
StripCardCosmeticRewardSeed(modelBuilder.Model);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// CardCosmeticReward rows have an FK to Cards.Id, and the production model HasData-seeds
|
||||
/// 1068 rows from card_cosmetic_rewards.csv (chunk A.2). In production those rows have
|
||||
/// matching cards inserted by the CardImporter before runtime. The unit-test factory uses
|
||||
/// SQLite + EnsureCreated + a minimal 3-card seed — most of the cosmetic-reward rows have
|
||||
/// no matching Cards row, and EnsureCreated's FK-deferred batch insert throws SqliteException
|
||||
/// "FOREIGN KEY constraint failed" at host construction time. Strip the seed in tests; the
|
||||
/// test fixture inserts CardCosmeticReward rows ad-hoc when a specific scenario needs them.
|
||||
///
|
||||
/// HasData seed is stored in the internal EntityType._data field (no public API). Clear it
|
||||
/// via reflection. The clear runs after base.Customize so the HasData call inside Seed()
|
||||
/// has populated the list before we wipe it.
|
||||
/// </summary>
|
||||
private static void StripCardCosmeticRewardSeed(IMutableModel model)
|
||||
{
|
||||
var entityType = model.FindEntityType(typeof(CardCosmeticReward));
|
||||
if (entityType is null) return;
|
||||
|
||||
// EntityType._data is a List<IDictionary<string, object>>? — null when empty.
|
||||
var dataField = entityType.GetType().GetField(
|
||||
"_data",
|
||||
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
|
||||
if (dataField is null) return;
|
||||
var list = dataField.GetValue(entityType) as System.Collections.IList;
|
||||
list?.Clear();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -1,75 +1,152 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using SVSim.Database;
|
||||
using SVSim.Database.Models;
|
||||
using SVSim.Database.Models.Config;
|
||||
using SVSim.UnitTests.Infrastructure;
|
||||
|
||||
namespace SVSim.UnitTests.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Round-trip tests for the GameConfigs key/value table — one row per section, raw jsonb payload
|
||||
/// deserialised by IGameConfigService rather than EF Core. Replaces the prior single-row
|
||||
/// GameConfigurations / GameConfigRoot jsonb-tree shape (2026-05-24 refactor).
|
||||
/// </summary>
|
||||
public class GameConfigurationJsonbTests
|
||||
{
|
||||
[Test]
|
||||
public async Task DefaultSeed_populates_canonical_GameConfigRoot_defaults()
|
||||
public async Task EnsureSeedData_writes_one_row_per_ConfigSection_with_ShippedDefaults_payload()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
|
||||
var cfg = await db.GameConfigurations.FirstOrDefaultAsync(c => c.Id == "default");
|
||||
var rows = await db.GameConfigs.AsNoTracking().ToListAsync();
|
||||
var byName = rows.ToDictionary(r => r.SectionName);
|
||||
|
||||
Assert.That(cfg, Is.Not.Null, "default GameConfiguration row must exist (seeded via EnsureSeedDataAsync)");
|
||||
Assert.That(cfg!.Config, Is.Not.Null, "Config must round-trip to non-null GameConfigRoot");
|
||||
Assert.That(cfg.Config.DefaultGrants.Crystals, Is.EqualTo(50000UL), "pre-refactor default");
|
||||
Assert.That(cfg.Config.DefaultGrants.Rupees, Is.EqualTo(50000UL), "pre-refactor default");
|
||||
Assert.That(cfg.Config.DefaultGrants.Ether, Is.EqualTo(50000UL), "pre-refactor default");
|
||||
Assert.That(cfg.Config.Player.MaxFriends, Is.EqualTo(20), "pre-refactor default");
|
||||
Assert.That(cfg.Config.DefaultLoadout.SleeveId, Is.EqualTo(3000011), "pre-refactor default sleeve");
|
||||
Assert.That(cfg.Config.PackRates.AnimatedRate, Is.EqualTo(0.08).Within(1e-9), "SV Classic default");
|
||||
Assert.That(cfg.Config.PackRates.Default.Bronze, Is.EqualTo(0.6744).Within(1e-9));
|
||||
// PerSlot is now a List<SlotRarityWeights> keyed by Slot string (see Task 5 deviation note).
|
||||
var slot8 = cfg.Config.PackRates.PerSlot.FirstOrDefault(s => s.Slot == "8");
|
||||
Assert.That(slot8, Is.Not.Null, "slot-8 default entry must be present");
|
||||
// One row per [ConfigSection]-marked POCO (7 sections today: Player, DefaultGrants,
|
||||
// DefaultLoadout, Challenge, Rotation, PackRates, MyRotationSchedule).
|
||||
Assert.That(byName.Keys, Is.EquivalentTo(new[]
|
||||
{
|
||||
"Player", "DefaultGrants", "DefaultLoadout", "Challenge", "Rotation", "PackRates",
|
||||
"MyRotationSchedule",
|
||||
}));
|
||||
|
||||
var mrSchedule = JsonSerializer.Deserialize<MyRotationScheduleConfig>(byName["MyRotationSchedule"].ValueJson)!;
|
||||
Assert.That(mrSchedule.FreeBattle.Begin, Is.EqualTo(new DateTime(2024, 5, 1, 20, 0, 0, DateTimeKind.Utc)),
|
||||
"ShippedDefaults reproduces the 2026-05-23 prod capture so a fresh install ships with Custom Rotation enabled");
|
||||
Assert.That(mrSchedule.FreeBattle.End, Is.EqualTo(new DateTime(2030, 6, 26, 19, 59, 59, DateTimeKind.Utc)));
|
||||
|
||||
var packRates = JsonSerializer.Deserialize<PackRateConfig>(byName["PackRates"].ValueJson)!;
|
||||
Assert.That(packRates.AnimatedRate, Is.EqualTo(0.08).Within(1e-9), "SV Classic AnimatedRate");
|
||||
Assert.That(packRates.Default.Bronze, Is.EqualTo(0.6744).Within(1e-9));
|
||||
var slot8 = packRates.PerSlot.FirstOrDefault(s => s.Slot == "8");
|
||||
Assert.That(slot8, Is.Not.Null, "ShippedDefaults() includes the slot-8 Silver-or-better entry");
|
||||
Assert.That(slot8!.Silver, Is.EqualTo(0.7692).Within(1e-9));
|
||||
|
||||
var grants = JsonSerializer.Deserialize<DefaultGrantsConfig>(byName["DefaultGrants"].ValueJson)!;
|
||||
Assert.That(grants.Crystals, Is.EqualTo(50000UL));
|
||||
Assert.That(grants.Rupees, Is.EqualTo(50000UL));
|
||||
Assert.That(grants.Ether, Is.EqualTo(50000UL));
|
||||
|
||||
var player = JsonSerializer.Deserialize<PlayerConfig>(byName["Player"].ValueJson)!;
|
||||
Assert.That(player.MaxFriends, Is.EqualTo(20));
|
||||
|
||||
var loadout = JsonSerializer.Deserialize<DefaultLoadoutConfig>(byName["DefaultLoadout"].ValueJson)!;
|
||||
Assert.That(loadout.SleeveId, Is.EqualTo(3000011));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Mutation_then_save_then_reload_round_trips_through_jsonb()
|
||||
public async Task Section_row_round_trips_through_jsonb_via_raw_json()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
|
||||
using (var scope = factory.Services.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
var cfg = await db.GameConfigurations.FirstAsync(c => c.Id == "default");
|
||||
cfg.Config.Rotation.TsRotationId = "99999";
|
||||
cfg.Config.PackRates.AnimatedRate = 0.42;
|
||||
db.Entry(cfg).Property(c => c.Config).IsModified = true;
|
||||
var rotation = await db.GameConfigs.FirstAsync(s => s.SectionName == "Rotation");
|
||||
// Hydrate, mutate, re-serialise — same pattern GlobalsImporter and any admin-write
|
||||
// path will use.
|
||||
var value = JsonSerializer.Deserialize<RotationConfig>(rotation.ValueJson)!;
|
||||
value.TsRotationId = "99999";
|
||||
value.IsBattlePassPeriod = true;
|
||||
rotation.ValueJson = JsonSerializer.Serialize(value);
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
using (var scope = factory.Services.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
var cfg = await db.GameConfigurations.FirstAsync(c => c.Id == "default");
|
||||
Assert.That(cfg.Config.Rotation.TsRotationId, Is.EqualTo("99999"));
|
||||
Assert.That(cfg.Config.PackRates.AnimatedRate, Is.EqualTo(0.42).Within(1e-9));
|
||||
var rotation = await db.GameConfigs.FirstAsync(s => s.SectionName == "Rotation");
|
||||
var value = JsonSerializer.Deserialize<RotationConfig>(rotation.ValueJson)!;
|
||||
Assert.That(value.TsRotationId, Is.EqualTo("99999"));
|
||||
Assert.That(value.IsBattlePassPeriod, Is.True);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Operator-edited PerSlot override (e.g. 100% Legendary for testing) must survive a DB
|
||||
/// round-trip and produce exactly ONE entry per slot — not stack on top of any default seed.
|
||||
/// The 2026-05-24 bug shape: pre-refactor PackRateConfig.PerSlot shipped with a Classic
|
||||
/// slot-8 seed in its initialiser; EF Core 8's OwnsMany jsonb path appended the operator's
|
||||
/// override on top instead of replacing it, and the seed won the FirstOrDefault in
|
||||
/// ResolveWeights. Post-refactor this can't happen (PerSlot defaults to empty,
|
||||
/// IGameConfigService uses pure STJ which replaces) but the round-trip assertion stays.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async Task Operator_PerSlot_override_round_trips_as_sole_entry_for_that_slot()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
|
||||
using (var scope = factory.Services.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
var row = await db.GameConfigs.FirstAsync(s => s.SectionName == "PackRates");
|
||||
var rates = JsonSerializer.Deserialize<PackRateConfig>(row.ValueJson)!;
|
||||
// Operator wipes the seeded slot-8 entry and replaces it with a 100%-Legendary override.
|
||||
rates.PerSlot.Clear();
|
||||
rates.PerSlot.Add(new SlotRarityWeights
|
||||
{
|
||||
Slot = "8", Bronze = 0, Silver = 0, Gold = 0, Legendary = 1,
|
||||
});
|
||||
row.ValueJson = JsonSerializer.Serialize(rates);
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
using (var scope = factory.Services.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
var row = await db.GameConfigs.FirstAsync(s => s.SectionName == "PackRates");
|
||||
var rates = JsonSerializer.Deserialize<PackRateConfig>(row.ValueJson)!;
|
||||
|
||||
var slot8Entries = rates.PerSlot.Where(s => s.Slot == "8").ToList();
|
||||
Assert.That(slot8Entries, Has.Count.EqualTo(1),
|
||||
"exactly one PerSlot[8] entry must round-trip — duplicates mean the loader appended " +
|
||||
"instead of replacing (the 2026-05-24 bug pattern).");
|
||||
Assert.That(slot8Entries[0].Legendary, Is.EqualTo(1.0).Within(1e-9),
|
||||
"the surviving PerSlot[8] entry must be the operator's override, not a stale seed.");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GlobalsImporter_updates_Rotation_without_clobbering_other_subconfigs()
|
||||
public async Task GlobalsImporter_updates_Rotation_without_clobbering_other_sections()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
await factory.SeedGlobalsAsync(); // imports load-index which has ts_rotation_id="10015"
|
||||
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
var cfg = await db.GameConfigurations.FirstAsync(c => c.Id == "default");
|
||||
|
||||
Assert.That(cfg.Config.Rotation.TsRotationId, Is.EqualTo("10015"),
|
||||
var rotation = JsonSerializer.Deserialize<RotationConfig>(
|
||||
(await db.GameConfigs.FirstAsync(s => s.SectionName == "Rotation")).ValueJson)!;
|
||||
Assert.That(rotation.TsRotationId, Is.EqualTo("10015"),
|
||||
"GlobalsImporter should set Rotation.TsRotationId from the prod capture.");
|
||||
// PackRates is NOT in the load-index capture; must keep the seeded default unchanged.
|
||||
Assert.That(cfg.Config.PackRates.AnimatedRate, Is.EqualTo(0.08).Within(1e-9),
|
||||
|
||||
// PackRates is NOT in the load-index capture; its row must keep ShippedDefaults values.
|
||||
var packRates = JsonSerializer.Deserialize<PackRateConfig>(
|
||||
(await db.GameConfigs.FirstAsync(s => s.SectionName == "PackRates")).ValueJson)!;
|
||||
Assert.That(packRates.AnimatedRate, Is.EqualTo(0.08).Within(1e-9),
|
||||
"GlobalsImporter must not clobber PackRates while updating Rotation.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -206,18 +206,9 @@ public class GlobalsRepositoryTests
|
||||
Assert.That(skins.Count, Is.EqualTo(8));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetGameConfiguration_default_has_seeded_ts_rotation_id()
|
||||
{
|
||||
var (factory, repo) = await SetupAsync();
|
||||
using var _ = factory;
|
||||
var cfg = await repo.GetGameConfiguration("default");
|
||||
Assert.That(cfg, Is.Not.Null);
|
||||
Assert.That(cfg.Config.Rotation.TsRotationId, Is.EqualTo("10015"),
|
||||
"GlobalsImporter should overwrite the migration's empty-string default with the capture value.");
|
||||
Assert.That(cfg.Config.Rotation.IsBattlePassPeriod, Is.True,
|
||||
"Prod sends bool true for is_battle_pass_period; capture should overwrite the migration default of false.");
|
||||
}
|
||||
// Note: GetGameConfiguration was removed from IGlobalsRepository in the 2026-05-24 config
|
||||
// refactor — Rotation/Challenge/etc. now load via IGameConfigService. See
|
||||
// GameConfigurationJsonbTests for the equivalent round-trip coverage.
|
||||
|
||||
[Test]
|
||||
public async Task GetMaintenanceCards_empty_when_capture_has_none()
|
||||
|
||||
@@ -29,9 +29,10 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- BaseDataSeeder reads CSVs from the runtime "Data" folder; mirror them into the test
|
||||
output so HasData seeding fires when EnsureCreated builds the SQLite schema. -->
|
||||
<Content Include="..\SVSim.EmulatedEntrypoint\Data\*.csv" Link="Data\%(Filename)%(Extension)">
|
||||
<!-- ReferenceDataImporter / CardCosmeticRewardImporter read CSVs from the runtime "Data"
|
||||
folder; mirror Bootstrap's copies into the test output so SVSimTestFactory can call
|
||||
the importers after EnsureCreated to populate reference tables. -->
|
||||
<Content Include="..\SVSim.Bootstrap\Data\*.csv" Link="Data\%(Filename)%(Extension)">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
<!-- Tests that call SVSimTestFactory.SeedGlobalsAsync() need the prod captures available in
|
||||
|
||||
145
SVSim.UnitTests/Services/GameConfigServiceTests.cs
Normal file
145
SVSim.UnitTests/Services/GameConfigServiceTests.cs
Normal file
@@ -0,0 +1,145 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using SVSim.Database;
|
||||
using SVSim.Database.Models;
|
||||
using SVSim.Database.Models.Config;
|
||||
using SVSim.Database.Services;
|
||||
using SVSim.EmulatedEntrypoint.Services;
|
||||
using SVSim.UnitTests.Infrastructure;
|
||||
|
||||
namespace SVSim.UnitTests.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Covers <see cref="GameConfigService"/>'s tier chain (DB → IConfiguration → ShippedDefaults →
|
||||
/// <c>new T()</c>) and the atomic-per-section policy. Uses a real test SVSimDbContext from
|
||||
/// <see cref="SVSimTestFactory"/> for the DB tier and an in-memory IConfiguration for the
|
||||
/// appsettings tier. Test-only section types live in this file (assembly not scanned by the
|
||||
/// seeder) so the fallback tiers can be exercised without fighting EnsureSeedDataAsync.
|
||||
/// </summary>
|
||||
public class GameConfigServiceTests
|
||||
{
|
||||
// Real section type (in Models.Config, seeded by EnsureSeedDataAsync) — used to test DB and
|
||||
// override-DB scenarios.
|
||||
private const string PackRatesKey = "PackRates";
|
||||
|
||||
// Test-only section types: not in SVSim.Database assembly → seeder ignores them → DB row is
|
||||
// never written by the seed step. Exercises appsettings / ShippedDefaults / new T() tiers
|
||||
// without having to delete seeded rows.
|
||||
[ConfigSection("UnseededWithFactory")]
|
||||
public class UnseededWithFactory
|
||||
{
|
||||
public string Value { get; set; } = "";
|
||||
public static UnseededWithFactory ShippedDefaults() => new() { Value = "from-shipped-defaults" };
|
||||
}
|
||||
|
||||
[ConfigSection("UnseededNoFactory")]
|
||||
public class UnseededNoFactory
|
||||
{
|
||||
public int N { get; set; }
|
||||
// Intentionally no ShippedDefaults() — exercises the final `new T()` tier.
|
||||
}
|
||||
|
||||
public class UnattributedSection
|
||||
{
|
||||
public string Foo { get; set; } = "";
|
||||
}
|
||||
|
||||
private static IConfiguration EmptyConfig() =>
|
||||
new ConfigurationBuilder().Build();
|
||||
|
||||
private static IConfiguration ConfigFrom(params (string key, string value)[] entries) =>
|
||||
new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(entries.Select(e => new KeyValuePair<string, string?>(e.key, e.value)))
|
||||
.Build();
|
||||
|
||||
[Test]
|
||||
public void Get_returns_DB_row_when_section_exists()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
var svc = new GameConfigService(db, EmptyConfig());
|
||||
|
||||
// The fresh-install seeder wrote PackRates → tier 1 must hit it.
|
||||
var rates = svc.Get<PackRateConfig>();
|
||||
Assert.That(rates.AnimatedRate, Is.EqualTo(0.08).Within(1e-9),
|
||||
"tier-1 (DB) should return the seeded PackRates row");
|
||||
Assert.That(rates.PerSlot.Any(s => s.Slot == "8"), Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Get_atomic_DB_wins_even_when_appsettings_also_supplies_the_section()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
// Mutate DB row so we can detect which tier won.
|
||||
var row = db.GameConfigs.First(s => s.SectionName == PackRatesKey);
|
||||
var rates = JsonSerializer.Deserialize<PackRateConfig>(row.ValueJson)!;
|
||||
rates.AnimatedRate = 0.5;
|
||||
row.ValueJson = JsonSerializer.Serialize(rates);
|
||||
db.SaveChanges();
|
||||
|
||||
// appsettings also supplies a different value — DB must still win (atomic per section).
|
||||
var appsettings = ConfigFrom(($"GameConfig:{PackRatesKey}:AnimatedRate", "0.99"));
|
||||
var svc = new GameConfigService(db, appsettings);
|
||||
|
||||
var result = svc.Get<PackRateConfig>();
|
||||
Assert.That(result.AnimatedRate, Is.EqualTo(0.5).Within(1e-9),
|
||||
"atomic-per-section: DB row wins entirely; appsettings tier never consulted");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Get_falls_through_to_appsettings_when_no_DB_row()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
var appsettings = ConfigFrom(("GameConfig:UnseededWithFactory:Value", "from-appsettings"));
|
||||
var svc = new GameConfigService(db, appsettings);
|
||||
|
||||
var result = svc.Get<UnseededWithFactory>();
|
||||
Assert.That(result.Value, Is.EqualTo("from-appsettings"),
|
||||
"tier 2 should win when DB has no row and appsettings has the section");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Get_falls_through_to_ShippedDefaults_when_no_DB_row_and_no_appsettings_section()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
var svc = new GameConfigService(db, EmptyConfig());
|
||||
|
||||
var result = svc.Get<UnseededWithFactory>();
|
||||
Assert.That(result.Value, Is.EqualTo("from-shipped-defaults"),
|
||||
"tier 3 (ShippedDefaults) should win when neither DB nor appsettings supplies the section");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Get_falls_through_to_parameterless_ctor_when_section_has_no_ShippedDefaults()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
var svc = new GameConfigService(db, EmptyConfig());
|
||||
|
||||
var result = svc.Get<UnseededNoFactory>();
|
||||
Assert.That(result.N, Is.EqualTo(0),
|
||||
"tier 4 (new T()) should win when no other tier and no ShippedDefaults method exists");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Get_throws_when_section_type_is_not_marked_with_ConfigSection()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
var svc = new GameConfigService(db, EmptyConfig());
|
||||
|
||||
var ex = Assert.Throws<InvalidOperationException>(() => svc.Get<UnattributedSection>());
|
||||
Assert.That(ex!.Message, Does.Contain("[ConfigSection"),
|
||||
"unmarked type must produce a clear diagnostic, not a silent fallback");
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
using SVSim.Database.Enums;
|
||||
using SVSim.Database.Models;
|
||||
using SVSim.Database.Models.Config;
|
||||
using SVSim.Database.Services;
|
||||
using SVSim.EmulatedEntrypoint.Services;
|
||||
|
||||
namespace SVSim.UnitTests.Services;
|
||||
@@ -25,6 +26,24 @@ public class PackOpenServiceTests
|
||||
public ShadowverseCardEntry? TryGetFoilTwin(long baseCardId) => null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test stub that returns a single pre-built section. Only handles <see cref="PackRateConfig"/>
|
||||
/// (the type <see cref="PackOpenService"/> reads in its ctor); other section types throw so a
|
||||
/// future test that needs them must extend this stub explicitly.
|
||||
/// </summary>
|
||||
private sealed class StubConfig : IGameConfigService
|
||||
{
|
||||
private readonly PackRateConfig _rates;
|
||||
public StubConfig(PackRateConfig rates) { _rates = rates; }
|
||||
public T Get<T>() where T : class, new()
|
||||
{
|
||||
if (typeof(T) == typeof(PackRateConfig)) return (T)(object)_rates;
|
||||
throw new NotImplementedException($"StubConfig: unhandled section type {typeof(T)}.");
|
||||
}
|
||||
}
|
||||
|
||||
private static PackOpenService MakeService(PackRateConfig rates) => new(new StubConfig(rates));
|
||||
|
||||
private static List<ShadowverseCardEntry> MakeFourCards() => new()
|
||||
{
|
||||
new ShadowverseCardEntry { Id = 1, Rarity = Rarity.Bronze },
|
||||
@@ -41,8 +60,7 @@ public class PackOpenServiceTests
|
||||
[Test]
|
||||
public void Draw_returns_eight_cards_for_one_pack()
|
||||
{
|
||||
var rootDefault = new GameConfigRoot(); // canonical SV Classic defaults
|
||||
var svc = new PackOpenService(rootDefault);
|
||||
var svc = MakeService(PackRateConfig.ShippedDefaults());
|
||||
var pool = new StubPool(MakeFourCards());
|
||||
var rng = new ScriptedRandom(0.5); // anything in Bronze/Silver range for non-slot-8
|
||||
|
||||
@@ -54,8 +72,9 @@ public class PackOpenServiceTests
|
||||
[Test]
|
||||
public void Draw_slot_8_never_returns_bronze_for_standard_pack()
|
||||
{
|
||||
var rootDefault = new GameConfigRoot(); // canonical SV Classic defaults
|
||||
var svc = new PackOpenService(rootDefault);
|
||||
// PackRateConfig.ShippedDefaults() includes the SV Classic slot-8 "Silver-or-better
|
||||
// guarantee" entry (PerSlot Bronze=0). Same shape the runtime seeder writes to GameConfigs.
|
||||
var svc = MakeService(PackRateConfig.ShippedDefaults());
|
||||
var pool = new StubPool(MakeFourCards());
|
||||
|
||||
for (int trial = 0; trial < 1000; trial++)
|
||||
@@ -69,8 +88,7 @@ public class PackOpenServiceTests
|
||||
[Test]
|
||||
public void Draw_legendary_special_forces_slot_8_to_legendary()
|
||||
{
|
||||
var rootDefault = new GameConfigRoot(); // canonical SV Classic defaults
|
||||
var svc = new PackOpenService(rootDefault);
|
||||
var svc = MakeService(PackRateConfig.ShippedDefaults());
|
||||
var pool = new StubPool(MakeFourCards());
|
||||
var pack = new PackConfigEntry { Id = 92001, BasePackId = 90001, PackCategory = PackCategory.SpecialCardPack };
|
||||
|
||||
@@ -85,8 +103,7 @@ public class PackOpenServiceTests
|
||||
[Test]
|
||||
public void Draw_distribution_over_10k_slots_1_to_7_matches_declared_rates_within_2_percent()
|
||||
{
|
||||
var rootDefault = new GameConfigRoot(); // canonical SV Classic defaults
|
||||
var svc = new PackOpenService(rootDefault);
|
||||
var svc = MakeService(PackRateConfig.ShippedDefaults());
|
||||
var pool = new StubPool(MakeFourCards());
|
||||
var counts = new Dictionary<Rarity, int>
|
||||
{
|
||||
@@ -117,8 +134,7 @@ public class PackOpenServiceTests
|
||||
[Test]
|
||||
public void Draw_excludes_listed_card_ids()
|
||||
{
|
||||
var rootDefault = new GameConfigRoot(); // canonical SV Classic defaults
|
||||
var svc = new PackOpenService(rootDefault);
|
||||
var svc = MakeService(PackRateConfig.ShippedDefaults());
|
||||
// Pool with two bronze cards; exclude one — every Bronze slot must pick the other.
|
||||
var pool = new StubPool(new List<ShadowverseCardEntry>
|
||||
{
|
||||
@@ -140,16 +156,16 @@ public class PackOpenServiceTests
|
||||
public void Draw_per_slot_override_is_applied_for_that_slot_and_default_for_others()
|
||||
{
|
||||
// Config: slot 3 is forced to Legendary; everything else uses Default.
|
||||
// PerSlot is a List<SlotRarityWeights> with a Slot string key (EF Core 8 deviation from
|
||||
// the Dictionary<int, T> in the spec — see Task 5 notes).
|
||||
var root = new GameConfigRoot();
|
||||
root.PackRates.PerSlot.Add(new SlotRarityWeights
|
||||
// PerSlot is a List<SlotRarityWeights> with a Slot string key (no Dictionary<int,T> of
|
||||
// complex types under jsonb-friendly serialisation — see Task 5 notes).
|
||||
var rates = PackRateConfig.ShippedDefaults();
|
||||
rates.PerSlot.Add(new SlotRarityWeights
|
||||
{
|
||||
Slot = "3",
|
||||
Bronze = 0, Silver = 0, Gold = 0, Legendary = 1.0,
|
||||
});
|
||||
|
||||
var svc = new PackOpenService(root);
|
||||
var svc = MakeService(rates);
|
||||
var pool = new StubPool(MakeFourCards());
|
||||
|
||||
for (int trial = 0; trial < 50; trial++)
|
||||
@@ -186,8 +202,7 @@ public class PackOpenServiceTests
|
||||
new List<ShadowverseCardEntry> { bronze },
|
||||
new Dictionary<long, ShadowverseCardEntry> { [bronze.Id] = bronzeFoil });
|
||||
|
||||
var root = new GameConfigRoot(); // default AnimatedRate = 0.08
|
||||
var svc = new PackOpenService(root);
|
||||
var svc = MakeService(PackRateConfig.ShippedDefaults()); // default AnimatedRate = 0.08
|
||||
|
||||
const int packs = 1_000; // 8000 slots
|
||||
int foilCount = 0;
|
||||
@@ -211,9 +226,9 @@ public class PackOpenServiceTests
|
||||
new Dictionary<long, ShadowverseCardEntry>()); // no foils
|
||||
|
||||
// Force the animated roll to always hit by setting AnimatedRate = 1.0.
|
||||
var root = new GameConfigRoot();
|
||||
root.PackRates.AnimatedRate = 1.0;
|
||||
var svc = new PackOpenService(root);
|
||||
var rates = PackRateConfig.ShippedDefaults();
|
||||
rates.AnimatedRate = 1.0;
|
||||
var svc = MakeService(rates);
|
||||
|
||||
var r = svc.Draw(StandardPack(), pools, 1, Array.Empty<long>(), new SystemRandom(seed: 1));
|
||||
foreach (var c in r.Cards)
|
||||
@@ -232,9 +247,9 @@ public class PackOpenServiceTests
|
||||
new List<ShadowverseCardEntry> { leg },
|
||||
new Dictionary<long, ShadowverseCardEntry> { [leg.Id] = legFoil });
|
||||
|
||||
var root = new GameConfigRoot();
|
||||
root.PackRates.AnimatedRate = 1.0; // every slot upgrades
|
||||
var svc = new PackOpenService(root);
|
||||
var rates = PackRateConfig.ShippedDefaults();
|
||||
rates.AnimatedRate = 1.0; // every slot upgrades
|
||||
var svc = MakeService(rates);
|
||||
|
||||
var specialPack = new PackConfigEntry { Id = 92001, BasePackId = 90001, PackCategory = PackCategory.SpecialCardPack };
|
||||
var r = svc.Draw(specialPack, pools, 1, Array.Empty<long>(), new SystemRandom(seed: 3));
|
||||
@@ -243,4 +258,22 @@ public class PackOpenServiceTests
|
||||
Assert.That(r.Cards[7].CardId, Is.EqualTo(legFoil.Id),
|
||||
"legendary-special slot 8 must be the foil-legendary when animated rate is forced to 1.0");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Regression guard for the 2026-05-24 slot-8-ignores-PerSlot-override bug. Invariant: a
|
||||
/// freshly-constructed <see cref="PackRateConfig"/> has an EMPTY PerSlot list. The original
|
||||
/// trigger (EF Core 8's <c>OwnsMany</c>+<c>ToJson</c> jsonb materialisation appending rows
|
||||
/// onto whatever the parent's ctor produced — leaving two slot-8 entries where the seeded one
|
||||
/// silently won <see cref="PackOpenService.ResolveWeights"/>'s <c>FirstOrDefault</c>) is gone
|
||||
/// now (config goes through <c>IGameConfigService</c> + STJ, which replaces correctly). The
|
||||
/// invariant stays because any future config layer that hydrates into a pre-initialised
|
||||
/// collection (custom deserialiser, ORM, manual Add loop) would resurrect the same failure
|
||||
/// mode. Defaults for collections live in <see cref="PackRateConfig.ShippedDefaults"/>.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void PackRateConfig_PerSlot_defaults_to_empty_to_avoid_jsonb_append_bug()
|
||||
{
|
||||
Assert.That(new PackRateConfig().PerSlot, Is.Empty,
|
||||
"PackRateConfig.PerSlot must default to empty — see test docstring for why.");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user