From a5e4f35c3256a3feb1f8bc2b682ac4179bff0ab3 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Tue, 26 May 2026 14:31:25 -0400 Subject: [PATCH] refactor(bootstrap): migrate mypage-index globals to seed files --- .../mypage-index-2026-05-23.json | 229 ------------------ SVSim.Bootstrap/Data/seeds/banners.json | 38 +++ SVSim.Bootstrap/Data/seeds/colosseum.json | 20 ++ .../seeds/master-point-ranking-periods.json | 9 + SVSim.Bootstrap/Data/seeds/sealed-season.json | 21 ++ .../Data/seeds/special-deck-formats.json | 7 + SVSim.Bootstrap/Importers/GlobalsImporter.cs | 139 ----------- .../Importers/MyPageGlobalsImporter.cs | 185 ++++++++++++++ SVSim.Bootstrap/Models/Seed/BannerSeed.cs | 15 ++ SVSim.Bootstrap/Models/Seed/ColosseumSeed.cs | 24 ++ .../Seed/MasterPointRankingPeriodSeed.cs | 12 + .../Models/Seed/SealedSeasonSeed.cs | 19 ++ .../Models/Seed/SpecialDeckFormatSeed.cs | 10 + SVSim.Bootstrap/Program.cs | 7 + .../Importers/MyPageGlobalsImporterTests.cs | 187 ++++++++++++++ .../Infrastructure/SVSimTestFactory.cs | 7 + 16 files changed, 561 insertions(+), 368 deletions(-) delete mode 100644 SVSim.Bootstrap/Data/prod-captures/mypage-index-2026-05-23.json create mode 100644 SVSim.Bootstrap/Data/seeds/banners.json create mode 100644 SVSim.Bootstrap/Data/seeds/colosseum.json create mode 100644 SVSim.Bootstrap/Data/seeds/master-point-ranking-periods.json create mode 100644 SVSim.Bootstrap/Data/seeds/sealed-season.json create mode 100644 SVSim.Bootstrap/Data/seeds/special-deck-formats.json create mode 100644 SVSim.Bootstrap/Importers/MyPageGlobalsImporter.cs create mode 100644 SVSim.Bootstrap/Models/Seed/BannerSeed.cs create mode 100644 SVSim.Bootstrap/Models/Seed/ColosseumSeed.cs create mode 100644 SVSim.Bootstrap/Models/Seed/MasterPointRankingPeriodSeed.cs create mode 100644 SVSim.Bootstrap/Models/Seed/SealedSeasonSeed.cs create mode 100644 SVSim.Bootstrap/Models/Seed/SpecialDeckFormatSeed.cs create mode 100644 SVSim.UnitTests/Importers/MyPageGlobalsImporterTests.cs diff --git a/SVSim.Bootstrap/Data/prod-captures/mypage-index-2026-05-23.json b/SVSim.Bootstrap/Data/prod-captures/mypage-index-2026-05-23.json deleted file mode 100644 index 3cd0ada..0000000 --- a/SVSim.Bootstrap/Data/prod-captures/mypage-index-2026-05-23.json +++ /dev/null @@ -1,229 +0,0 @@ -{ - "data_headers": { - "short_udid": 411054851, - "viewer_id": 906243102, - "sid": "", - "servertime": 1779553959, - "result_code": 1 - }, - "data": { - "user_info": { - "device_type": "2", - "name": "combusty7", - "country_code": "KOR", - "max_friend": "20", - "last_play_time": "2026-05-23 16:32:39", - "is_received_two_pick_mission": "1", - "birth": "19600101", - "selected_emblem_id": "701441011", - "selected_degree_id": "300003", - "mission_change_time": "2017-09-17 14:47:13", - "mission_receive_type": "0", - "is_official": "0", - "is_official_mark_displayed": "0" - }, - "sealed_info": { - "enable": 1, - "crystal_cost": 600, - "rupy_cost": 600, - "ticket_cost": 4, - "is_join": false, - "pack_info": [ - 10032, - 10032, - 10031, - 10030, - 10029 - ], - "deck_using_num_min": 30, - "schedule_id": 21, - "is_deck_code_maintenance": false, - "sales_period_info": { - "sales_period_series": 33 - } - }, - "colosseum_info": { - "colosseum_id": "165", - "is_display_tips": "0", - "tips_id": "0", - "card_pool_name": "Take Two (Dragonblade–Rivenbrandt)", - "is_colosseum_period": true, - "is_round_period": true, - "deck_format": "3", - "is_normal_two_pick": "1", - "is_special_mode": "10", - "is_all_card_enabled": 0, - "start_time": "2026-05-21 06:00:00", - "colosseum_name": "Rivenbrandt Take Two Cup", - "now_round": "1", - "end_time": "2026-05-25 19:59:59", - "sales_period_info": { - "sales_period_time": "2026-05-25 19:59:59" - } - }, - "is_available_colosseum_free_entry": true, - "arena_info": [ - { - "mode": 1, - "enable": 1, - "cost": 150, - "rupy_cost": 150, - "ticket_cost": 1, - "is_join": false, - "format_info": { - "two_pick_type": "1", - "card_pool_name": "Take Two (Dragonblade–Rivenbrandt)", - "announce_id": 0, - "last_card_pack_set_id": "10029", - "start_time": "2026-05-01 02:00:00", - "end_time": "2026-06-01 01:59:59" - }, - "sales_period_info": { - "sales_period_time": "2026-06-01 01:59:59" - } - } - ], - "is_arena_challenge_period": true, - "is_hidden_boss_appeared": false, - "competition_info": { - "is_competition_period": false - }, - "treasure_info": null, - "unread_present_count": 0, - "unreceived_mission_reward_count": 0, - "lottery_period_info": null, - "master_point_ranking_period": { - "id": "119", - "period_num": "118", - "necessary_score": "0", - "begin_time": "2026-05-01 02:00:00", - "end_time": "2026-06-01 01:59:59" - }, - "last_announce_id": "3353", - "last_announce_update_time": "2026-05-15 10:22:11", - "unfinished_battle_exists": false, - "is_joined_room": false, - "receive_friend_apply_count": 0, - "feature_maintenance_list": [], - "can_give_daily_login_bonus": false, - "friend_battle_invite_count": 0, - "user_config": { - "receive_invitation": "1", - "receive_invitation_in_battle": "1", - "receive_invitation_in_offline": "1", - "receive_friend_apply": "1", - "is_allow_send_adjust": "1", - "is_foil_preferred": "0", - "is_prize_preferred": "0" - }, - "banner": [ - { - "image_name": "banner_000788", - "click": "account_transition_with_two", - "status": "10", - "change_time": "10", - "remaining_time": "0", - "image_paths": [] - }, - { - "image_name": "banner_000906", - "click": "colosseum", - "status": "", - "change_time": "10", - "remaining_time": "0", - "image_paths": [] - }, - { - "image_name": "banner_000220", - "click": "deck_intro_rotation", - "status": "17", - "change_time": "10", - "remaining_time": "0", - "image_paths": [] - }, - { - "image_name": "banner_000840", - "click": "mission", - "status": "2", - "change_time": "10", - "remaining_time": "0", - "image_paths": [] - } - ], - "sub_banner": null, - "sub_banner_list": [], - "user_mypage_info": { - "user_mypage_setting": { - "mypage_id": "0", - "select_type": "0", - "mypage_id_list": [] - } - }, - "user_offline_event": [], - "convention": { - "is_join_tournament": false, - "recent_start_date": null, - "is_admin_watch_user": false - }, - "special_crystal_info": [], - "room_type_in_session": { - "special_deck_format_list": [ - { - "deck_format": "5", - "end_time": "2030-06-26 19:59:59" - } - ] - }, - "guild_notification": { - "guild_id": null, - "guild_room_message_id": null, - "is_join_request": false, - "is_invited": false - }, - "shop_notification": { - "card_pack": { - "is_open_free_gacha_campaign": false, - "can_free_gacha": false - }, - "build_deck": [], - "sleeve": [], - "leader_skin": [] - }, - "pre_release_status": 0, - "gathering_info": { - "has_invite": 0, - "is_entry": 0 - }, - "quest": { - "is_open": false, - "is_display_badge": false, - "is_daily_first_access": false, - "end_time": "", - "name": "" - }, - "basic_puzzle": { - "is_display_badge": true - }, - "all_card_enabled_period": null, - "user_item_list": [ - { - "item_id": "1", - "number": "19" - }, - { - "item_id": "10011", - "number": "1" - }, - { - "item_id": "80001", - "number": "1" - } - ], - "is_battle_pass_period": true, - "story_notification": { - "is_display_ribbon": false, - "is_display_badge": false - }, - "home_dialog_list": [] - } -} \ No newline at end of file diff --git a/SVSim.Bootstrap/Data/seeds/banners.json b/SVSim.Bootstrap/Data/seeds/banners.json new file mode 100644 index 0000000..d8d5e07 --- /dev/null +++ b/SVSim.Bootstrap/Data/seeds/banners.json @@ -0,0 +1,38 @@ +[ + { + "id": 1, + "image_name": "banner_000788", + "click": "account_transition_with_two", + "status": "10", + "change_time": 10, + "remaining_time": 0, + "image_paths": [] + }, + { + "id": 2, + "image_name": "banner_000906", + "click": "colosseum", + "status": "", + "change_time": 10, + "remaining_time": 0, + "image_paths": [] + }, + { + "id": 3, + "image_name": "banner_000220", + "click": "deck_intro_rotation", + "status": "17", + "change_time": 10, + "remaining_time": 0, + "image_paths": [] + }, + { + "id": 4, + "image_name": "banner_000840", + "click": "mission", + "status": "2", + "change_time": 10, + "remaining_time": 0, + "image_paths": [] + } +] diff --git a/SVSim.Bootstrap/Data/seeds/colosseum.json b/SVSim.Bootstrap/Data/seeds/colosseum.json new file mode 100644 index 0000000..fecad57 --- /dev/null +++ b/SVSim.Bootstrap/Data/seeds/colosseum.json @@ -0,0 +1,20 @@ +{ + "id": 1, + "colosseum_id": "165", + "colosseum_name": "Rivenbrandt Take Two Cup", + "card_pool_name": "Take Two (Dragonblade–Rivenbrandt)", + "deck_format": "3", + "start_time": "2026-05-21 06:00:00", + "end_time": "2026-05-25 19:59:59", + "now_round": "1", + "is_display_tips": "0", + "tips_id": "0", + "is_colosseum_period": true, + "is_round_period": true, + "is_normal_two_pick": "1", + "is_special_mode": "10", + "is_all_card_enabled": 0, + "sales_period_info": { + "sales_period_time": "2026-05-25 19:59:59" + } +} diff --git a/SVSim.Bootstrap/Data/seeds/master-point-ranking-periods.json b/SVSim.Bootstrap/Data/seeds/master-point-ranking-periods.json new file mode 100644 index 0000000..863eb41 --- /dev/null +++ b/SVSim.Bootstrap/Data/seeds/master-point-ranking-periods.json @@ -0,0 +1,9 @@ +[ + { + "id": 119, + "period_num": 118, + "necessary_score": 0, + "begin_time": "2026-05-01 02:00:00", + "end_time": "2026-06-01 01:59:59" + } +] diff --git a/SVSim.Bootstrap/Data/seeds/sealed-season.json b/SVSim.Bootstrap/Data/seeds/sealed-season.json new file mode 100644 index 0000000..fda3f24 --- /dev/null +++ b/SVSim.Bootstrap/Data/seeds/sealed-season.json @@ -0,0 +1,21 @@ +{ + "id": 1, + "enable": 1, + "crystal_cost": 600, + "rupy_cost": 600, + "ticket_cost": 4, + "deck_using_num_min": 30, + "schedule_id": 21, + "is_join": false, + "is_deck_code_maintenance": false, + "pack_info": [ + 10032, + 10032, + 10031, + 10030, + 10029 + ], + "sales_period_info": { + "sales_period_series": 33 + } +} diff --git a/SVSim.Bootstrap/Data/seeds/special-deck-formats.json b/SVSim.Bootstrap/Data/seeds/special-deck-formats.json new file mode 100644 index 0000000..a39a6a2 --- /dev/null +++ b/SVSim.Bootstrap/Data/seeds/special-deck-formats.json @@ -0,0 +1,7 @@ +[ + { + "id": 1, + "deck_format": "5", + "end_time": "2030-06-26 19:59:59" + } +] diff --git a/SVSim.Bootstrap/Importers/GlobalsImporter.cs b/SVSim.Bootstrap/Importers/GlobalsImporter.cs index ebaed5e..c8e74d6 100644 --- a/SVSim.Bootstrap/Importers/GlobalsImporter.cs +++ b/SVSim.Bootstrap/Importers/GlobalsImporter.cs @@ -27,7 +27,6 @@ public class GlobalsImporter Console.WriteLine($"[GlobalsImporter] Loading captures from {capturesDir}..."); JsonElement? loadIndex = LoadCapture(capturesDir, "load-index"); - JsonElement? mypageIndex = LoadCapture(capturesDir, "mypage-index"); JsonElement? deckInfo = LoadCapture(capturesDir, "deck-info"); JsonElement? packInfo = LoadCapture(capturesDir, "pack-info"); @@ -51,15 +50,6 @@ public class GlobalsImporter total += await UpdateRotationCardSetFlags(context, loadIndex.Value); } - if (mypageIndex.HasValue) - { - total += await ImportBanners(context, mypageIndex.Value); - total += await ImportColosseum(context, mypageIndex.Value); - total += await ImportSealed(context, mypageIndex.Value); - total += await ImportMasterPointRankingPeriod(context, mypageIndex.Value); - total += await ImportRoomTypeInSession(context, mypageIndex.Value); - } - if (deckInfo.HasValue) { total += await ImportDefaultDecks(context, deckInfo.Value); @@ -521,135 +511,6 @@ public class GlobalsImporter return updated; } - // ---------- Mypage: Banners ---------- - - private async Task ImportBanners(SVSimDbContext context, JsonElement mypage) - { - if (!mypage.TryGetProperty("banner", out var arr) || arr.ValueKind != JsonValueKind.Array) return 0; - - // Banners have no wire ID; we treat the capture as authoritative — clear and rewrite. - var existing = await context.Banners.ToListAsync(); - context.Banners.RemoveRange(existing); - - int created = 0; - int idx = 1; - foreach (var el in arr.EnumerateArray()) - { - context.Banners.Add(new BannerEntry - { - Id = idx++, - ImageName = GetString(el, "image_name"), - Click = GetString(el, "click"), - Status = GetString(el, "status"), - ChangeTime = GetInt(el, "change_time"), - RemainingTime = GetInt(el, "remaining_time"), - ImagePaths = el.TryGetProperty("image_paths", out var ip) ? Serialize(ip) : "[]" - }); - created++; - } - Console.WriteLine($"[GlobalsImporter] Banners: {(existing.Count > 0 ? $"-{existing.Count}/" : "")}+{created}"); - return created; - } - - // ---------- Mypage: Colosseum (singleton) ---------- - - private async Task ImportColosseum(SVSimDbContext context, JsonElement mypage) - { - if (!mypage.TryGetProperty("colosseum_info", out var info) || info.ValueKind != JsonValueKind.Object) return 0; - - var existing = await context.Colosseums.FirstOrDefaultAsync(e => e.Id == 1); - var entry = existing ?? new ColosseumConfig { Id = 1 }; - entry.ColosseumId = GetString(info, "colosseum_id"); - entry.ColosseumName = GetString(info, "colosseum_name"); - entry.CardPoolName = GetString(info, "card_pool_name"); - entry.DeckFormat = GetString(info, "deck_format"); - entry.StartTime = ParseWireDateTime(GetString(info, "start_time")); - entry.EndTime = ParseWireDateTime(GetString(info, "end_time")); - entry.NowRound = GetString(info, "now_round"); - entry.IsDisplayTips = GetString(info, "is_display_tips"); - entry.TipsId = GetString(info, "tips_id"); - entry.IsColosseumPeriod = GetBool(info, "is_colosseum_period"); - entry.IsRoundPeriod = GetBool(info, "is_round_period"); - entry.IsNormalTwoPick = GetString(info, "is_normal_two_pick"); - entry.IsSpecialMode = GetString(info, "is_special_mode"); - entry.IsAllCardEnabled = GetInt(info, "is_all_card_enabled"); - entry.SalesPeriodInfo = info.TryGetProperty("sales_period_info", out var sp) ? Serialize(sp) : "{}"; - if (existing is null) context.Colosseums.Add(entry); - Console.WriteLine($"[GlobalsImporter] Colosseum: {(existing is null ? "+1" : "~1")}"); - return 1; - } - - // ---------- Mypage: Sealed (singleton) ---------- - - private async Task ImportSealed(SVSimDbContext context, JsonElement mypage) - { - if (!mypage.TryGetProperty("sealed_info", out var info) || info.ValueKind != JsonValueKind.Object) return 0; - - var existing = await context.SealedSeasons.FirstOrDefaultAsync(e => e.Id == 1); - var entry = existing ?? new SealedConfig { Id = 1 }; - entry.Enable = GetInt(info, "enable"); - entry.CrystalCost = GetInt(info, "crystal_cost"); - entry.RupyCost = GetInt(info, "rupy_cost"); - entry.TicketCost = GetInt(info, "ticket_cost"); - entry.DeckUsingNumMin = GetInt(info, "deck_using_num_min"); - entry.ScheduleId = GetInt(info, "schedule_id"); - entry.IsJoin = GetBool(info, "is_join"); - entry.IsDeckCodeMaintenance = GetBool(info, "is_deck_code_maintenance"); - entry.PackInfo = info.TryGetProperty("pack_info", out var pi) ? Serialize(pi) : "[]"; - entry.SalesPeriodInfo = info.TryGetProperty("sales_period_info", out var sp) ? Serialize(sp) : "{}"; - if (existing is null) context.SealedSeasons.Add(entry); - Console.WriteLine($"[GlobalsImporter] Sealed: {(existing is null ? "+1" : "~1")}"); - return 1; - } - - // ---------- Mypage: Master Point Ranking Period ---------- - - private async Task ImportMasterPointRankingPeriod(SVSimDbContext context, JsonElement mypage) - { - if (!mypage.TryGetProperty("master_point_ranking_period", out var info) || info.ValueKind != JsonValueKind.Object) return 0; - - int id = GetInt(info, "id"); - if (id == 0) return 0; - - var existing = await context.MasterPointRankingPeriods.FirstOrDefaultAsync(e => e.Id == id); - var entry = existing ?? new MasterPointRankingPeriodEntry { Id = id }; - entry.PeriodNum = GetInt(info, "period_num"); - entry.NecessaryScore = GetLong(info, "necessary_score"); - entry.BeginTime = ParseWireDateTime(GetString(info, "begin_time")); - entry.EndTime = ParseWireDateTime(GetString(info, "end_time")); - if (existing is null) context.MasterPointRankingPeriods.Add(entry); - Console.WriteLine($"[GlobalsImporter] MasterPointRankingPeriod (id={id}): {(existing is null ? "+1" : "~1")}"); - return 1; - } - - // ---------- Mypage: Room Type In Session (special deck formats) ---------- - - private async Task ImportRoomTypeInSession(SVSimDbContext context, JsonElement mypage) - { - if (!mypage.TryGetProperty("room_type_in_session", out var rt) || rt.ValueKind != JsonValueKind.Object) return 0; - if (!rt.TryGetProperty("special_deck_format_list", out var arr) || arr.ValueKind != JsonValueKind.Array) return 0; - - // Same shape semantics as Banners — the wire has no stable id, treat the capture as - // authoritative and clear-and-rewrite with a synthetic ordinal. - var existing = await context.SpecialDeckFormats.ToListAsync(); - context.SpecialDeckFormats.RemoveRange(existing); - - int created = 0; - int idx = 1; - foreach (var el in arr.EnumerateArray()) - { - context.SpecialDeckFormats.Add(new SpecialDeckFormatEntry - { - Id = idx++, - DeckFormat = GetString(el, "deck_format"), - EndTime = ParseWireDateTime(GetString(el, "end_time")) - }); - created++; - } - Console.WriteLine($"[GlobalsImporter] SpecialDeckFormats: {(existing.Count > 0 ? $"-{existing.Count}/" : "")}+{created}"); - return created; - } - // ---------- Deck/info: Default Decks ---------- private async Task ImportDefaultDecks(SVSimDbContext context, JsonElement deckInfo) diff --git a/SVSim.Bootstrap/Importers/MyPageGlobalsImporter.cs b/SVSim.Bootstrap/Importers/MyPageGlobalsImporter.cs new file mode 100644 index 0000000..d18386e --- /dev/null +++ b/SVSim.Bootstrap/Importers/MyPageGlobalsImporter.cs @@ -0,0 +1,185 @@ +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using SVSim.Bootstrap.Models.Seed; +using SVSim.Database; +using SVSim.Database.Models; + +namespace SVSim.Bootstrap.Importers; + +/// +/// Idempotent upsert of /mypage/index-derived globals from per-table seed files. +/// Banners and SpecialDeckFormats use CLEAR-AND-REWRITE semantics (no stable wire ID, capture is authoritative). +/// Colosseum and SealedSeason are singletons (Id=1). MasterPointRankingPeriod upserts by wire id. +/// +public class MyPageGlobalsImporter +{ + public async Task ImportBannersAsync(SVSimDbContext context, string seedDir) + { + var seed = SeedLoader.LoadList(Path.Combine(seedDir, "banners.json")); + if (seed.Count == 0) + { + Console.WriteLine("[MyPageGlobalsImporter] No banner seed rows; skipping."); + return 0; + } + + // Clear-and-rewrite: banners have no stable wire ID, the capture is authoritative. + var existing = await context.Banners.ToListAsync(); + context.Banners.RemoveRange(existing); + + foreach (var s in seed) + { + context.Banners.Add(new BannerEntry + { + Id = s.Id, + ImageName = s.ImageName, + Click = s.Click, + Status = s.Status, + ChangeTime = s.ChangeTime, + RemainingTime = s.RemainingTime, + ImagePaths = s.ImagePaths.ValueKind == JsonValueKind.Undefined + ? "[]" + : JsonSerializer.Serialize(s.ImagePaths), + }); + } + await context.SaveChangesAsync(); + Console.WriteLine($"[MyPageGlobalsImporter] Banners: -{existing.Count}/+{seed.Count}"); + return seed.Count; + } + + public async Task ImportColosseumAsync(SVSimDbContext context, string seedDir) + { + var s = SeedLoader.LoadObject(Path.Combine(seedDir, "colosseum.json")); + if (s is null) + { + Console.WriteLine("[MyPageGlobalsImporter] No colosseum seed; skipping."); + return 0; + } + + var existing = await context.Colosseums.FirstOrDefaultAsync(e => e.Id == 1); + var entry = existing ?? new ColosseumConfig { Id = 1 }; + + entry.ColosseumId = s.ColosseumId; + entry.ColosseumName = s.ColosseumName; + entry.CardPoolName = s.CardPoolName; + entry.DeckFormat = s.DeckFormat; + entry.StartTime = ImporterBase.ParseWireDateTime(s.StartTime); + entry.EndTime = ImporterBase.ParseWireDateTime(s.EndTime); + entry.NowRound = s.NowRound; + entry.IsDisplayTips = s.IsDisplayTips; + entry.TipsId = s.TipsId; + entry.IsColosseumPeriod = s.IsColosseumPeriod; + entry.IsRoundPeriod = s.IsRoundPeriod; + entry.IsNormalTwoPick = s.IsNormalTwoPick; + entry.IsSpecialMode = s.IsSpecialMode; + entry.IsAllCardEnabled = s.IsAllCardEnabled; + entry.SalesPeriodInfo = s.SalesPeriodInfo.ValueKind == JsonValueKind.Undefined + ? "{}" + : JsonSerializer.Serialize(s.SalesPeriodInfo); + + if (existing is null) context.Colosseums.Add(entry); + await context.SaveChangesAsync(); + Console.WriteLine($"[MyPageGlobalsImporter] Colosseum: {(existing is null ? "+1" : "~1")}"); + return 1; + } + + public async Task ImportSealedAsync(SVSimDbContext context, string seedDir) + { + var s = SeedLoader.LoadObject(Path.Combine(seedDir, "sealed-season.json")); + if (s is null) + { + Console.WriteLine("[MyPageGlobalsImporter] No sealed-season seed; skipping."); + return 0; + } + + var existing = await context.SealedSeasons.FirstOrDefaultAsync(e => e.Id == 1); + var entry = existing ?? new SealedConfig { Id = 1 }; + + entry.Enable = s.Enable; + entry.CrystalCost = s.CrystalCost; + entry.RupyCost = s.RupyCost; + entry.TicketCost = s.TicketCost; + entry.DeckUsingNumMin = s.DeckUsingNumMin; + entry.ScheduleId = s.ScheduleId; + entry.IsJoin = s.IsJoin; + entry.IsDeckCodeMaintenance = s.IsDeckCodeMaintenance; + entry.PackInfo = s.PackInfo.ValueKind == JsonValueKind.Undefined + ? "[]" + : JsonSerializer.Serialize(s.PackInfo); + entry.SalesPeriodInfo = s.SalesPeriodInfo.ValueKind == JsonValueKind.Undefined + ? "{}" + : JsonSerializer.Serialize(s.SalesPeriodInfo); + + if (existing is null) context.SealedSeasons.Add(entry); + await context.SaveChangesAsync(); + Console.WriteLine($"[MyPageGlobalsImporter] SealedSeason: {(existing is null ? "+1" : "~1")}"); + return 1; + } + + public async Task ImportMasterPointRankingPeriodAsync(SVSimDbContext context, string seedDir) + { + var seed = SeedLoader.LoadList( + Path.Combine(seedDir, "master-point-ranking-periods.json")); + if (seed.Count == 0) + { + Console.WriteLine("[MyPageGlobalsImporter] No master-point-ranking-period seed rows; skipping."); + return 0; + } + + var existing = await context.MasterPointRankingPeriods.ToDictionaryAsync(e => e.Id); + int created = 0, updated = 0; + + foreach (var s in seed) + { + if (s.Id == 0) continue; + + var entry = existing.TryGetValue(s.Id, out var ex) + ? ex : new MasterPointRankingPeriodEntry { Id = s.Id }; + + entry.PeriodNum = s.PeriodNum; + entry.NecessaryScore = s.NecessaryScore; + entry.BeginTime = ImporterBase.ParseWireDateTime(s.BeginTime); + entry.EndTime = ImporterBase.ParseWireDateTime(s.EndTime); + + if (ex is null) + { + context.MasterPointRankingPeriods.Add(entry); + existing[s.Id] = entry; + created++; + } + else updated++; + } + + await context.SaveChangesAsync(); + Console.WriteLine($"[MyPageGlobalsImporter] MasterPointRankingPeriod: +{created}/~{updated}"); + return created + updated; + } + + public async Task ImportSpecialDeckFormatsAsync(SVSimDbContext context, string seedDir) + { + var seed = SeedLoader.LoadList( + Path.Combine(seedDir, "special-deck-formats.json")); + if (seed.Count == 0) + { + Console.WriteLine("[MyPageGlobalsImporter] No special-deck-format seed rows; skipping."); + return 0; + } + + // Clear-and-rewrite: same semantics as banners — no stable wire ID, capture is authoritative. + var existing = await context.SpecialDeckFormats.ToListAsync(); + context.SpecialDeckFormats.RemoveRange(existing); + + foreach (var s in seed) + { + context.SpecialDeckFormats.Add(new SpecialDeckFormatEntry + { + Id = s.Id, + DeckFormat = s.DeckFormat, + EndTime = ImporterBase.ParseWireDateTime(s.EndTime), + }); + } + + await context.SaveChangesAsync(); + Console.WriteLine($"[MyPageGlobalsImporter] SpecialDeckFormats: -{existing.Count}/+{seed.Count}"); + return seed.Count; + } +} diff --git a/SVSim.Bootstrap/Models/Seed/BannerSeed.cs b/SVSim.Bootstrap/Models/Seed/BannerSeed.cs new file mode 100644 index 0000000..203b9df --- /dev/null +++ b/SVSim.Bootstrap/Models/Seed/BannerSeed.cs @@ -0,0 +1,15 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SVSim.Bootstrap.Models.Seed; + +public sealed class BannerSeed +{ + [JsonPropertyName("id")] public int Id { get; set; } + [JsonPropertyName("image_name")] public string ImageName { get; set; } = ""; + [JsonPropertyName("click")] public string Click { get; set; } = ""; + [JsonPropertyName("status")] public string Status { get; set; } = ""; + [JsonPropertyName("change_time")] public int ChangeTime { get; set; } + [JsonPropertyName("remaining_time")] public int RemainingTime { get; set; } + [JsonPropertyName("image_paths")] public JsonElement ImagePaths { get; set; } +} diff --git a/SVSim.Bootstrap/Models/Seed/ColosseumSeed.cs b/SVSim.Bootstrap/Models/Seed/ColosseumSeed.cs new file mode 100644 index 0000000..325da6f --- /dev/null +++ b/SVSim.Bootstrap/Models/Seed/ColosseumSeed.cs @@ -0,0 +1,24 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SVSim.Bootstrap.Models.Seed; + +public sealed class ColosseumSeed +{ + [JsonPropertyName("id")] public int Id { get; set; } = 1; + [JsonPropertyName("colosseum_id")] public string ColosseumId { get; set; } = ""; + [JsonPropertyName("colosseum_name")] public string ColosseumName { get; set; } = ""; + [JsonPropertyName("card_pool_name")] public string CardPoolName { get; set; } = ""; + [JsonPropertyName("deck_format")] public string DeckFormat { get; set; } = ""; + [JsonPropertyName("start_time")] public string StartTime { get; set; } = ""; + [JsonPropertyName("end_time")] public string EndTime { get; set; } = ""; + [JsonPropertyName("now_round")] public string NowRound { get; set; } = ""; + [JsonPropertyName("is_display_tips")] public string IsDisplayTips { get; set; } = ""; + [JsonPropertyName("tips_id")] public string TipsId { get; set; } = ""; + [JsonPropertyName("is_colosseum_period")] public bool IsColosseumPeriod { get; set; } + [JsonPropertyName("is_round_period")] public bool IsRoundPeriod { get; set; } + [JsonPropertyName("is_normal_two_pick")] public string IsNormalTwoPick { get; set; } = ""; + [JsonPropertyName("is_special_mode")] public string IsSpecialMode { get; set; } = ""; + [JsonPropertyName("is_all_card_enabled")] public int IsAllCardEnabled { get; set; } + [JsonPropertyName("sales_period_info")] public JsonElement SalesPeriodInfo { get; set; } +} diff --git a/SVSim.Bootstrap/Models/Seed/MasterPointRankingPeriodSeed.cs b/SVSim.Bootstrap/Models/Seed/MasterPointRankingPeriodSeed.cs new file mode 100644 index 0000000..bfa008c --- /dev/null +++ b/SVSim.Bootstrap/Models/Seed/MasterPointRankingPeriodSeed.cs @@ -0,0 +1,12 @@ +using System.Text.Json.Serialization; + +namespace SVSim.Bootstrap.Models.Seed; + +public sealed class MasterPointRankingPeriodSeed +{ + [JsonPropertyName("id")] public int Id { get; set; } + [JsonPropertyName("period_num")] public int PeriodNum { get; set; } + [JsonPropertyName("necessary_score")] public long NecessaryScore { get; set; } + [JsonPropertyName("begin_time")] public string BeginTime { get; set; } = ""; + [JsonPropertyName("end_time")] public string EndTime { get; set; } = ""; +} diff --git a/SVSim.Bootstrap/Models/Seed/SealedSeasonSeed.cs b/SVSim.Bootstrap/Models/Seed/SealedSeasonSeed.cs new file mode 100644 index 0000000..78ec506 --- /dev/null +++ b/SVSim.Bootstrap/Models/Seed/SealedSeasonSeed.cs @@ -0,0 +1,19 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SVSim.Bootstrap.Models.Seed; + +public sealed class SealedSeasonSeed +{ + [JsonPropertyName("id")] public int Id { get; set; } = 1; + [JsonPropertyName("enable")] public int Enable { get; set; } + [JsonPropertyName("crystal_cost")] public int CrystalCost { get; set; } + [JsonPropertyName("rupy_cost")] public int RupyCost { get; set; } + [JsonPropertyName("ticket_cost")] public int TicketCost { get; set; } + [JsonPropertyName("deck_using_num_min")] public int DeckUsingNumMin { get; set; } + [JsonPropertyName("schedule_id")] public int ScheduleId { get; set; } + [JsonPropertyName("is_join")] public bool IsJoin { get; set; } + [JsonPropertyName("is_deck_code_maintenance")] public bool IsDeckCodeMaintenance { get; set; } + [JsonPropertyName("pack_info")] public JsonElement PackInfo { get; set; } + [JsonPropertyName("sales_period_info")] public JsonElement SalesPeriodInfo { get; set; } +} diff --git a/SVSim.Bootstrap/Models/Seed/SpecialDeckFormatSeed.cs b/SVSim.Bootstrap/Models/Seed/SpecialDeckFormatSeed.cs new file mode 100644 index 0000000..1e04f20 --- /dev/null +++ b/SVSim.Bootstrap/Models/Seed/SpecialDeckFormatSeed.cs @@ -0,0 +1,10 @@ +using System.Text.Json.Serialization; + +namespace SVSim.Bootstrap.Models.Seed; + +public sealed class SpecialDeckFormatSeed +{ + [JsonPropertyName("id")] public int Id { get; set; } + [JsonPropertyName("deck_format")] public string DeckFormat { get; set; } = ""; + [JsonPropertyName("end_time")] public string EndTime { get; set; } = ""; +} diff --git a/SVSim.Bootstrap/Program.cs b/SVSim.Bootstrap/Program.cs index 51ec14f..a307ef8 100644 --- a/SVSim.Bootstrap/Program.cs +++ b/SVSim.Bootstrap/Program.cs @@ -83,6 +83,13 @@ public static class Program await puzzleImporter.ImportPuzzlesAsync(context, opts.SeedDir); await puzzleImporter.ImportMissionsAsync(context, opts.SeedDir); + var mypage = new MyPageGlobalsImporter(); + await mypage.ImportBannersAsync(context, opts.SeedDir); + await mypage.ImportColosseumAsync(context, opts.SeedDir); + await mypage.ImportSealedAsync(context, opts.SeedDir); + await mypage.ImportMasterPointRankingPeriodAsync(context, opts.SeedDir); + await mypage.ImportSpecialDeckFormatsAsync(context, opts.SeedDir); + // BuildDeck pipeline: series CSV → catalog JSON → package CSV. Catalog must run after // series CSV (FK on products → series) and before package CSV (so the catalog-side // enriched rows take precedence over stub creation). diff --git a/SVSim.UnitTests/Importers/MyPageGlobalsImporterTests.cs b/SVSim.UnitTests/Importers/MyPageGlobalsImporterTests.cs new file mode 100644 index 0000000..a163b4a --- /dev/null +++ b/SVSim.UnitTests/Importers/MyPageGlobalsImporterTests.cs @@ -0,0 +1,187 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using SVSim.Bootstrap.Importers; +using SVSim.Database; +using SVSim.Database.Models; +using SVSim.UnitTests.Infrastructure; + +namespace SVSim.UnitTests.Importers; + +public class MyPageGlobalsImporterTests +{ + private static string SeedDir => Path.Combine(AppContext.BaseDirectory, "Data", "seeds"); + + [Test] + public async Task Imports_banners_from_seed_file() + { + using var factory = new SVSimTestFactory(); + using var scope = factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var importer = new MyPageGlobalsImporter(); + await importer.ImportBannersAsync(db, SeedDir); + + int count = await db.Banners.CountAsync(); + Assert.That(count, Is.GreaterThan(0), "seed must contain banners"); + } + + [Test] + public async Task Imports_colosseum_singleton() + { + using var factory = new SVSimTestFactory(); + using var scope = factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var importer = new MyPageGlobalsImporter(); + await importer.ImportColosseumAsync(db, SeedDir); + + var row = await db.Colosseums.FirstOrDefaultAsync(e => e.Id == 1); + Assert.That(row, Is.Not.Null); + Assert.That(row!.Id, Is.EqualTo(1)); + Assert.That(row.ColosseumId, Is.Not.Empty); + } + + [Test] + public async Task Imports_sealed_singleton() + { + using var factory = new SVSimTestFactory(); + using var scope = factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var importer = new MyPageGlobalsImporter(); + await importer.ImportSealedAsync(db, SeedDir); + + var row = await db.SealedSeasons.FirstOrDefaultAsync(e => e.Id == 1); + Assert.That(row, Is.Not.Null); + Assert.That(row!.Id, Is.EqualTo(1)); + // pack_info is a JSON array column, must be non-empty in the captured seed. + Assert.That(row.PackInfo, Does.StartWith("[")); + } + + [Test] + public async Task Imports_master_point_ranking_period() + { + using var factory = new SVSimTestFactory(); + using var scope = factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var importer = new MyPageGlobalsImporter(); + await importer.ImportMasterPointRankingPeriodAsync(db, SeedDir); + + int count = await db.MasterPointRankingPeriods.CountAsync(); + Assert.That(count, Is.GreaterThan(0), "seed must contain at least one ranking period"); + } + + [Test] + public async Task Imports_special_deck_formats() + { + using var factory = new SVSimTestFactory(); + using var scope = factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var importer = new MyPageGlobalsImporter(); + await importer.ImportSpecialDeckFormatsAsync(db, SeedDir); + + int count = await db.SpecialDeckFormats.CountAsync(); + Assert.That(count, Is.GreaterThan(0), "seed must contain special deck formats"); + } + + [Test] + public async Task Banners_are_clear_and_rewrite() + { + using var factory = new SVSimTestFactory(); + using var scope = factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + // Pre-seed a legacy banner with Id outside the seed range — the importer must wipe it. + db.Banners.Add(new BannerEntry + { + Id = 999, + ImageName = "legacy_banner", + Click = "legacy", + Status = "9", + ChangeTime = 0, + RemainingTime = 0, + ImagePaths = "[]", + }); + await db.SaveChangesAsync(); + + var importer = new MyPageGlobalsImporter(); + await importer.ImportBannersAsync(db, SeedDir); + + var stale = await db.Banners.FindAsync(999); + Assert.That(stale, Is.Null, + "ImportBannersAsync must clear-and-rewrite — pre-existing legacy rows must be removed"); + } + + [Test] + public async Task Special_deck_formats_are_clear_and_rewrite() + { + using var factory = new SVSimTestFactory(); + using var scope = factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + db.SpecialDeckFormats.Add(new SpecialDeckFormatEntry + { + Id = 999, + DeckFormat = "99", + EndTime = DateTime.UtcNow, + }); + await db.SaveChangesAsync(); + + var importer = new MyPageGlobalsImporter(); + await importer.ImportSpecialDeckFormatsAsync(db, SeedDir); + + var stale = await db.SpecialDeckFormats.FindAsync(999); + Assert.That(stale, Is.Null, + "ImportSpecialDeckFormatsAsync must clear-and-rewrite — pre-existing legacy rows must be removed"); + } + + [Test] + public async Task Singletons_are_idempotent_on_rerun() + { + using var factory = new SVSimTestFactory(); + using var scope = factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var importer = new MyPageGlobalsImporter(); + await importer.ImportColosseumAsync(db, SeedDir); + await importer.ImportSealedAsync(db, SeedDir); + + await importer.ImportColosseumAsync(db, SeedDir); + await importer.ImportSealedAsync(db, SeedDir); + + Assert.That(await db.Colosseums.CountAsync(), Is.EqualTo(1), + "Colosseum singleton must remain a single row on re-run"); + Assert.That(await db.SealedSeasons.CountAsync(), Is.EqualTo(1), + "SealedSeason singleton must remain a single row on re-run"); + } + + [Test] + public async Task Master_point_ranking_period_leaves_existing_rows_untouched() + { + using var factory = new SVSimTestFactory(); + using var scope = factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + const int legacyId = 88888; + db.MasterPointRankingPeriods.Add(new MasterPointRankingPeriodEntry + { + Id = legacyId, + PeriodNum = 87, + NecessaryScore = 12345, + BeginTime = new DateTime(2020, 1, 1, 0, 0, 0, DateTimeKind.Utc), + EndTime = new DateTime(2020, 2, 1, 0, 0, 0, DateTimeKind.Utc), + }); + await db.SaveChangesAsync(); + + var importer = new MyPageGlobalsImporter(); + await importer.ImportMasterPointRankingPeriodAsync(db, SeedDir); + + var legacy = await db.MasterPointRankingPeriods.FindAsync(legacyId); + Assert.That(legacy, Is.Not.Null, + "MasterPointRankingPeriod upserts by id — legacy rows not in seed must survive"); + Assert.That(legacy!.PeriodNum, Is.EqualTo(87)); + Assert.That(legacy.NecessaryScore, Is.EqualTo(12345)); + } +} diff --git a/SVSim.UnitTests/Infrastructure/SVSimTestFactory.cs b/SVSim.UnitTests/Infrastructure/SVSimTestFactory.cs index e6a895e..18cc2e2 100644 --- a/SVSim.UnitTests/Infrastructure/SVSimTestFactory.cs +++ b/SVSim.UnitTests/Infrastructure/SVSimTestFactory.cs @@ -198,6 +198,13 @@ internal sealed class SVSimTestFactory : WebApplicationFactory await puzzleImporter.ImportGroupsAsync(ctx, seedDir); await puzzleImporter.ImportPuzzlesAsync(ctx, seedDir); await puzzleImporter.ImportMissionsAsync(ctx, seedDir); + + var mypage = new MyPageGlobalsImporter(); + await mypage.ImportBannersAsync(ctx, seedDir); + await mypage.ImportColosseumAsync(ctx, seedDir); + await mypage.ImportSealedAsync(ctx, seedDir); + await mypage.ImportMasterPointRankingPeriodAsync(ctx, seedDir); + await mypage.ImportSpecialDeckFormatsAsync(ctx, seedDir); } /// Convenience: bake the X-Test-Viewer-Id header into a fresh client.