From b6966ece6ec0221041d3d9d4a30eefd5b3a4a511 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Tue, 26 May 2026 09:16:21 -0400 Subject: [PATCH] Prebuilt deck purchasing and fixes --- .../build-deck/build_deck_package_master.csv | 1885 +++++++++++ .../build-deck/build_deck_series_master.csv | 23 + .../build_deck-info-2026-05-26.json | 1 + .../prod-captures/deck-info-2026-05-23.json | 4 +- .../Importers/BuildDeckImporter.cs | 366 +++ SVSim.Bootstrap/Program.cs | 8 + SVSim.Bootstrap/SVSim.Bootstrap.csproj | 3 + .../20260526043148_AddBuildDeck.Designer.cs | 2834 +++++++++++++++++ .../Migrations/20260526043148_AddBuildDeck.cs | 191 ++ .../Migrations/SVSimDbContextModelSnapshot.cs | 248 ++ .../Models/BuildDeckProductCardEntry.cs | 17 + .../Models/BuildDeckProductEntry.cs | 32 + .../Models/BuildDeckProductRewardEntry.cs | 18 + SVSim.Database/Models/BuildDeckSeriesEntry.cs | 22 + .../Models/BuildDeckSeriesRewardEntry.cs | 20 + SVSim.Database/Models/Viewer.cs | 2 + .../Models/ViewerBuildDeckProductPurchase.cs | 14 + .../BuildDeck/BuildDeckRepository.cs | 67 + .../BuildDeck/IBuildDeckRepository.cs | 29 + SVSim.Database/SVSimDbContext.cs | 19 + SVSim.Database/Services/RewardGrantService.cs | 24 +- .../Controllers/BuildDeckController.cs | 329 ++ .../Controllers/DeckController.cs | 25 +- .../Controllers/LeaderSkinController.cs | 64 + .../Requests/BuildDeck/BuildDeckBuyRequest.cs | 21 + .../BuildDeckGetPurchaseCountRequest.cs | 13 + .../BuildDeck/BuildDeckInfoRequest.cs | 17 + .../LeaderSkin/LeaderSkinSetRequest.cs | 33 + .../BuildDeck/BuildDeckBuyResponse.cs | 22 + .../BuildDeckGetPurchaseCountResponse.cs | 16 + .../BuildDeck/BuildDeckInfoResponse.cs | 118 + .../LeaderSkin/LeaderSkinSetResponse.cs | 27 + SVSim.EmulatedEntrypoint/Program.cs | 2 + .../BuildDeckControllerBuyTests.cs | 441 +++ ...uildDeckControllerGetPurchaseCountTests.cs | 61 + .../BuildDeckControllerInfoTests.cs | 144 + .../Controllers/LeaderSkinControllerTests.cs | 126 + .../Importers/BuildDeckImporterTests.cs | 110 + SVSim.UnitTests/SVSim.UnitTests.csproj | 11 + 39 files changed, 7392 insertions(+), 15 deletions(-) create mode 100644 SVSim.Bootstrap/Data/build-deck/build_deck_package_master.csv create mode 100644 SVSim.Bootstrap/Data/build-deck/build_deck_series_master.csv create mode 100644 SVSim.Bootstrap/Data/prod-captures/build_deck-info-2026-05-26.json create mode 100644 SVSim.Bootstrap/Importers/BuildDeckImporter.cs create mode 100644 SVSim.Database/Migrations/20260526043148_AddBuildDeck.Designer.cs create mode 100644 SVSim.Database/Migrations/20260526043148_AddBuildDeck.cs create mode 100644 SVSim.Database/Models/BuildDeckProductCardEntry.cs create mode 100644 SVSim.Database/Models/BuildDeckProductEntry.cs create mode 100644 SVSim.Database/Models/BuildDeckProductRewardEntry.cs create mode 100644 SVSim.Database/Models/BuildDeckSeriesEntry.cs create mode 100644 SVSim.Database/Models/BuildDeckSeriesRewardEntry.cs create mode 100644 SVSim.Database/Models/ViewerBuildDeckProductPurchase.cs create mode 100644 SVSim.Database/Repositories/BuildDeck/BuildDeckRepository.cs create mode 100644 SVSim.Database/Repositories/BuildDeck/IBuildDeckRepository.cs create mode 100644 SVSim.EmulatedEntrypoint/Controllers/BuildDeckController.cs create mode 100644 SVSim.EmulatedEntrypoint/Controllers/LeaderSkinController.cs create mode 100644 SVSim.EmulatedEntrypoint/Models/Dtos/Requests/BuildDeck/BuildDeckBuyRequest.cs create mode 100644 SVSim.EmulatedEntrypoint/Models/Dtos/Requests/BuildDeck/BuildDeckGetPurchaseCountRequest.cs create mode 100644 SVSim.EmulatedEntrypoint/Models/Dtos/Requests/BuildDeck/BuildDeckInfoRequest.cs create mode 100644 SVSim.EmulatedEntrypoint/Models/Dtos/Requests/LeaderSkin/LeaderSkinSetRequest.cs create mode 100644 SVSim.EmulatedEntrypoint/Models/Dtos/Responses/BuildDeck/BuildDeckBuyResponse.cs create mode 100644 SVSim.EmulatedEntrypoint/Models/Dtos/Responses/BuildDeck/BuildDeckGetPurchaseCountResponse.cs create mode 100644 SVSim.EmulatedEntrypoint/Models/Dtos/Responses/BuildDeck/BuildDeckInfoResponse.cs create mode 100644 SVSim.EmulatedEntrypoint/Models/Dtos/Responses/LeaderSkin/LeaderSkinSetResponse.cs create mode 100644 SVSim.UnitTests/Controllers/BuildDeckControllerBuyTests.cs create mode 100644 SVSim.UnitTests/Controllers/BuildDeckControllerGetPurchaseCountTests.cs create mode 100644 SVSim.UnitTests/Controllers/BuildDeckControllerInfoTests.cs create mode 100644 SVSim.UnitTests/Controllers/LeaderSkinControllerTests.cs create mode 100644 SVSim.UnitTests/Importers/BuildDeckImporterTests.cs diff --git a/SVSim.Bootstrap/Data/build-deck/build_deck_package_master.csv b/SVSim.Bootstrap/Data/build-deck/build_deck_package_master.csv new file mode 100644 index 0000000..3514786 --- /dev/null +++ b/SVSim.Bootstrap/Data/build-deck/build_deck_package_master.csv @@ -0,0 +1,1885 @@ +product_id,card_id,number,is_spot +1,701141011,1,0 +1,102141010,1,0 +1,101131020,1,0 +1,102131030,1,0 +1,101131050,1,0 +1,102131020,1,0 +1,102131010,1,0 +1,101121020,2,0 +1,101121010,2,0 +1,101121080,3,0 +1,102121010,2,0 +1,102121030,2,0 +1,101121110,2,0 +1,101111010,3,0 +1,101114050,3,0 +1,101114010,2,0 +1,102111060,2,0 +1,100111010,2,0 +1,100114010,3,0 +1,100111020,3,0 +1,100111070,2,0 +2,701241011,1,0 +2,101241020,1,0 +2,102231010,1,0 +2,101231050,1,0 +2,101234020,1,0 +2,101231040,1,0 +2,101231020,1,0 +2,102221010,3,0 +2,102224040,2,0 +2,101221010,2,0 +2,101221070,2,0 +2,101221100,2,0 +2,101221060,2,0 +2,101211110,2,0 +2,101211090,2,0 +2,101211060,2,0 +2,101211070,2,0 +2,102211050,2,0 +2,100211010,3,0 +2,100211020,3,0 +2,100211040,2,0 +2,100221020,2,0 +3,701341011,1,0 +3,101341010,1,0 +3,102331010,1,0 +3,101334040,1,0 +3,101334030,1,0 +3,102334020,1,0 +3,101334020,1,0 +3,101024010,3,0 +3,101321040,2,0 +3,102324040,2,0 +3,101324040,2,0 +3,101324050,2,0 +3,101321070,2,0 +3,101314010,3,0 +3,101311010,2,0 +3,101311050,2,0 +3,101314020,3,0 +3,100314010,2,0 +3,100314040,2,0 +3,100314020,2,0 +3,100314030,2,0 +3,100321010,2,0 +4,701441011,1,0 +4,101441010,1,0 +4,101431040,1,0 +4,101431030,1,0 +4,102431010,1,0 +4,101431050,1,0 +4,101431020,1,0 +4,101424010,2,0 +4,101421080,3,0 +4,101421020,2,0 +4,102421020,2,0 +4,102021030,2,0 +4,101421070,2,0 +4,101411110,2,0 +4,102411020,3,0 +4,101411030,3,0 +4,101411070,2,0 +4,100414020,3,0 +4,100414010,3,0 +4,100411040,2,0 +4,100421020,2,0 +5,701541011,1,0 +5,102541010,1,0 +5,101534020,1,0 +5,101534010,1,0 +5,102531010,1,0 +5,101534030,1,0 +5,101531050,1,0 +5,101521010,2,0 +5,101521020,2,0 +5,102521040,3,0 +5,101521030,2,0 +5,102521010,2,0 +5,101521060,2,0 +5,101514010,2,0 +5,101511120,3,0 +5,102511010,3,0 +5,102514050,2,0 +5,100511010,3,0 +5,100514010,2,0 +5,100511040,3,0 +5,100521030,2,0 +6,701641011,1,0 +6,102641010,1,0 +6,102631030,1,0 +6,101633010,1,0 +6,101631040,1,0 +6,101634020,1,0 +6,101631050,1,0 +6,101621070,2,0 +6,101621030,2,0 +6,102621010,2,0 +6,101624010,3,0 +6,101021040,2,0 +6,101621050,2,0 +6,101611040,2,0 +6,101611050,1,0 +6,101614020,3,0 +6,102614050,2,0 +6,101611140,2,0 +6,100614010,2,0 +6,100614020,3,0 +6,100614030,2,0 +6,100611040,3,0 +7,701741011,1,0 +7,101741020,1,0 +7,101733020,1,0 +7,102731030,1,0 +7,102731010,1,0 +7,101734010,1,0 +7,101731010,1,0 +7,102723010,3,0 +7,102021020,2,0 +7,101721020,2,0 +7,101721040,2,0 +7,101721100,2,0 +7,101721050,2,0 +7,101713020,2,0 +7,101713010,2,0 +7,101711090,2,0 +7,102711010,2,0 +7,101713050,2,0 +7,100714020,3,0 +7,100714010,3,0 +7,100713010,2,0 +7,100713030,2,0 +201,703141011,1,0 +201,104141010,1,0 +201,104131030,1,0 +201,104131020,1,0 +201,104123010,2,0 +201,104121040,2,0 +201,104121030,3,0 +201,104114010,2,0 +201,104111040,2,0 +201,104111020,2,0 +201,103134010,1,0 +201,103131030,1,0 +201,103121030,2,0 +201,103121020,2,0 +201,103114020,2,0 +201,103111030,2,0 +201,103031020,1,0 +201,103021020,2,0 +201,100114010,3,0 +201,100111060,2,0 +201,100111020,3,0 +201,100111010,2,0 +202,703241011,1,0 +202,104241020,1,0 +202,104232010,1,0 +202,104231030,1,0 +202,104231020,1,0 +202,104221040,2,0 +202,104221030,3,0 +202,104221020,2,0 +202,104211040,2,0 +202,104211020,3,0 +202,103231030,1,0 +202,103231020,1,0 +202,103224010,2,0 +202,103221040,2,0 +202,103221020,2,0 +202,103211050,3,0 +202,103211030,2,0 +202,100211040,2,0 +202,100211030,2,0 +202,100211020,3,0 +202,100211010,3,0 +203,703341011,1,0 +203,104334010,1,0 +203,104331020,1,0 +203,104324010,2,0 +203,104321040,2,0 +203,104321020,2,0 +203,103341010,1,0 +203,103334010,1,0 +203,103331030,1,0 +203,103331020,1,0 +203,103324010,2,0 +203,103321040,2,0 +203,103321030,3,0 +203,103314010,2,0 +203,103311060,2,0 +203,103311050,3,0 +203,103311030,3,0 +203,100321010,2,0 +203,100314030,2,0 +203,100314020,3,0 +203,100314010,3,0 +204,703441011,1,0 +204,104441020,1,0 +204,104431030,1,0 +204,104431020,1,0 +204,104424010,2,0 +204,104421040,2,0 +204,104412010,2,0 +204,104411040,2,0 +204,104411030,3,0 +204,104031020,1,0 +204,103434010,1,0 +204,103431030,1,0 +204,103424010,3,0 +204,103421030,2,0 +204,103421020,2,0 +204,103411030,3,0 +204,103021030,2,0 +204,100421020,2,0 +204,100414020,3,0 +204,100414010,3,0 +204,100411040,2,0 +205,703541011,1,0 +205,104541020,1,0 +205,104533010,1,0 +205,104531030,1,0 +205,104531020,1,0 +205,104521030,2,0 +205,104521020,2,0 +205,104514010,2,0 +205,104021020,2,0 +205,103534010,1,0 +205,103531020,1,0 +205,103521040,2,0 +205,103521030,2,0 +205,103514020,3,0 +205,103511050,3,0 +205,103511030,2,0 +205,103021040,3,0 +205,100521030,2,0 +205,100514010,2,0 +205,100511050,3,0 +205,100511010,3,0 +206,703641011,1,0 +206,104641020,1,0 +206,104633010,1,0 +206,104631030,1,0 +206,104631020,1,0 +206,104623010,2,0 +206,104621040,2,0 +206,104621020,3,0 +206,104614010,2,0 +206,104611030,2,0 +206,104611020,2,0 +206,103634010,1,0 +206,103631030,1,0 +206,103624010,3,0 +206,103621020,3,0 +206,103611030,2,0 +206,103011030,2,0 +206,100614030,2,0 +206,100614020,2,0 +206,100611050,3,0 +206,100611040,3,0 +207,703741011,1,0 +207,104741010,1,0 +207,104733010,1,0 +207,104731030,1,0 +207,104731020,1,0 +207,104723010,2,0 +207,104721030,2,0 +207,104721020,2,0 +207,104711040,2,0 +207,104711020,2,0 +207,103733010,1,0 +207,103731020,1,0 +207,103721040,3,0 +207,103721030,2,0 +207,103721020,2,0 +207,103711060,2,0 +207,103711040,2,0 +207,103011050,2,0 +207,100714030,2,0 +207,100714020,3,0 +207,100713030,2,0 +207,100711010,3,0 +301,106141010,1,0 +301,106141020,1,0 +301,105131020,2,0 +301,105131010,2,0 +301,106131020,2,0 +301,106133010,2,0 +301,106121020,3,0 +301,105123010,3,0 +301,106124010,3,0 +301,105121010,2,0 +301,106021010,3,0 +301,106114010,3,0 +301,105111020,2,0 +301,106111010,3,0 +301,106111030,2,0 +301,100111020,2,0 +301,100114010,1,0 +301,100111010,3,0 +302,106241010,1,0 +302,105241020,1,0 +302,106231020,3,0 +302,106231010,2,0 +302,106234010,3,0 +302,105224010,2,0 +302,105221030,3,0 +302,106221010,2,0 +302,106221020,3,0 +302,106221030,2,0 +302,104221020,2,0 +302,104211040,2,0 +302,105211010,2,0 +302,104211020,3,0 +302,105011020,3,0 +302,100211010,3,0 +302,100221020,3,0 +303,105341010,1,0 +303,106341010,1,0 +303,106331020,2,0 +303,105331020,3,0 +303,105334010,3,0 +303,106324010,2,0 +303,106321030,3,0 +303,106321010,2,0 +303,105322010,2,0 +303,105321010,2,0 +303,104321030,3,0 +303,106312010,3,0 +303,105312010,3,0 +303,104311040,3,0 +303,104311030,1,0 +303,100314020,2,0 +303,100314030,2,0 +303,100311010,2,0 +304,106441010,1,0 +304,105041020,1,0 +304,106031020,2,0 +304,106434010,2,0 +304,106431020,2,0 +304,106033010,2,0 +304,106021020,3,0 +304,106421020,3,0 +304,104421040,2,0 +304,104424010,2,0 +304,105421020,2,0 +304,105021030,2,0 +304,106411010,3,0 +304,105411010,3,0 +304,104411020,1,0 +304,104411030,3,0 +304,100414010,3,0 +304,100414020,3,0 +305,106541020,1,0 +305,106541010,1,0 +305,106531020,3,0 +305,106531010,3,0 +305,105531010,2,0 +305,105521010,3,0 +305,104521030,3,0 +305,104521020,2,0 +305,104021020,3,0 +305,106521010,3,0 +305,104514010,3,0 +305,106511010,1,0 +305,105511010,3,0 +305,106511030,3,0 +305,100511010,3,0 +305,100514010,3,0 +306,105641020,1,0 +306,106641010,1,0 +306,106634010,3,0 +306,106631010,2,0 +306,105631010,3,0 +306,106624010,3,0 +306,106621020,3,0 +306,105621010,3,0 +306,104621020,3,0 +306,104623010,2,0 +306,105611010,3,0 +306,104611020,3,0 +306,106011020,1,0 +306,106611020,3,0 +306,100611050,2,0 +306,100614020,2,0 +306,100614010,2,0 +307,106741010,1,0 +307,105741010,1,0 +307,105731020,2,0 +307,106733010,3,0 +307,106731020,3,0 +307,106721020,2,0 +307,106721030,2,0 +307,105723010,3,0 +307,105021010,2,0 +307,106724010,3,0 +307,105721020,2,0 +307,106713010,3,0 +307,104011040,1,0 +307,105711030,3,0 +307,105711010,3,0 +307,100714020,3,0 +307,100713010,3,0 +401,705114011,3,0 +401,109141010,1,0 +401,107141020,1,0 +401,109131020,1,0 +401,107131010,3,0 +401,108131010,2,0 +401,108131030,3,0 +401,109121010,1,0 +401,106124010,3,0 +401,108121010,3,0 +401,108121020,3,0 +401,108124010,3,0 +401,109111030,1,0 +401,107111030,3,0 +401,108111010,3,0 +401,108113010,3,0 +401,100111010,3,0 +402,705214011,3,0 +402,109241010,1,0 +402,108241010,1,0 +402,109234010,1,0 +402,107231010,3,0 +402,108231010,2,0 +402,108234010,3,0 +402,109221010,1,0 +402,106221010,3,0 +402,107221010,3,0 +402,107221020,3,0 +402,108221030,3,0 +402,109211010,1,0 +402,107211020,3,0 +402,107214010,3,0 +402,108211030,3,0 +402,100211020,3,0 +403,705314011,3,0 +403,109341010,1,0 +403,108341010,1,0 +403,109331020,1,0 +403,107334010,2,0 +403,107334020,3,0 +403,108334010,3,0 +403,109321020,1,0 +403,106324010,3,0 +403,107322010,3,0 +403,107324010,3,0 +403,108321020,3,0 +403,109311010,1,0 +403,107314020,3,0 +403,108311010,3,0 +403,108311020,3,0 +403,100314030,3,0 +404,705414011,3,0 +404,109441010,1,0 +404,107441020,1,0 +404,109431010,1,0 +404,107431010,3,0 +404,108431010,3,0 +404,108434010,2,0 +404,109421010,1,0 +404,106423010,3,0 +404,107021020,3,0 +404,108421020,3,0 +404,108424010,3,0 +404,109411020,1,0 +404,107411010,3,0 +404,107411020,3,0 +404,108411040,3,0 +404,100414020,3,0 +405,705514011,3,0 +405,109541010,1,0 +405,107541010,1,0 +405,109531010,1,0 +405,107531020,3,0 +405,108531010,2,0 +405,108531020,3,0 +405,109521020,1,0 +405,106524010,3,0 +405,107021010,3,0 +405,108521010,3,0 +405,108521020,3,0 +405,109511020,1,0 +405,107511010,3,0 +405,107513010,3,0 +405,108511030,3,0 +405,100511010,3,0 +406,705614021,3,0 +406,109641010,1,0 +406,108641010,1,0 +406,109631020,1,0 +406,107634010,3,0 +406,108631020,3,0 +406,108634010,2,0 +406,109621020,1,0 +406,106624010,3,0 +406,107621020,3,0 +406,108621010,3,0 +406,108621020,3,0 +406,109611020,1,0 +406,107611020,3,0 +406,107614010,3,0 +406,108614010,3,0 +406,100611050,3,0 +407,705714011,3,0 +407,109741010,1,0 +407,107741010,1,0 +407,109731010,1,0 +407,107732010,2,0 +407,108731010,3,0 +407,108733010,3,0 +407,109721020,1,0 +407,106724010,3,0 +407,107723010,3,0 +407,107724010,3,0 +407,108721010,3,0 +407,109711020,1,0 +407,107713010,3,0 +407,108713010,3,0 +407,108714010,3,0 +407,100713010,3,0 +408,705814011,3,0 +408,109841010,1,0 +408,107841030,1,0 +408,109831010,1,0 +408,107831010,2,0 +408,107831020,3,0 +408,107834020,3,0 +408,109821020,1,0 +408,107821030,3,0 +408,107821040,3,0 +408,107824020,3,0 +408,108821010,3,0 +408,109811010,1,0 +408,107811090,3,0 +408,107811100,3,0 +408,107813030,3,0 +408,100811070,3,0 +501,111141020,1,0 +501,110141020,2,0 +501,110141030,2,0 +501,109131020,3,0 +501,110131010,3,0 +501,110131030,3,0 +501,108121010,3,0 +501,110121010,3,0 +501,109023010,3,0 +501,108113010,3,0 +501,108111030,3,0 +501,110114010,3,0 +501,100111010,3,0 +501,100111020,3,0 +501,100114010,2,0 +502,111241020,1,0 +502,109241020,2,0 +502,110241010,2,0 +502,109234010,3,0 +502,110231020,3,0 +502,110231010,3,0 +502,108221030,3,0 +502,110221030,3,0 +502,110221010,3,0 +502,110214010,3,0 +502,110211030,3,0 +502,108211030,3,0 +502,100211010,3,0 +502,100211020,3,0 +502,100221020,2,0 +503,111341020,1,0 +503,109341030,2,0 +503,110341030,2,0 +503,109331010,3,0 +503,110331010,3,0 +503,110331030,3,0 +503,108321020,3,0 +503,110324010,3,0 +503,111321020,3,0 +503,110311010,3,0 +503,109314010,3,0 +503,111311030,3,0 +503,100314010,3,0 +503,100314030,3,0 +503,100314020,2,0 +504,111441020,1,0 +504,110441010,2,0 +504,110441030,2,0 +504,109431030,3,0 +504,110434010,3,0 +504,110431020,3,0 +504,108424010,3,0 +504,109421010,3,0 +504,110421030,3,0 +504,108411040,3,0 +504,110411010,3,0 +504,110411030,3,0 +504,100414020,3,0 +504,100414010,3,0 +504,100411040,2,0 +505,111541020,1,0 +505,110541010,2,0 +505,110041020,2,0 +505,109031010,3,0 +505,110531010,3,0 +505,110531030,3,0 +505,109521020,3,0 +505,109521010,3,0 +505,110521010,3,0 +505,108511030,3,0 +505,109514010,3,0 +505,110511020,3,0 +505,100514010,3,0 +505,100511010,3,0 +505,100511020,2,0 +506,111641020,1,0 +506,109641010,2,0 +506,110641010,2,0 +506,109631020,3,0 +506,109631010,3,0 +506,110631020,3,0 +506,109623010,3,0 +506,109621010,3,0 +506,110621020,3,0 +506,109611010,3,0 +506,110611020,3,0 +506,110611030,3,0 +506,100614020,3,0 +506,100614010,3,0 +506,100611050,2,0 +507,111741020,1,0 +507,110741030,2,0 +507,109041020,2,0 +507,109731010,3,0 +507,109734010,3,0 +507,110731020,3,0 +507,108721010,3,0 +507,108722010,3,0 +507,109724010,3,0 +507,108713010,3,0 +507,108714010,3,0 +507,109713010,3,0 +507,100713010,3,0 +507,100714020,3,0 +507,100714010,2,0 +508,111841020,1,0 +508,109841020,2,0 +508,110841010,2,0 +508,109831010,3,0 +508,110834010,3,0 +508,110831020,3,0 +508,109821010,3,0 +508,110821010,3,0 +508,110821020,3,0 +508,108811030,3,0 +508,110811020,3,0 +508,110811030,3,0 +508,100824010,3,0 +508,100811030,3,0 +508,100811020,2,0 +601,111141010,2,0 +601,112041020,2,0 +601,112141010,1,0 +601,112031020,3,0 +601,112131020,3,0 +601,110131020,3,0 +601,112021020,3,0 +601,112122010,3,0 +601,111124010,3,0 +601,112011020,3,0 +601,112111010,3,0 +601,110114010,3,0 +601,100111010,3,0 +601,100111020,2,0 +601,100114010,3,0 +602,112241010,2,0 +602,112241020,2,0 +602,111241010,1,0 +602,112231030,3,0 +602,112231010,3,0 +602,111231010,3,0 +602,111221020,3,0 +602,112222010,3,0 +602,112224010,3,0 +602,113011010,3,0 +602,112011030,3,0 +602,112211010,3,0 +602,100211010,3,0 +602,100211020,3,0 +602,100211050,2,0 +603,112341020,2,0 +603,112341030,2,0 +603,110341010,1,0 +603,112331020,3,0 +603,111331020,3,0 +603,111331010,3,0 +603,112322010,3,0 +603,110324010,3,0 +603,110024010,3,0 +603,111311020,3,0 +603,111314010,3,0 +603,112314010,3,0 +603,100314010,3,0 +603,100314020,3,0 +603,100314070,2,0 +604,112441010,2,0 +604,112441020,2,0 +604,111441010,1,0 +604,111431020,3,0 +604,112431010,3,0 +604,112433010,3,0 +604,111421010,3,0 +604,111421020,3,0 +604,112422010,3,0 +604,112411010,3,0 +604,111411030,3,0 +604,111414010,3,0 +604,100414020,3,0 +604,100414010,3,0 +604,100411040,2,0 +605,112541020,2,0 +605,112541030,2,0 +605,112541010,1,0 +605,111531010,3,0 +605,112531010,3,0 +605,111031020,3,0 +605,110521020,3,0 +605,112521010,3,0 +605,112521020,3,0 +605,112511020,3,0 +605,112511030,3,0 +605,111514010,3,0 +605,100514010,3,0 +605,100511010,3,0 +605,100511020,2,0 +606,112641020,2,0 +606,112641030,2,0 +606,112041010,1,0 +606,112631020,3,0 +606,112631010,3,0 +606,112634010,3,0 +606,112621010,3,0 +606,112621020,3,0 +606,111624010,3,0 +606,113611010,3,0 +606,112611030,3,0 +606,111611010,3,0 +606,100614010,3,0 +606,100614020,3,0 +606,100611050,2,0 +607,111741030,2,0 +607,112741020,2,0 +607,111741020,1,0 +607,112731020,3,0 +607,112731010,3,0 +607,111031010,3,0 +607,113721010,3,0 +607,112721020,3,0 +607,110721010,3,0 +607,113711020,3,0 +607,112711010,3,0 +607,111711020,3,0 +607,100713010,3,0 +607,100714020,3,0 +607,100714030,2,0 +608,112841030,2,0 +608,111841010,2,0 +608,112841020,1,0 +608,111831030,3,0 +608,111831020,3,0 +608,112834010,3,0 +608,112821010,3,0 +608,112821020,3,0 +608,111824010,3,0 +608,112811030,3,0 +608,110811010,3,0 +608,111814010,3,0 +608,100811070,3,0 +608,100811040,3,0 +608,100811050,2,0 +701,115141020,1,0 +701,114141020,2,0 +701,113141010,2,0 +701,113131030,3,0 +701,114131020,3,0 +701,114131010,3,0 +701,114124010,3,0 +701,114121020,3,0 +701,113121010,3,0 +701,114111030,3,0 +701,114114010,3,0 +701,113114010,3,0 +701,100114010,3,0 +701,100111010,2,0 +701,100111020,3,0 +702,115241020,1,0 +702,114241020,2,0 +702,113241020,2,0 +702,114231010,3,0 +702,113234010,3,0 +702,114234010,3,0 +702,114224010,3,0 +702,114221010,3,0 +702,114221020,3,0 +702,114211030,3,0 +702,114211010,3,0 +702,113211020,3,0 +702,100211010,3,0 +702,100211060,2,0 +702,100211020,3,0 +703,115341020,1,0 +703,114341010,2,0 +703,114041010,2,0 +703,113334010,3,0 +703,114331020,3,0 +703,114331010,3,0 +703,114324010,3,0 +703,114321010,3,0 +703,114021010,3,0 +703,114311010,3,0 +703,114311020,3,0 +703,115311020,3,0 +703,100314010,3,0 +703,100314020,2,0 +703,100314070,3,0 +704,115441020,1,0 +704,114441020,2,0 +704,114441030,2,0 +704,114434010,3,0 +704,113431020,3,0 +704,114431010,3,0 +704,114424010,3,0 +704,114421020,3,0 +704,113421030,3,0 +704,114411030,3,0 +704,114411020,3,0 +704,113414010,3,0 +704,100414020,3,0 +704,100411040,2,0 +704,100414010,3,0 +705,115541020,1,0 +705,114541010,2,0 +705,114541020,2,0 +705,114531010,3,0 +705,113531020,3,0 +705,113531010,3,0 +705,114524010,3,0 +705,114521020,3,0 +705,114521010,3,0 +705,114511030,3,0 +705,113511020,3,0 +705,112511030,3,0 +705,100514010,3,0 +705,100511050,2,0 +705,100511010,3,0 +706,115641020,1,0 +706,114641010,2,0 +706,114641020,2,0 +706,113631010,3,0 +706,114631010,3,0 +706,114631020,3,0 +706,114624010,3,0 +706,113621010,3,0 +706,114621020,3,0 +706,113614010,3,0 +706,114611030,3,0 +706,114611010,3,0 +706,100614020,3,0 +706,100611050,2,0 +706,100614010,3,0 +707,115741020,1,0 +707,114741010,2,0 +707,114741030,2,0 +707,113733010,3,0 +707,113731010,3,0 +707,114731020,3,0 +707,114724010,3,0 +707,112722010,3,0 +707,114721020,3,0 +707,114713010,3,0 +707,114711030,3,0 +707,114011010,3,0 +707,100714020,3,0 +707,100714030,2,0 +707,100713010,3,0 +708,115841020,1,0 +708,114841010,2,0 +708,114844010,2,0 +708,114031020,3,0 +708,114831020,3,0 +708,114831010,3,0 +708,114821010,3,0 +708,114821020,3,0 +708,113821010,3,0 +708,113811020,3,0 +708,113814010,3,0 +708,114811030,3,0 +708,100811030,3,0 +708,100811020,2,0 +708,100824010,3,0 +10001,113131010,3,1 +10001,113131030,3,1 +10001,115141030,3,1 +10001,112114010,3,1 +10001,114114010,3,1 +10001,114124010,3,1 +10001,114014010,3,1 +10001,113131020,3,1 +10001,115141020,3,1 +10001,112134010,3,1 +10001,114011010,3,1 +10001,113114010,2,1 +10001,114141030,3,1 +10001,116141010,2,1 +10002,115241010,3,1 +10002,113234010,3,1 +10002,114234020,3,1 +10002,113211020,3,1 +10002,113221020,2,1 +10002,115231010,3,1 +10002,114214010,2,1 +10002,115031020,3,1 +10002,116041010,3,1 +10002,115241020,3,1 +10002,116221010,3,1 +10002,116234010,2,1 +10002,115231020,3,1 +10002,113241020,3,1 +10002,114241010,1,1 +10003,115024010,3,1 +10003,114324010,3,1 +10003,114031010,3,1 +10003,114014010,3,1 +10003,114311010,3,1 +10003,115311020,3,1 +10003,114314010,1,1 +10003,116041010,1,1 +10003,114311020,3,1 +10003,114321010,3,1 +10003,116331010,3,1 +10003,115341020,3,1 +10003,114341010,3,1 +10003,115341030,2,1 +10003,114331010,3,1 +10004,114424010,3,1 +10004,113011010,2,1 +10004,116031010,3,1 +10004,114014010,3,1 +10004,114411030,3,1 +10004,116411030,3,1 +10004,100414010,3,0 +10004,114421020,3,1 +10004,116431010,3,1 +10004,112422010,2,1 +10004,114441020,3,1 +10004,116441020,3,1 +10004,116041020,3,1 +10004,114441030,3,1 +10005,114521020,3,1 +10005,114524010,3,1 +10005,115514010,2,1 +10005,114031010,3,1 +10005,114014010,3,1 +10005,114511030,3,1 +10005,114541020,3,1 +10005,114541030,3,1 +10005,115521010,2,1 +10005,115531030,2,1 +10005,116541010,2,1 +10005,116521010,3,1 +10005,114541010,3,1 +10005,116534010,2,1 +10005,115541010,3,1 +10006,115621030,3,1 +10006,114624010,2,1 +10006,115634010,3,1 +10006,114611030,3,1 +10006,115631020,3,1 +10006,116611010,3,1 +10006,116621020,3,1 +10006,100614020,3,0 +10006,116634010,3,1 +10006,114011010,1,1 +10006,113641020,3,1 +10006,115641020,3,1 +10006,112634010,3,1 +10006,116641010,3,1 +10006,114641020,1,1 +10007,112011030,1,1 +10007,113011010,3,1 +10007,116031010,3,1 +10007,114014010,3,1 +10007,112721020,3,1 +10007,113721010,3,1 +10007,116731020,3,1 +10007,114011010,3,1 +10007,112711010,3,1 +10007,112741020,3,1 +10007,113741020,3,1 +10007,116741010,3,1 +10007,116741020,3,1 +10007,116041020,3,1 +10008,115814010,3,1 +10008,116821020,3,1 +10008,112834010,3,1 +10008,116814010,3,1 +10008,113811010,1,1 +10008,116811010,3,1 +10008,116811020,3,1 +10008,114841020,3,1 +10008,116831010,3,1 +10008,116834010,3,1 +10008,113831010,3,1 +10008,113841010,3,1 +10008,112841020,3,1 +10008,115841030,3,1 +11901,118011010,3,1 +11901,118111030,3,1 +11901,118011020,3,1 +11901,118031010,3,1 +11901,118111020,3,1 +11901,118131020,3,1 +11901,119111010,3,1 +11901,116041010,2,1 +11901,118021010,3,1 +11901,115111030,3,1 +11901,116141030,1,1 +11901,118121020,3,1 +11901,119141010,1,1 +11901,118021020,3,1 +11901,118131010,3,1 +11902,100211010,3,0 +11902,117214010,3,1 +11902,115231010,3,1 +11902,116211010,3,1 +11902,117211030,3,1 +11902,117224010,3,1 +11902,119224010,3,1 +11902,116221010,3,1 +11902,119211020,3,1 +11902,115234010,3,1 +11902,118214010,3,1 +11902,118241030,3,1 +11902,119231020,3,1 +11902,119241010,1,1 +11905,115531010,3,1 +11905,118511020,3,1 +11905,118011020,3,1 +11905,118511030,3,1 +11905,118541010,2,1 +11905,119511030,3,1 +11905,119541010,1,1 +11905,117534010,3,1 +11905,118511010,3,1 +11905,119511020,3,1 +11905,118521020,3,1 +11905,118524010,3,1 +11905,117541010,1,1 +11905,116534010,3,1 +11905,118521010,3,1 +11906,117611020,3,1 +11906,119611010,3,1 +11906,117634010,3,1 +11906,115611020,3,1 +11906,116611010,3,1 +11906,117621020,3,1 +11906,117631020,3,1 +11906,118611010,3,1 +11906,100614020,3,0 +11906,117621010,3,1 +11906,119624010,3,1 +11906,115641010,1,1 +11906,117641020,2,1 +11906,119631020,1,1 +11906,119641010,1,1 +11906,117631010,2,1 +12003,100314010,3,1 +12003,120324010,3,1 +12003,119031010,3,1 +12003,117014010,2,1 +12003,118341030,2,1 +12003,100314020,3,1 +12003,116334010,3,1 +12003,117324010,3,1 +12003,118324010,3,1 +12003,119334010,1,1 +12003,120314010,3,1 +12003,119011010,3,1 +12003,120331010,3,1 +12003,120341010,2,1 +12003,116314010,3,1 +12004,119424010,2,1 +12004,118431010,3,1 +12004,120411010,3,1 +12004,120421020,3,1 +12004,100414010,3,1 +12004,117434010,2,1 +12004,118424010,3,1 +12004,120414010,3,1 +12004,116434010,3,1 +12004,118441030,1,1 +12004,119441030,1,1 +12004,120421010,3,1 +12004,119044010,1,1 +12004,120411030,2,1 +12004,120431020,2,1 +12004,120441020,1,1 +12004,118411030,3,1 +12004,119421020,1,1 +12007,117724010,3,1 +12007,119713010,3,1 +12007,119021020,3,1 +12007,116714010,3,1 +12007,117721010,2,1 +12007,117731020,3,1 +12007,120711010,1,1 +12007,120741010,1,1 +12007,120714010,3,1 +12007,118732010,3,1 +12007,117031020,1,1 +12007,117741030,2,1 +12007,119731020,3,1 +12007,100714030,1,1 +12007,118714010,3,1 +12007,117041010,1,1 +12007,117721020,1,1 +12007,118721020,3,1 +12008,116821020,3,1 +12008,117811020,3,1 +12008,116814010,3,1 +12008,117824010,3,1 +12008,116811010,3,1 +12008,120821020,3,1 +12008,116811020,3,1 +12008,119811020,3,1 +12008,116831010,1,1 +12008,118831010,1,1 +12008,116834010,1,1 +12008,117841030,2,1 +12008,120811020,2,1 +12008,120831010,1,1 +12008,120841010,1,1 +12008,120831020,3,1 +12008,119041010,1,1 +12008,119831020,3,1 +12104,121034010,3,1 +12104,118411020,3,1 +12104,119424010,3,1 +12104,121424010,3,1 +12104,118011020,3,1 +12104,121421010,3,1 +12104,100414010,3,1 +12104,117434010,1,1 +12104,118424010,1,1 +12104,121414010,3,1 +12104,121434010,2,1 +12104,121411010,3,1 +12104,121431010,3,1 +12104,119441030,1,1 +12104,121044010,1,1 +12104,120431010,1,1 +12104,121441010,2,1 +12104,119421020,1,1 +12106,121023010,3,1 +12106,121634010,3,1 +12106,121613010,3,1 +12106,118011020,3,1 +12106,118611010,3,1 +12106,118631010,3,1 +12106,119621020,3,1 +12106,120641020,1,1 +12106,120641030,1,1 +12106,121641010,2,1 +12106,120624010,3,1 +12106,121624010,2,1 +12106,118021010,3,1 +12106,119621010,3,1 +12106,119631020,1,1 +12106,118633010,3,1 +12108,120014010,3,1 +12108,121023010,3,1 +12108,118811020,3,1 +12108,121824010,3,1 +12108,117811010,3,1 +12108,120821020,3,1 +12108,121821010,3,1 +12108,121821020,3,1 +12108,121841010,1,1 +12108,121814010,2,1 +12108,121831010,3,1 +12108,121834010,3,1 +12108,121031010,1,1 +12108,119831030,3,1 +12108,120841020,1,1 +12108,121841020,2,1 +12201,120014010,2,1 +12201,120141020,1,1 +12201,122131010,3,1 +12201,122131020,3,1 +12201,119111010,3,1 +12201,120121020,3,1 +12201,122121030,3,1 +12201,120134010,2,1 +12201,120123010,3,1 +12201,120111040,3,1 +12201,121131010,2,1 +12201,122121020,3,1 +12201,120141010,2,1 +12201,122111010,3,1 +12201,122141010,1,1 +12201,119111040,3,1 +12204,122421010,2,1 +12204,120434010,2,1 +12204,122414010,3,1 +12204,120421020,3,1 +12204,122411010,3,1 +12204,122441020,1,1 +12204,100414010,3,1 +12204,120414010,3,1 +12204,121411020,3,1 +12204,119431010,3,1 +12204,120421010,3,1 +12204,121441020,1,1 +12204,119431020,2,1 +12204,120441020,1,1 +12204,121421020,3,1 +12204,122441010,1,1 +12204,121431020,3,1 +12206,119611010,3,1 +12206,122613010,3,1 +12206,118611010,3,1 +12206,120621010,3,1 +12206,122634010,3,1 +12206,121641030,1,1 +12206,122641020,1,1 +12206,119624010,2,1 +12206,118631020,2,1 +12206,119631020,2,1 +12206,121631010,3,1 +12206,122621010,3,1 +12206,121611030,3,1 +12206,121641020,1,1 +12206,122621020,3,1 +12206,122641010,1,1 +12206,122611030,3,1 +12302,123211030,3,1 +12302,122214010,3,1 +12302,123224010,3,1 +12302,120231020,2,1 +12302,121211030,3,1 +12302,121221020,3,1 +12302,122231010,3,1 +12302,120021020,2,1 +12302,123211010,3,1 +12302,123241010,2,1 +12302,122244010,1,1 +12302,122221030,3,1 +12302,123221010,3,1 +12302,123244010,1,1 +12302,121231010,2,1 +12302,123231010,3,1 +12307,121731020,2,1 +12307,122031020,2,1 +12307,122721020,3,1 +12307,122731020,3,1 +12307,123711020,3,1 +12307,123741010,2,1 +12307,123723010,3,1 +12307,119011010,3,1 +12307,120741030,1,1 +12307,121711010,3,1 +12307,122741020,1,1 +12307,123721010,3,1 +12307,123713010,2,1 +12307,120721020,3,1 +12307,120711030,3,1 +12307,123731010,3,1 +12308,123824010,3,1 +12308,120014010,3,1 +12308,120821020,3,1 +12308,122821020,2,1 +12308,122821030,2,1 +12308,123841010,2,1 +12308,122834010,3,1 +12308,123814010,3,1 +12308,119811020,2,1 +12308,119824010,2,1 +12308,122841010,1,1 +12308,120811020,3,1 +12308,120831010,2,1 +12308,120841010,1,1 +12308,121811020,3,1 +12308,120831020,2,1 +12308,119831020,3,1 +12403,124341020,2,1 +12403,122341030,1,1 +12403,122341020,1,1 +12403,124331010,3,1 +12403,123331010,3,1 +12403,123331020,3,1 +12403,122331020,1,1 +12403,124321020,3,1 +12403,123321010,3,1 +12403,122321010,3,1 +12403,122321020,2,1 +12403,124314010,3,1 +12403,124314020,3,1 +12403,122311010,3,1 +12403,122311020,3,1 +12403,122314010,3,1 +12406,124641010,1,1 +12406,124641020,2,1 +12406,123641020,1,1 +12406,124631010,3,1 +12406,124634010,3,1 +12406,123631010,3,1 +12406,121631020,1,1 +12406,124621010,3,1 +12406,124621020,3,1 +12406,124624010,3,1 +12406,123621010,3,1 +12406,124611010,3,1 +12406,124611020,3,1 +12406,124614010,3,1 +12406,123614010,2,1 +12406,122613010,3,1 +12408,124841010,2,1 +12408,123841030,1,1 +12408,121841010,1,1 +12408,124831010,3,1 +12408,124834010,2,1 +12408,123831010,3,1 +12408,122831010,2,1 +12408,124821010,3,1 +12408,124824010,3,1 +12408,121821010,3,1 +12408,121824010,3,1 +12408,124811010,3,1 +12408,124814010,3,1 +12408,123811020,2,1 +12408,122811020,3,1 +12408,121814010,3,1 +12501,125141020,2,1 +12501,123141020,1,1 +12501,122141020,1,1 +12501,125131010,3,1 +12501,125134010,3,1 +12501,122131020,2,1 +12501,122134010,2,1 +12501,125121020,3,1 +12501,125121010,3,1 +12501,124121020,3,1 +12501,122121030,3,1 +12501,125111010,3,1 +12501,125114010,2,1 +12501,122111010,3,1 +12501,121111010,3,1 +12501,100111010,3,1 +12504,125441020,2,1 +12504,124441010,1,1 +12504,122441010,1,1 +12504,125431010,3,1 +12504,125434010,3,1 +12504,123434010,1,1 +12504,122031020,3,1 +12504,125421010,3,1 +12504,125421030,3,1 +12504,124024010,3,1 +12504,123421030,3,1 +12504,125011020,3,1 +12504,125411010,3,1 +12504,124411030,3,1 +12504,123414010,2,1 +12504,100414010,3,1 +12506,125641020,2,1 +12506,122641020,1,1 +12506,121641030,1,1 +12506,125631010,3,1 +12506,125633010,3,1 +12506,122634010,3,1 +12506,121631010,1,1 +12506,125621020,3,1 +12506,123621030,3,1 +12506,122621010,3,1 +12506,122621020,3,1 +12506,125611010,3,1 +12506,125614010,2,1 +12506,123611010,3,1 +12506,122613010,3,1 +12506,121611030,3,1 +12603,126341010,2,1 +12603,126341020,1,1 +12603,123341020,1,1 +12603,126331010,3,1 +12603,126334010,3,1 +12603,124334010,2,1 +12603,123334010,2,1 +12603,126321010,3,1 +12603,126321020,3,1 +12603,124024010,3,1 +12603,122324010,3,1 +12603,126311010,3,1 +12603,126311020,3,1 +12603,126314010,3,1 +12603,125314020,2,1 +12603,124311020,3,1 +12604,126441010,2,1 +12604,126441020,1,1 +12604,125441020,1,1 +12604,126431020,3,1 +12604,126431010,3,1 +12604,125431020,2,1 +12604,122031010,2,1 +12604,126421010,3,1 +12604,126424010,3,1 +12604,125421010,3,1 +12604,125421020,3,1 +12604,126014010,2,1 +12604,126411010,3,1 +12604,126411020,3,1 +12604,125011020,3,1 +12604,124411030,3,1 +12607,126741010,2,1 +12607,126741020,1,1 +12607,125741030,1,1 +12607,126731010,3,1 +12607,126732010,3,1 +12607,124731020,1,1 +12607,123031020,3,1 +12607,126721010,3,1 +12607,126721020,3,1 +12607,125723010,3,1 +12607,124721020,2,1 +12607,122721010,3,1 +12607,126711010,3,1 +12607,126711020,3,1 +12607,123711030,3,1 +12607,122714010,3,1 +12702,127241010,2,1 +12702,126241020,1,1 +12702,123241020,1,1 +12702,127231010,3,1 +12702,126231020,2,1 +12702,123231020,2,1 +12702,123234010,3,1 +12702,127221010,3,1 +12702,125221010,2,1 +12702,124221020,3,1 +12702,123221030,3,1 +12702,127211010,3,1 +12702,127211020,3,1 +12702,124214010,3,1 +12702,123211020,3,1 +12702,123211010,3,1 +12705,127541010,2,1 +12705,126541010,1,1 +12705,125541020,1,1 +12705,127531010,3,1 +12705,127033010,3,1 +12705,123031020,1,1 +12705,123531010,3,1 +12705,127521010,3,1 +12705,126521020,3,1 +12705,125521010,3,1 +12705,125524010,3,1 +12705,123524010,2,1 +12705,127511010,3,1 +12705,127511030,3,1 +12705,123511030,3,1 +12705,123514010,3,1 +12708,127841010,2,1 +12708,126041020,1,1 +12708,125844010,1,1 +12708,127033010,3,1 +12708,127831010,3,1 +12708,126031020,1,1 +12708,124031020,1,1 +12708,124031010,1,1 +12708,123831020,1,1 +12708,127821010,3,1 +12708,127821030,1,1 +12708,127821020,1,1 +12708,126824010,1,1 +12708,125021010,1,1 +12708,125821020,1,1 +12708,125821010,1,1 +12708,124024010,1,1 +12708,124821020,1,1 +12708,124824010,1,1 +12708,123821030,1,1 +12708,127014010,1,1 +12708,127811010,3,1 +12708,127811020,1,1 +12708,127814010,1,1 +12708,126011010,1,1 +12708,126011020,1,1 +12708,126014010,1,1 +12708,125814010,1,1 +12708,124014010,1,1 +12708,124814010,1,1 +12708,123814010,1,1 +12801,128141010,1,1 +12801,128141020,1,1 +12801,127141030,1,1 +12801,126141020,1,1 +12801,128131010,3,1 +12801,127134010,3,1 +12801,125131010,2,1 +12801,125131020,2,1 +12801,128121010,3,1 +12801,126121030,3,1 +12801,125121010,3,1 +12801,124121020,3,1 +12801,128111010,3,1 +12801,128111020,3,1 +12801,128114010,3,1 +12801,126111030,3,1 +12801,125111010,2,1 +12802,128241010,1,1 +12802,128241020,1,1 +12802,126241020,1,1 +12802,126241030,1,1 +12802,128231010,3,1 +12802,127231020,3,1 +12802,127231030,3,1 +12802,125231020,1,1 +12802,128221020,3,1 +12802,128224010,3,1 +12802,126221030,3,1 +12802,125221030,3,1 +12802,124221020,3,1 +12802,128211020,3,1 +12802,127211020,3,1 +12802,127214010,2,1 +12802,125011010,3,1 +12806,128641010,1,1 +12806,127641010,1,1 +12806,126641010,1,1 +12806,125641020,1,1 +12806,127631010,3,1 +12806,127634010,3,1 +12806,126631020,3,1 +12806,125633010,1,1 +12806,128624010,2,1 +12806,127621010,3,1 +12806,127621020,3,1 +12806,127621030,3,1 +12806,126621010,3,1 +12806,128611020,3,1 +12806,128614010,3,1 +12806,127611010,3,1 +12806,127614010,3,1 +12807,128741010,1,1 +12807,128741020,1,1 +12807,127741010,1,1 +12807,127741030,1,1 +12807,128032010,1,1 +12807,128731010,3,1 +12807,128733010,3,1 +12807,127731010,3,1 +12807,128721010,3,1 +12807,128721020,3,1 +12807,127721010,3,1 +12807,124024010,2,1 +12807,128711010,3,1 +12807,128713010,3,1 +12807,127713010,3,1 +12807,126711010,3,1 +12807,126713010,3,1 +12901,129141010,2,1 +12901,128141010,1,1 +12901,127141030,1,1 +12901,126141020,2,1 +12901,125141010,1,1 +12901,129131010,3,1 +12901,127134010,2,1 +12901,125131020,2,1 +12901,129124010,3,1 +12901,128121010,3,1 +12901,126121030,3,1 +12901,125121010,3,1 +12901,128111010,3,1 +12901,128111020,3,1 +12901,128114010,3,1 +12901,126111030,3,1 +12901,125111010,2,1 +12902,129241010,2,1 +12902,128241010,1,1 +12902,128241020,1,1 +12902,128241030,2,1 +12902,126241030,1,1 +12902,129234010,3,1 +12902,128231010,2,1 +12902,127231030,2,1 +12902,129221010,3,1 +12902,128224010,3,1 +12902,125221020,3,1 +12902,125221010,3,1 +12902,129214010,3,1 +12902,128211020,3,1 +12902,127211020,3,1 +12902,125211020,2,1 +12902,125011010,3,1 +12903,129341010,2,1 +12903,129341020,1,1 +12903,128341030,2,1 +12903,126341030,2,1 +12903,129031010,1,1 +12903,127334010,3,1 +12903,125331010,3,1 +12903,129321010,3,1 +12903,129322010,3,1 +12903,128321020,3,1 +12903,127321020,3,1 +12903,125321010,2,1 +12903,129311020,3,1 +12903,129311010,3,1 +12903,129314020,3,1 +12903,127311020,3,1 +12904,129441020,2,1 +12904,129441010,1,1 +12904,127441020,2,1 +12904,126441020,2,1 +12904,129431010,2,1 +12904,129434010,3,1 +12904,125431020,2,1 +12904,129421010,3,1 +12904,129421020,3,1 +12904,128424010,3,1 +12904,127421020,3,1 +12904,129411010,2,1 +12904,129411020,3,1 +12904,127414010,3,1 +12904,125411010,3,1 +12904,100414010,3,1 +12905,129541010,2,1 +12905,128041010,1,1 +12905,128041020,1,1 +12905,126541010,2,1 +12905,125541020,1,1 +12905,129531010,2,1 +12905,129534010,2,1 +12905,126531010,3,1 +12905,129521010,3,1 +12905,127524010,3,1 +12905,126521020,3,1 +12905,125524010,3,1 +12905,129511020,3,1 +12905,128514010,3,1 +12905,127511030,3,1 +12905,126511030,3,1 +12905,126514010,2,1 +12906,129641020,2,1 +12906,128641010,1,1 +12906,128641020,2,1 +12906,125641030,2,1 +12906,129631010,3,1 +12906,128032010,3,1 +12906,128634010,1,1 +12906,129624010,3,1 +12906,128621010,3,1 +12906,127621020,3,1 +12906,126621020,3,1 +12906,129611010,3,1 +12906,129614010,2,1 +12906,128611020,3,1 +12906,127614010,3,1 +12906,126611020,3,1 +12907,129741020,2,1 +12907,129741010,1,1 +12907,128741010,2,1 +12907,128741020,2,1 +12907,129731010,3,1 +12907,128731010,2,1 +12907,128733010,2,1 +12907,129721010,3,1 +12907,128721010,3,1 +12907,128721020,3,1 +12907,127721010,3,1 +12907,129711010,3,1 +12907,129713020,3,1 +12907,128711010,3,1 +12907,126711020,3,1 +12907,126713010,2,1 +12908,129841020,2,1 +12908,128841030,2,1 +12908,126841010,2,1 +12908,125844010,1,1 +12908,129831010,3,1 +12908,129834010,2,1 +12908,125834010,2,1 +12908,129024010,3,1 +12908,129821010,3,1 +12908,129821020,3,1 +12908,125821020,3,1 +12908,129811020,3,1 +12908,129811030,3,1 +12908,127814010,2,1 +12908,126811010,3,1 +12908,125811030,3,1 +13001,130141020,1,1 +13001,129141010,1,1 +13001,128141010,1,1 +13001,127141030,1,1 +13001,130131010,3,1 +13001,129131010,2,1 +13001,129131030,3,1 +13001,127131010,2,1 +13001,130124010,3,1 +13001,129024010,2,1 +13001,128121010,3,1 +13001,126121030,3,1 +13001,130111020,2,1 +13001,130114010,3,1 +13001,128111010,3,1 +13001,128111020,3,1 +13001,128114010,3,1 +13001,127111010,1,1 +13002,130241010,1,1 +13002,130241020,1,1 +13002,129241010,1,1 +13002,128041020,1,1 +13002,130234010,3,1 +13002,129234010,2,1 +13002,128231010,3,1 +13002,127231030,2,1 +13002,130221010,3,1 +13002,130221020,3,1 +13002,130224010,3,1 +13002,129024010,2,1 +13002,128224010,3,1 +13002,129214010,3,1 +13002,128211020,3,1 +13002,127211010,3,1 +13002,127211020,3,1 +13003,130341020,1,1 +13003,129341010,1,1 +13003,129341020,1,1 +13003,129341030,1,1 +13003,130331010,3,1 +13003,130334010,3,1 +13003,129031010,1,1 +13003,127334010,3,1 +13003,130321010,3,1 +13003,129321010,3,1 +13003,129322010,3,1 +13003,128321020,3,1 +13003,127321020,2,1 +13003,130311020,3,1 +13003,129311020,3,1 +13003,129314020,3,1 +13003,127311020,3,1 +13006,130641020,1,1 +13006,127641010,2,1 +13006,126641010,1,1 +13006,130634010,3,1 +13006,127631010,3,1 +13006,126631020,3,1 +13006,126631030,1,1 +13006,130621020,3,1 +13006,130624010,3,1 +13006,128624010,2,1 +13006,127621020,3,1 +13006,127621030,3,1 +13006,130611010,3,1 +13006,128611020,3,1 +13006,127611010,3,1 +13006,127614010,3,1 +13103,131341020,2,1 +13103,128341010,1,1 +13103,128341020,1,1 +13103,131331020,3,1 +13103,129334010,1,1 +13103,128331010,3,1 +13103,128334010,3,1 +13103,131321010,3,1 +13103,131321020,3,1 +13103,131324010,2,1 +13103,128321010,3,1 +13103,131311010,3,1 +13103,131311020,3,1 +13103,130314020,3,1 +13103,128314010,3,1 +13103,127314010,3,1 +13105,131541020,2,1 +13105,128041020,1,1 +13105,127541010,1,1 +13105,131534010,3,1 +13105,129531020,2,1 +13105,127033010,2,1 +13105,127531010,3,1 +13105,131021010,3,1 +13105,131521010,3,1 +13105,131521020,3,1 +13105,128521010,2,1 +13105,131011010,3,1 +13105,131011020,3,1 +13105,131012010,3,1 +13105,128514010,3,1 +13105,128511030,3,1 +13108,131841020,2,1 +13108,130841010,1,1 +13108,129841020,1,1 +13108,131834010,3,1 +13108,129831010,2,1 +13108,129831020,2,1 +13108,129834010,3,1 +13108,131821020,3,1 +13108,131821030,3,1 +13108,130821020,2,1 +13108,130824010,3,1 +13108,129821010,3,1 +13108,129821020,3,1 +13108,131813010,3,1 +13108,129811020,3,1 +13108,129811030,3,1 +13204,132441010,1,1 +13204,132441020,1,1 +13204,131441030,1,1 +13204,130441010,1,1 +13204,132031010,2,1 +13204,132034010,2,1 +13204,132431010,3,1 +13204,132434010,3,1 +13204,132421010,3,1 +13204,132421020,3,1 +13204,132424010,3,1 +13204,128424010,3,1 +13204,132411010,3,1 +13204,132411030,3,1 +13204,132414010,3,1 +13204,131411020,2,1 +13204,100414010,3,1 +13205,132041020,1,1 +13205,132541020,1,1 +13205,131541030,1,1 +13205,130041010,1,1 +13205,132531010,3,1 +13205,132534010,3,1 +13205,130531020,1,1 +13205,129534010,3,1 +13205,132023010,3,1 +13205,132521010,3,1 +13205,132524010,3,1 +13205,129024010,3,1 +13205,132011010,3,1 +13205,132511030,2,1 +13205,132511020,3,1 +13205,132514010,3,1 +13205,128514010,3,1 +13206,132041010,1,1 +13206,132641010,1,1 +13206,130641030,1,1 +13206,130641020,1,1 +13206,132634010,3,1 +13206,131631020,3,1 +13206,130634010,3,1 +13206,129031020,1,1 +13206,132621020,3,1 +13206,130624010,3,1 +13206,130621020,2,1 +13206,128624010,3,1 +13206,132611020,3,1 +13206,132611030,3,1 +13206,131611030,3,1 +13206,130611010,3,1 +13206,128611020,3,1 \ No newline at end of file diff --git a/SVSim.Bootstrap/Data/build-deck/build_deck_series_master.csv b/SVSim.Bootstrap/Data/build-deck/build_deck_series_master.csv new file mode 100644 index 0000000..c5456bb --- /dev/null +++ b/SVSim.Bootstrap/Data/build-deck/build_deck_series_master.csv @@ -0,0 +1,23 @@ +series_id,series_name,introduction,title_path,drumroll_path +13200,BDSSN_トライアル_32,BDSI_トライアル_32,build_deck_13200_logo_02,build_deck_13200_logo_01 +13100,BDSSN_トライアル_31,BDSI_トライアル_31,build_deck_13100_logo_02,build_deck_13100_logo_01 +13000,BDSSN_トライアル_30,BDSI_トライアル_30,build_deck_13000_logo_02,build_deck_13000_logo_01 +12900,BDSSN_トライアル_29,BDSI_トライアル_29,build_deck_12900_logo_02,build_deck_12900_logo_01 +12800,BDSSN_トライアル_28,BDSI_トライアル_28,build_deck_12800_logo_02,build_deck_12800_logo_01 +12700,BDSSN_トライアル_27,BDSI_トライアル_27,build_deck_12700_logo_02,build_deck_12700_logo_01 +12600,BDSSN_トライアル_26,BDSI_トライアル_26,build_deck_12600_logo_02,build_deck_12600_logo_01 +12500,BDSSN_トライアル_25,BDSI_トライアル_25,build_deck_12500_logo_02,build_deck_12500_logo_01 +12400,BDSSN_トライアル_24,BDSI_トライアル_24,build_deck_12400_logo_02,build_deck_12400_logo_01 +12300,BDSSN_トライアル_23,BDSI_トライアル_23,build_deck_12300_logo_02,build_deck_12300_logo_01 +12200,BDSSN_トライアル_22,BDSI_トライアル_22,build_deck_12200_logo_02,build_deck_12200_logo_01 +12100,BDSSN_トライアル_21,BDSI_トライアル_21,build_deck_12100_logo_02,build_deck_12100_logo_01 +12000,BDSSN_トライアル_20,BDSI_トライアル_20,build_deck_12000_logo_02,build_deck_12000_logo_01 +11900,BDSSN_トライアル_19,BDSI_トライアル_19,build_deck_11900_logo_02,build_deck_11900_logo_01 +10100,BDSSN_テンポラリーデッキ,BDSI_テンポラリーデッキ,build_deck_10100_logo_02,build_deck_10100_logo_01 +107,BDSSN_構築済みデッキ7弾,BDSI_構築済みデッキ7弾,build_deck_107_logo_02,build_deck_107_logo_01 +106,BDSSN_構築済みデッキ6弾,BDSI_構築済みデッキ6弾,build_deck_106_logo_02,build_deck_106_logo_01 +105,BDSSN_構築済みデッキ5弾,BDSI_構築済みデッキ5弾,build_deck_105_logo_02,build_deck_105_logo_01 +104,BDSSN_構築済みデッキ4弾,BDSI_構築済みデッキ4弾,build_deck_104_logo_02,build_deck_104_logo_01 +103,BDSSN_構築済みデッキ3弾,BDSI_構築済みデッキ3弾,build_deck_103_logo_02,build_deck_103_logo_01 +102,BDSSN_構築済みデッキ2弾,BDSI_構築済みデッキ2弾,build_deck_102_logo_02,build_deck_102_logo_01 +101,BDSSN_構築済みデッキ1弾,BDSI_構築済みデッキ1弾,build_deck_101_logo_02,build_deck_101_logo_01 diff --git a/SVSim.Bootstrap/Data/prod-captures/build_deck-info-2026-05-26.json b/SVSim.Bootstrap/Data/prod-captures/build_deck-info-2026-05-26.json new file mode 100644 index 0000000..9b294c6 --- /dev/null +++ b/SVSim.Bootstrap/Data/prod-captures/build_deck-info-2026-05-26.json @@ -0,0 +1 @@ +{"data_headers":{"sid":"8f8942b22bd297c23e28a416a5dca7a21779591266","short_udid":411054851,"viewer_id":906243102,"servertime":1779591266,"result_code":1},"data":{"15":{"series_id":"107","order_id":"16","is_new":false,"products":[{"product_id":"701","product_name":"BDPN_構築済みデッキ7弾_エルフ","leader_id":"1","deck_code":"pd0107","featured_card_id":"115141020","purchase_num_max":1,"purchase_num_current":0,"is_first_price":true,"rewards":{"1":{"reward_type":"6","reward_detail_id":"1151410200","reward_number":"1","message_id":"51004"}},"sales_period_info":[],"price_crystal":"1200"},{"product_id":"702","product_name":"BDPN_構築済みデッキ7弾_ロイヤル","leader_id":"2","deck_code":"pd0207","featured_card_id":"115241020","purchase_num_max":1,"purchase_num_current":0,"is_first_price":true,"rewards":{"1":{"reward_type":"6","reward_detail_id":"1152410200","reward_number":"1","message_id":"51004"}},"sales_period_info":[],"price_crystal":"1200"},{"product_id":"703","product_name":"BDPN_構築済みデッキ7弾_ウィッチ","leader_id":"3","deck_code":"pd0307","featured_card_id":"115341020","purchase_num_max":1,"purchase_num_current":0,"is_first_price":true,"rewards":{"1":{"reward_type":"6","reward_detail_id":"1153410200","reward_number":"1","message_id":"51004"}},"sales_period_info":[],"price_crystal":"1200"},{"product_id":"704","product_name":"BDPN_構築済みデッキ7弾_ドラゴン","leader_id":"4","deck_code":"pd0407","featured_card_id":"115441020","purchase_num_max":1,"purchase_num_current":0,"is_first_price":true,"rewards":{"1":{"reward_type":"6","reward_detail_id":"1154410200","reward_number":"1","message_id":"51004"}},"sales_period_info":[],"price_crystal":"1200"},{"product_id":"705","product_name":"BDPN_構築済みデッキ7弾_ネクロマンサー","leader_id":"5","deck_code":"pd0507","featured_card_id":"115541020","purchase_num_max":1,"purchase_num_current":0,"is_first_price":true,"rewards":{"1":{"reward_type":"6","reward_detail_id":"1155410200","reward_number":"1","message_id":"51004"}},"sales_period_info":[],"price_crystal":"1200"},{"product_id":"706","product_name":"BDPN_構築済みデッキ7弾_ヴァンパイア","leader_id":"6","deck_code":"pd0607","featured_card_id":"115641020","purchase_num_max":1,"purchase_num_current":0,"is_first_price":true,"rewards":{"1":{"reward_type":"6","reward_detail_id":"1156410200","reward_number":"1","message_id":"51004"}},"sales_period_info":[],"price_crystal":"1200"},{"product_id":"707","product_name":"BDPN_構築済みデッキ7弾_ビショップ","leader_id":"7","deck_code":"pd0707","featured_card_id":"115741020","purchase_num_max":1,"purchase_num_current":0,"is_first_price":true,"rewards":{"1":{"reward_type":"6","reward_detail_id":"1157410200","reward_number":"1","message_id":"51004"}},"sales_period_info":[],"price_crystal":"1200"},{"product_id":"708","product_name":"BDPN_構築済みデッキ7弾_ネメシス","leader_id":"8","deck_code":"pd0807","featured_card_id":"115841020","purchase_num_max":1,"purchase_num_current":0,"is_first_price":true,"rewards":{"1":{"reward_type":"6","reward_detail_id":"1158410200","reward_number":"1","message_id":"51004"}},"sales_period_info":[],"price_crystal":"1200"}],"series_rewards":{"1":{"reward_list":[{"reward_type":"5","reward_detail_id":"712014010","reward_number":"3","message_id":51004}],"is_get":false},"2":{"reward_list":[{"reward_type":"5","reward_detail_id":"712031010","reward_number":"3","message_id":51004}],"is_get":false},"3":{"reward_list":[{"reward_type":"6","reward_detail_id":"712014010","reward_number":"1","message_id":51004},{"reward_type":"7","reward_detail_id":"712014015","reward_number":"1","message_id":51004},{"reward_type":"7","reward_detail_id":"712014016","reward_number":"1","message_id":51004},{"reward_type":"7","reward_detail_id":"712014017","reward_number":"1","message_id":51004},{"reward_type":"7","reward_detail_id":"712014018","reward_number":"1","message_id":51004}],"is_get":false},"4":{"reward_list":[{"reward_type":"6","reward_detail_id":"712031010","reward_number":"1","message_id":51004},{"reward_type":"7","reward_detail_id":"712031010","reward_number":"1","message_id":51004}],"is_get":false},"5":{"reward_list":[{"reward_type":"5","reward_detail_id":"712014011","reward_number":"3","message_id":51004}],"is_get":false},"6":{"reward_list":[{"reward_type":"5","reward_detail_id":"712031011","reward_number":"3","message_id":51004}],"is_get":false},"7":{"reward_list":[{"reward_type":"4","reward_detail_id":"1000","reward_number":"1","message_id":51004}],"is_get":false},"8":{"reward_list":[{"reward_type":"4","reward_detail_id":"1000","reward_number":"1","message_id":51004}],"is_get":false}}},"16":{"series_id":"106","order_id":"17","is_new":false,"products":[{"product_id":"601","product_name":"BDPN_構築済みデッキ6弾_エルフ","leader_id":"1","deck_code":"pd0106","featured_card_id":"111141010","purchase_num_max":1,"purchase_num_current":0,"is_first_price":true,"rewards":{"1":{"reward_type":"6","reward_detail_id":"1111410100","reward_number":"1","message_id":"51004"}},"sales_period_info":[],"price_crystal":"1200"},{"product_id":"602","product_name":"BDPN_構築済みデッキ6弾_ロイヤル","leader_id":"2","deck_code":"pd0206","featured_card_id":"112241010","purchase_num_max":1,"purchase_num_current":0,"is_first_price":true,"rewards":{"1":{"reward_type":"6","reward_detail_id":"1122410100","reward_number":"1","message_id":"51004"}},"sales_period_info":[],"price_crystal":"1200"},{"product_id":"603","product_name":"BDPN_構築済みデッキ6弾_ウィッチ","leader_id":"3","deck_code":"pd0306","featured_card_id":"112341020","purchase_num_max":1,"purchase_num_current":0,"is_first_price":true,"rewards":{"1":{"reward_type":"6","reward_detail_id":"1123410200","reward_number":"1","message_id":"51004"}},"sales_period_info":[],"price_crystal":"1200"},{"product_id":"604","product_name":"BDPN_構築済みデッキ6弾_ドラゴン","leader_id":"4","deck_code":"pd0406","featured_card_id":"112441010","purchase_num_max":1,"purchase_num_current":0,"is_first_price":true,"rewards":{"1":{"reward_type":"6","reward_detail_id":"1124410100","reward_number":"1","message_id":"51004"}},"sales_period_info":[],"price_crystal":"1200"},{"product_id":"605","product_name":"BDPN_構築済みデッキ6弾_ネクロマンサー","leader_id":"5","deck_code":"pd0506","featured_card_id":"112541020","purchase_num_max":1,"purchase_num_current":0,"is_first_price":true,"rewards":{"1":{"reward_type":"6","reward_detail_id":"1125410200","reward_number":"1","message_id":"51004"}},"sales_period_info":[],"price_crystal":"1200"},{"product_id":"606","product_name":"BDPN_構築済みデッキ6弾_ヴァンパイア","leader_id":"6","deck_code":"pd0606","featured_card_id":"112641020","purchase_num_max":1,"purchase_num_current":0,"is_first_price":true,"rewards":{"1":{"reward_type":"6","reward_detail_id":"1126410200","reward_number":"1","message_id":"51004"}},"sales_period_info":[],"price_crystal":"1200"},{"product_id":"607","product_name":"BDPN_構築済みデッキ6弾_ビショップ","leader_id":"7","deck_code":"pd0706","featured_card_id":"111741030","purchase_num_max":1,"purchase_num_current":0,"is_first_price":true,"rewards":{"1":{"reward_type":"6","reward_detail_id":"1117410300","reward_number":"1","message_id":"51004"}},"sales_period_info":[],"price_crystal":"1200"},{"product_id":"608","product_name":"BDPN_構築済みデッキ6弾_ネメシス","leader_id":"8","deck_code":"pd0806","featured_card_id":"112841030","purchase_num_max":1,"purchase_num_current":0,"is_first_price":true,"rewards":{"1":{"reward_type":"6","reward_detail_id":"1128410300","reward_number":"1","message_id":"51004"}},"sales_period_info":[],"price_crystal":"1200"}],"series_rewards":{"1":{"reward_list":[{"reward_type":"5","reward_detail_id":"710034010","reward_number":"3","message_id":51004}],"is_get":false},"2":{"reward_list":[{"reward_type":"5","reward_detail_id":"710031010","reward_number":"3","message_id":51004}],"is_get":false},"3":{"reward_list":[{"reward_type":"6","reward_detail_id":"710034010","reward_number":"1","message_id":51004},{"reward_type":"7","reward_detail_id":"710034010","reward_number":"1","message_id":51004}],"is_get":false},"4":{"reward_list":[{"reward_type":"6","reward_detail_id":"710031010","reward_number":"1","message_id":51004},{"reward_type":"7","reward_detail_id":"710031010","reward_number":"1","message_id":51004}],"is_get":false},"5":{"reward_list":[{"reward_type":"5","reward_detail_id":"710034011","reward_number":"3","message_id":51004}],"is_get":false},"6":{"reward_list":[{"reward_type":"5","reward_detail_id":"710031011","reward_number":"3","message_id":51004}],"is_get":false},"7":{"reward_list":[{"reward_type":"4","reward_detail_id":"1000","reward_number":"1","message_id":51004}],"is_get":false},"8":{"reward_list":[{"reward_type":"4","reward_detail_id":"1000","reward_number":"1","message_id":51004}],"is_get":false}}},"17":{"series_id":"105","order_id":"18","is_new":false,"products":[{"product_id":"501","product_name":"BDPN_構築済みデッキ5弾_エルフ","leader_id":"1","deck_code":"pd0105","featured_card_id":"111141020","purchase_num_max":1,"purchase_num_current":0,"is_first_price":true,"rewards":{"1":{"reward_type":"6","reward_detail_id":"1111410200","reward_number":"1","message_id":"51004"}},"sales_period_info":[],"price_crystal":"1200"},{"product_id":"502","product_name":"BDPN_構築済みデッキ5弾_ロイヤル","leader_id":"2","deck_code":"pd0205","featured_card_id":"111241020","purchase_num_max":1,"purchase_num_current":0,"is_first_price":true,"rewards":{"1":{"reward_type":"6","reward_detail_id":"1112410200","reward_number":"1","message_id":"51004"}},"sales_period_info":[],"price_crystal":"1200"},{"product_id":"503","product_name":"BDPN_構築済みデッキ5弾_ウィッチ","leader_id":"3","deck_code":"pd0305","featured_card_id":"111341020","purchase_num_max":1,"purchase_num_current":0,"is_first_price":true,"rewards":{"1":{"reward_type":"6","reward_detail_id":"1113410200","reward_number":"1","message_id":"51004"}},"sales_period_info":[],"price_crystal":"1200"},{"product_id":"504","product_name":"BDPN_構築済みデッキ5弾_ドラゴン","leader_id":"4","deck_code":"pd0405","featured_card_id":"111441020","purchase_num_max":1,"purchase_num_current":0,"is_first_price":true,"rewards":{"1":{"reward_type":"6","reward_detail_id":"1114410200","reward_number":"1","message_id":"51004"}},"sales_period_info":[],"price_crystal":"1200"},{"product_id":"505","product_name":"BDPN_構築済みデッキ5弾_ネクロマンサー","leader_id":"5","deck_code":"pd0505","featured_card_id":"111541020","purchase_num_max":1,"purchase_num_current":0,"is_first_price":true,"rewards":{"1":{"reward_type":"6","reward_detail_id":"1115410200","reward_number":"1","message_id":"51004"}},"sales_period_info":[],"price_crystal":"1200"},{"product_id":"506","product_name":"BDPN_構築済みデッキ5弾_ヴァンパイア","leader_id":"6","deck_code":"pd0605","featured_card_id":"111641020","purchase_num_max":1,"purchase_num_current":0,"is_first_price":true,"rewards":{"1":{"reward_type":"6","reward_detail_id":"1116410200","reward_number":"1","message_id":"51004"}},"sales_period_info":[],"price_crystal":"1200"},{"product_id":"507","product_name":"BDPN_構築済みデッキ5弾_ビショップ","leader_id":"7","deck_code":"pd0705","featured_card_id":"111741020","purchase_num_max":1,"purchase_num_current":0,"is_first_price":true,"rewards":{"1":{"reward_type":"6","reward_detail_id":"1117410200","reward_number":"1","message_id":"51004"}},"sales_period_info":[],"price_crystal":"1200"},{"product_id":"508","product_name":"BDPN_構築済みデッキ5弾_ネメシス","leader_id":"8","deck_code":"pd0805","featured_card_id":"111841020","purchase_num_max":1,"purchase_num_current":0,"is_first_price":true,"rewards":{"1":{"reward_type":"6","reward_detail_id":"1118410200","reward_number":"1","message_id":"51004"}},"sales_period_info":[],"price_crystal":"1200"}],"series_rewards":{"1":{"reward_list":[{"reward_type":"5","reward_detail_id":"707034010","reward_number":"3","message_id":51004}],"is_get":false},"2":{"reward_list":[{"reward_type":"5","reward_detail_id":"707031010","reward_number":"3","message_id":51004}],"is_get":false},"3":{"reward_list":[{"reward_type":"6","reward_detail_id":"707034010","reward_number":"1","message_id":51004},{"reward_type":"7","reward_detail_id":"707034010","reward_number":"1","message_id":51004}],"is_get":false},"4":{"reward_list":[{"reward_type":"6","reward_detail_id":"707031010","reward_number":"1","message_id":51004},{"reward_type":"7","reward_detail_id":"707031010","reward_number":"1","message_id":51004}],"is_get":false},"5":{"reward_list":[{"reward_type":"5","reward_detail_id":"707034011","reward_number":"3","message_id":51004}],"is_get":false},"6":{"reward_list":[{"reward_type":"5","reward_detail_id":"707031011","reward_number":"3","message_id":51004}],"is_get":false},"7":{"reward_list":[{"reward_type":"4","reward_detail_id":"1000","reward_number":"1","message_id":51004}],"is_get":false},"8":{"reward_list":[{"reward_type":"4","reward_detail_id":"1000","reward_number":"1","message_id":51004}],"is_get":false}}},"18":{"series_id":"104","order_id":"19","is_new":false,"products":[{"product_id":"401","product_name":"BDPN_構築済みデッキ4弾_エルフ","leader_id":"1","deck_code":"pd0104","featured_card_id":"705114010","purchase_num_max":1,"purchase_num_current":0,"is_first_price":true,"rewards":{"1":{"reward_type":"7","reward_detail_id":"705114010","reward_number":"1","message_id":"51004"},"2":{"reward_type":"6","reward_detail_id":"705114010","reward_number":"1","message_id":"51004"},"3":{"reward_type":"5","reward_detail_id":"705114010","reward_number":"3","message_id":"51004"}},"sales_period_info":[],"price_crystal":"800"},{"product_id":"402","product_name":"BDPN_構築済みデッキ4弾_ロイヤル","leader_id":"2","deck_code":"pd0204","featured_card_id":"705214010","purchase_num_max":1,"purchase_num_current":0,"is_first_price":true,"rewards":{"1":{"reward_type":"7","reward_detail_id":"705214010","reward_number":"1","message_id":"51004"},"2":{"reward_type":"6","reward_detail_id":"705214010","reward_number":"1","message_id":"51004"},"3":{"reward_type":"5","reward_detail_id":"705214010","reward_number":"3","message_id":"51004"}},"sales_period_info":[],"price_crystal":"800"},{"product_id":"403","product_name":"BDPN_構築済みデッキ4弾_ウィッチ","leader_id":"3","deck_code":"pd0304","featured_card_id":"705314010","purchase_num_max":1,"purchase_num_current":0,"is_first_price":true,"rewards":{"1":{"reward_type":"7","reward_detail_id":"705314010","reward_number":"1","message_id":"51004"},"2":{"reward_type":"6","reward_detail_id":"705314010","reward_number":"1","message_id":"51004"},"3":{"reward_type":"5","reward_detail_id":"705314010","reward_number":"3","message_id":"51004"}},"sales_period_info":[],"price_crystal":"800"},{"product_id":"404","product_name":"BDPN_構築済みデッキ4弾_ドラゴン","leader_id":"4","deck_code":"pd0404","featured_card_id":"705414010","purchase_num_max":1,"purchase_num_current":0,"is_first_price":true,"rewards":{"1":{"reward_type":"7","reward_detail_id":"705414010","reward_number":"1","message_id":"51004"},"2":{"reward_type":"6","reward_detail_id":"705414010","reward_number":"1","message_id":"51004"},"3":{"reward_type":"5","reward_detail_id":"705414010","reward_number":"3","message_id":"51004"}},"sales_period_info":[],"price_crystal":"800"},{"product_id":"405","product_name":"BDPN_構築済みデッキ4弾_ネクロマンサー","leader_id":"5","deck_code":"pd0504","featured_card_id":"705514010","purchase_num_max":1,"purchase_num_current":0,"is_first_price":true,"rewards":{"1":{"reward_type":"7","reward_detail_id":"705514010","reward_number":"1","message_id":"51004"},"2":{"reward_type":"6","reward_detail_id":"705514010","reward_number":"1","message_id":"51004"},"3":{"reward_type":"5","reward_detail_id":"705514010","reward_number":"3","message_id":"51004"}},"sales_period_info":[],"price_crystal":"800"},{"product_id":"406","product_name":"BDPN_構築済みデッキ4弾_ヴァンパイア","leader_id":"6","deck_code":"pd0604","featured_card_id":"705614020","purchase_num_max":1,"purchase_num_current":0,"is_first_price":true,"rewards":{"1":{"reward_type":"7","reward_detail_id":"705614020","reward_number":"1","message_id":"51004"},"2":{"reward_type":"6","reward_detail_id":"705614020","reward_number":"1","message_id":"51004"},"3":{"reward_type":"5","reward_detail_id":"705614020","reward_number":"3","message_id":"51004"}},"sales_period_info":[],"price_crystal":"800"},{"product_id":"407","product_name":"BDPN_構築済みデッキ4弾_ビショップ","leader_id":"7","deck_code":"pd0704","featured_card_id":"705714010","purchase_num_max":1,"purchase_num_current":0,"is_first_price":true,"rewards":{"1":{"reward_type":"7","reward_detail_id":"705714010","reward_number":"1","message_id":"51004"},"2":{"reward_type":"6","reward_detail_id":"705714010","reward_number":"1","message_id":"51004"},"3":{"reward_type":"5","reward_detail_id":"705714010","reward_number":"3","message_id":"51004"}},"sales_period_info":[],"price_crystal":"800"},{"product_id":"408","product_name":"BDPN_構築済みデッキ4弾_ネメシス","leader_id":"8","deck_code":"pd0804","featured_card_id":"705814010","purchase_num_max":1,"purchase_num_current":0,"is_first_price":true,"rewards":{"1":{"reward_type":"7","reward_detail_id":"705814010","reward_number":"1","message_id":"51004"},"2":{"reward_type":"6","reward_detail_id":"705814010","reward_number":"1","message_id":"51004"},"3":{"reward_type":"5","reward_detail_id":"705814010","reward_number":"3","message_id":"51004"}},"sales_period_info":[],"price_crystal":"800"}],"series_rewards":[]},"19":{"series_id":"103","order_id":"20","is_new":false,"products":[{"product_id":"301","product_name":"BDPN_構築済みデッキ3弾_エルフ","leader_id":"1","deck_code":"pd0103","featured_card_id":"106141010","purchase_num_max":1,"purchase_num_current":0,"is_first_price":true,"rewards":{"1":{"reward_type":"6","reward_detail_id":"1061410100","reward_number":"1","message_id":"51004"}},"sales_period_info":[],"price_crystal":"800"},{"product_id":"302","product_name":"BDPN_構築済みデッキ3弾_ロイヤル","leader_id":"2","deck_code":"pd0203","featured_card_id":"106241010","purchase_num_max":1,"purchase_num_current":0,"is_first_price":true,"rewards":{"1":{"reward_type":"6","reward_detail_id":"1062410100","reward_number":"1","message_id":"51004"}},"sales_period_info":[],"price_crystal":"800"},{"product_id":"303","product_name":"BDPN_構築済みデッキ3弾_ウィッチ","leader_id":"3","deck_code":"pd0303","featured_card_id":"105341010","purchase_num_max":1,"purchase_num_current":0,"is_first_price":true,"rewards":{"1":{"reward_type":"6","reward_detail_id":"1053410100","reward_number":"1","message_id":"51004"}},"sales_period_info":[],"price_crystal":"800"},{"product_id":"304","product_name":"BDPN_構築済みデッキ3弾_ドラゴン","leader_id":"4","deck_code":"pd0403","featured_card_id":"106441010","purchase_num_max":1,"purchase_num_current":0,"is_first_price":true,"rewards":{"1":{"reward_type":"6","reward_detail_id":"1064410100","reward_number":"1","message_id":"51004"}},"sales_period_info":[],"price_crystal":"800"},{"product_id":"305","product_name":"BDPN_構築済みデッキ3弾_ネクロマンサー","leader_id":"5","deck_code":"pd0503","featured_card_id":"106541020","purchase_num_max":1,"purchase_num_current":0,"is_first_price":true,"rewards":{"1":{"reward_type":"6","reward_detail_id":"1065410200","reward_number":"1","message_id":"51004"}},"sales_period_info":[],"price_crystal":"800"},{"product_id":"306","product_name":"BDPN_構築済みデッキ3弾_ヴァンパイア","leader_id":"6","deck_code":"pd0603","featured_card_id":"105641020","purchase_num_max":1,"purchase_num_current":0,"is_first_price":true,"rewards":{"1":{"reward_type":"6","reward_detail_id":"1056410200","reward_number":"1","message_id":"51004"}},"sales_period_info":[],"price_crystal":"800"},{"product_id":"307","product_name":"BDPN_構築済みデッキ3弾_ビショップ","leader_id":"7","deck_code":"pd0703","featured_card_id":"106741010","purchase_num_max":1,"purchase_num_current":0,"is_first_price":true,"rewards":{"1":{"reward_type":"6","reward_detail_id":"1067410100","reward_number":"1","message_id":"51004"}},"sales_period_info":[],"price_crystal":"800"}],"series_rewards":[]},"20":{"series_id":"102","order_id":"21","is_new":false,"products":[{"product_id":"201","product_name":"BDPN_構築済みデッキ2弾_エルフ","leader_id":"1","deck_code":"pd0102","featured_card_id":"703141011","purchase_num_max":3,"purchase_num_current":0,"is_first_price":true,"rewards":{"1":{"reward_type":"7","reward_detail_id":"703141011","reward_number":"1","message_id":"51004"},"2":{"reward_type":"6","reward_detail_id":"703141011","reward_number":"1","message_id":"51004"}},"sales_period_info":[],"price_crystal":"500"},{"product_id":"202","product_name":"BDPN_構築済みデッキ2弾_ロイヤル","leader_id":"2","deck_code":"pd0202","featured_card_id":"703241011","purchase_num_max":3,"purchase_num_current":0,"is_first_price":true,"rewards":{"1":{"reward_type":"7","reward_detail_id":"703241011","reward_number":"1","message_id":"51004"},"2":{"reward_type":"6","reward_detail_id":"703241011","reward_number":"1","message_id":"51004"}},"sales_period_info":[],"price_crystal":"500"},{"product_id":"203","product_name":"BDPN_構築済みデッキ2弾_ウィッチ","leader_id":"3","deck_code":"pd0302","featured_card_id":"703341011","purchase_num_max":3,"purchase_num_current":0,"is_first_price":true,"rewards":{"1":{"reward_type":"7","reward_detail_id":"703341011","reward_number":"1","message_id":"51004"},"2":{"reward_type":"6","reward_detail_id":"703341011","reward_number":"1","message_id":"51004"}},"sales_period_info":[],"price_crystal":"500"},{"product_id":"204","product_name":"BDPN_構築済みデッキ2弾_ドラゴン","leader_id":"4","deck_code":"pd0402","featured_card_id":"703441011","purchase_num_max":3,"purchase_num_current":3,"is_first_price":false,"rewards":{"1":{"reward_type":"7","reward_detail_id":"703441011","reward_number":"1","message_id":"51004"},"2":{"reward_type":"6","reward_detail_id":"703441011","reward_number":"1","message_id":"51004"}},"sales_period_info":[],"price_crystal":"750"},{"product_id":"205","product_name":"BDPN_構築済みデッキ2弾_ネクロマンサー","leader_id":"5","deck_code":"pd0502","featured_card_id":"703541011","purchase_num_max":3,"purchase_num_current":0,"is_first_price":true,"rewards":{"1":{"reward_type":"7","reward_detail_id":"703541011","reward_number":"1","message_id":"51004"},"2":{"reward_type":"6","reward_detail_id":"703541011","reward_number":"1","message_id":"51004"}},"sales_period_info":[],"price_crystal":"500"},{"product_id":"206","product_name":"BDPN_構築済みデッキ2弾_ヴァンパイア","leader_id":"6","deck_code":"pd0602","featured_card_id":"703641011","purchase_num_max":3,"purchase_num_current":3,"is_first_price":false,"rewards":{"1":{"reward_type":"7","reward_detail_id":"703641011","reward_number":"1","message_id":"51004"},"2":{"reward_type":"6","reward_detail_id":"703641011","reward_number":"1","message_id":"51004"}},"sales_period_info":[],"price_crystal":"750"},{"product_id":"207","product_name":"BDPN_構築済みデッキ2弾_ビショップ","leader_id":"7","deck_code":"pd0702","featured_card_id":"703741011","purchase_num_max":3,"purchase_num_current":0,"is_first_price":true,"rewards":{"1":{"reward_type":"7","reward_detail_id":"703741011","reward_number":"1","message_id":"51004"},"2":{"reward_type":"6","reward_detail_id":"703741011","reward_number":"1","message_id":"51004"}},"sales_period_info":[],"price_crystal":"500"}],"series_rewards":[]},"21":{"series_id":"101","order_id":"22","is_new":false,"products":[{"product_id":"1","product_name":"BDPN_構築済みデッキ1弾_エルフ","leader_id":"1","deck_code":"pd0101","featured_card_id":"701141011","purchase_num_max":3,"purchase_num_current":0,"is_first_price":true,"rewards":{"1":{"reward_type":"7","reward_detail_id":"701141011","reward_number":"1","message_id":"51004"},"2":{"reward_type":"6","reward_detail_id":"701141011","reward_number":"1","message_id":"51004"}},"sales_period_info":[],"price_crystal":"500"},{"product_id":"2","product_name":"BDPN_構築済みデッキ1弾_ロイヤル","leader_id":"2","deck_code":"pd0201","featured_card_id":"701241011","purchase_num_max":3,"purchase_num_current":0,"is_first_price":true,"rewards":{"1":{"reward_type":"7","reward_detail_id":"701241011","reward_number":"1","message_id":"51004"},"2":{"reward_type":"6","reward_detail_id":"701241011","reward_number":"1","message_id":"51004"}},"sales_period_info":[],"price_crystal":"500"},{"product_id":"3","product_name":"BDPN_構築済みデッキ1弾_ウィッチ","leader_id":"3","deck_code":"pd0301","featured_card_id":"701341011","purchase_num_max":3,"purchase_num_current":0,"is_first_price":true,"rewards":{"1":{"reward_type":"7","reward_detail_id":"701341011","reward_number":"1","message_id":"51004"},"2":{"reward_type":"6","reward_detail_id":"701341011","reward_number":"1","message_id":"51004"}},"sales_period_info":[],"price_crystal":"500"},{"product_id":"4","product_name":"BDPN_構築済みデッキ1弾_ドラゴン","leader_id":"4","deck_code":"pd0401","featured_card_id":"701441011","purchase_num_max":3,"purchase_num_current":3,"is_first_price":false,"rewards":{"1":{"reward_type":"7","reward_detail_id":"701441011","reward_number":"1","message_id":"51004"},"2":{"reward_type":"6","reward_detail_id":"701441011","reward_number":"1","message_id":"51004"}},"sales_period_info":[],"price_crystal":"750"},{"product_id":"5","product_name":"BDPN_構築済みデッキ1弾_ネクロマンサー","leader_id":"5","deck_code":"pd0501","featured_card_id":"701541011","purchase_num_max":3,"purchase_num_current":0,"is_first_price":true,"rewards":{"1":{"reward_type":"7","reward_detail_id":"701541011","reward_number":"1","message_id":"51004"},"2":{"reward_type":"6","reward_detail_id":"701541011","reward_number":"1","message_id":"51004"}},"sales_period_info":[],"price_crystal":"500"},{"product_id":"6","product_name":"BDPN_構築済みデッキ1弾_ヴァンパイア","leader_id":"6","deck_code":"pd0601","featured_card_id":"701641011","purchase_num_max":3,"purchase_num_current":0,"is_first_price":true,"rewards":{"1":{"reward_type":"7","reward_detail_id":"701641011","reward_number":"1","message_id":"51004"},"2":{"reward_type":"6","reward_detail_id":"701641011","reward_number":"1","message_id":"51004"}},"sales_period_info":[],"price_crystal":"500"},{"product_id":"7","product_name":"BDPN_構築済みデッキ1弾_ビショップ","leader_id":"7","deck_code":"pd0701","featured_card_id":"701741011","purchase_num_max":3,"purchase_num_current":0,"is_first_price":true,"rewards":{"1":{"reward_type":"7","reward_detail_id":"701741011","reward_number":"1","message_id":"51004"},"2":{"reward_type":"6","reward_detail_id":"701741011","reward_number":"1","message_id":"51004"}},"sales_period_info":[],"price_crystal":"500"}],"series_rewards":[]}}} diff --git a/SVSim.Bootstrap/Data/prod-captures/deck-info-2026-05-23.json b/SVSim.Bootstrap/Data/prod-captures/deck-info-2026-05-23.json index 07e23cb..d22023c 100644 --- a/SVSim.Bootstrap/Data/prod-captures/deck-info-2026-05-23.json +++ b/SVSim.Bootstrap/Data/prod-captures/deck-info-2026-05-23.json @@ -457,7 +457,7 @@ "4": { "class_id": 4, "is_random_leader_skin": 0, - "leader_skin_id": 104 + "leader_skin_id": 4 }, "5": { "class_id": 5, @@ -467,7 +467,7 @@ "6": { "class_id": 6, "is_random_leader_skin": 0, - "leader_skin_id": 106 + "leader_skin_id": 6 }, "7": { "class_id": 7, diff --git a/SVSim.Bootstrap/Importers/BuildDeckImporter.cs b/SVSim.Bootstrap/Importers/BuildDeckImporter.cs new file mode 100644 index 0000000..9e11a89 --- /dev/null +++ b/SVSim.Bootstrap/Importers/BuildDeckImporter.cs @@ -0,0 +1,366 @@ +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using SVSim.Database; +using SVSim.Database.Models; +using static SVSim.Bootstrap.Importers.ImporterBase; + +namespace SVSim.Bootstrap.Importers; + +/// +/// Loads the prebuilt-deck catalog from a mix of client-master CSVs and one prod-capture JSON. +/// Three methods run in dependency order (see Bootstrap/Program.cs): +/// 1. ImportSeriesAsync — build_deck_series_master.csv → 22 series rows (all IsEnabled=false initially) +/// 2. ImportCatalogAsync — prod-captures/build_deck-info-*.json → enriches 7 series + 53 products (Task 15) +/// 3. ImportPackageAsync — build_deck_package_master.csv → card lists for all 112 products, +/// creates disabled stubs for products not seeded by the catalog importer +/// Idempotent — re-runnable on the same files. +/// +public class BuildDeckImporter +{ + private const string BuildDeckSubdir = "build-deck"; + + public async Task ImportSeriesAsync(SVSimDbContext db, string dataDir) + { + string csvPath = Path.Combine(dataDir, BuildDeckSubdir, "build_deck_series_master.csv"); + if (!File.Exists(csvPath)) + { + Console.Error.WriteLine($"[BuildDeckImporter] series CSV missing: {csvPath}"); + return 0; + } + + var rows = ReadCsv(csvPath).Skip(1).ToList(); // skip header + int created = 0, updated = 0; + + var existing = await db.BuildDeckSeries.ToDictionaryAsync(s => s.Id); + foreach (var cols in rows) + { + if (cols.Length < 5) continue; + if (!int.TryParse(cols[0], out int id)) continue; + + if (existing.TryGetValue(id, out var row)) + { + // Update CSV-derived fields; do not flip IsEnabled or OrderIndex (catalog importer owns those) + bool changed = false; + if (row.NameKey != cols[1]) { row.NameKey = cols[1]; changed = true; } + if (row.IntroKey != cols[2]) { row.IntroKey = cols[2]; changed = true; } + if (row.TitlePath != cols[3]) { row.TitlePath = cols[3]; changed = true; } + if (row.DrumrollPath != cols[4]) { row.DrumrollPath = cols[4]; changed = true; } + if (changed) updated++; + } + else + { + db.BuildDeckSeries.Add(new BuildDeckSeriesEntry + { + Id = id, + NameKey = cols[1], + IntroKey = cols[2], + TitlePath = cols[3], + DrumrollPath = cols[4], + OrderIndex = 0, + IsNew = false, + IsEnabled = false, + }); + created++; + } + } + await db.SaveChangesAsync(); + Console.WriteLine($"[BuildDeckImporter] Series: created={created}, updated={updated}"); + return created + updated; + } + + public async Task ImportPackageAsync(SVSimDbContext db, string dataDir) + { + string csvPath = Path.Combine(dataDir, BuildDeckSubdir, "build_deck_package_master.csv"); + if (!File.Exists(csvPath)) + { + Console.Error.WriteLine($"[BuildDeckImporter] package CSV missing: {csvPath}"); + return 0; + } + + var rows = ReadCsv(csvPath).Skip(1).ToList(); // header: product_id,card_id,number,is_spot + var byProduct = rows + .Where(c => c.Length >= 4) + .GroupBy(c => int.Parse(c[0])) + .ToDictionary(g => g.Key, g => g.Select(c => new BuildDeckProductCardEntry + { + CardId = long.Parse(c[1]), + Number = int.Parse(c[2]), + IsSpot = int.Parse(c[3]) != 0, + }).ToList()); + + // Load existing products (we may have stubs from a prior run or rows created by catalog importer) + var existing = await db.BuildDeckProducts.Include(p => p.Cards).ToDictionaryAsync(p => p.Id); + int created = 0, updated = 0; + + foreach (var (productId, cardEntries) in byProduct) + { + if (existing.TryGetValue(productId, out var product)) + { + // Replace card list wholesale — CSV is authoritative. + product.Cards.Clear(); + foreach (var c in cardEntries) product.Cards.Add(c); + updated++; + } + else + { + int? seriesId = InferSeriesId(productId); + if (seriesId is null) + { + Console.Error.WriteLine($"[BuildDeckImporter] product {productId} has no inferable series; skipping"); + continue; + } + db.BuildDeckProducts.Add(new BuildDeckProductEntry + { + Id = productId, + SeriesId = seriesId.Value, + LeaderId = 0, + DeckCode = string.Empty, + ProductNameKey = string.Empty, + FeaturedCardId = 0, + PurchaseNumMax = 1, + IntroPriceCrystal = null, + RegularPriceCrystal = null, + IntroPriceRupy = null, + RegularPriceRupy = null, + IsEnabled = false, + Cards = cardEntries, + }); + created++; + } + } + await db.SaveChangesAsync(); + Console.WriteLine($"[BuildDeckImporter] Package: created={created}, updated={updated}"); + return created + updated; + } + + public async Task ImportCatalogAsync(SVSimDbContext db, string capturesDir) + { + var data = LoadCapture(capturesDir, "build_deck-info"); + if (data is null) return 0; + + int touchedSeries = 0, touchedProducts = 0; + + // Load existing rows for fast lookup + var existingSeries = await db.BuildDeckSeries + .Include(s => s.SeriesRewards) + .ToDictionaryAsync(s => s.Id); + var existingProducts = await db.BuildDeckProducts + .Include(p => p.Rewards) + .ToDictionaryAsync(p => p.Id); + + // The captured data root is an object keyed by order_id string ("15"…"21"); iterate values. + foreach (var seriesNode in data.Value.EnumerateObject()) + { + var s = seriesNode.Value; + int seriesId = GetInt(s, "series_id"); + int orderId = GetInt(s, "order_id"); + bool isNew = GetBool(s, "is_new"); + + if (!existingSeries.TryGetValue(seriesId, out var seriesRow)) + { + // Catalog runs before package importer in production, so series rows from the series + // CSV should already exist. If not (e.g. the capture has a series the CSV doesn't), + // create a bare row so the FK from products holds. + seriesRow = new BuildDeckSeriesEntry + { + Id = seriesId, NameKey = string.Empty, IntroKey = string.Empty, + TitlePath = string.Empty, DrumrollPath = string.Empty, + }; + db.BuildDeckSeries.Add(seriesRow); + existingSeries[seriesId] = seriesRow; + } + seriesRow.OrderIndex = orderId; + seriesRow.IsNew = isNew; + seriesRow.IsEnabled = true; + + // Series rewards: replace wholesale (capture is authoritative for enabled series) + seriesRow.SeriesRewards.Clear(); + if (s.TryGetProperty("series_rewards", out var seriesRewards) && + seriesRewards.ValueKind == JsonValueKind.Object) + { + foreach (var tier in seriesRewards.EnumerateObject()) + { + if (!int.TryParse(tier.Name, out int tierIndex)) continue; + if (!tier.Value.TryGetProperty("reward_list", out var rewardList) || + rewardList.ValueKind != JsonValueKind.Array) continue; + + int itemIndex = 0; + foreach (var r in rewardList.EnumerateArray()) + { + seriesRow.SeriesRewards.Add(new BuildDeckSeriesRewardEntry + { + TierIndex = tierIndex, + ItemIndex = itemIndex++, + RewardType = GetInt(r, "reward_type"), + RewardDetailId = GetLong(r, "reward_detail_id"), + RewardNumber = GetInt(r, "reward_number"), + MessageId = GetInt(r, "message_id"), + }); + } + } + } + touchedSeries++; + + // Products + if (!s.TryGetProperty("products", out var products) || products.ValueKind != JsonValueKind.Array) + continue; + + // First pass: parse each captured product, track intro/regular tiers per product. + var capturedThisSeries = new List(); + foreach (var p in products.EnumerateArray()) + { + int productId = GetInt(p, "product_id"); + + if (!existingProducts.TryGetValue(productId, out var productRow)) + { + productRow = new BuildDeckProductEntry { Id = productId, SeriesId = seriesId }; + db.BuildDeckProducts.Add(productRow); + existingProducts[productId] = productRow; + } + productRow.SeriesId = seriesId; + productRow.LeaderId = GetInt(p, "leader_id"); + productRow.DeckCode = GetString(p, "deck_code"); + productRow.ProductNameKey = GetString(p, "product_name"); + productRow.FeaturedCardId = GetLong(p, "featured_card_id"); + productRow.PurchaseNumMax = GetInt(p, "purchase_num_max"); + productRow.IsEnabled = true; + + bool isFirstPrice = GetBool(p, "is_first_price"); + + // Tier-aware price ingestion: each captured row has ONE price tier (intro OR regular). + int? priceCrystal = p.TryGetProperty("price_crystal", out var pc) && pc.ValueKind != JsonValueKind.Null + ? (int?)GetInt(p, "price_crystal") : null; + int? priceRupy = p.TryGetProperty("price_rupy", out var pr) && pr.ValueKind != JsonValueKind.Null + ? (int?)GetInt(p, "price_rupy") : null; + + if (priceCrystal is not null) + { + if (isFirstPrice) productRow.IntroPriceCrystal = priceCrystal; + else productRow.RegularPriceCrystal = priceCrystal; + } + if (priceRupy is not null) + { + if (isFirstPrice) productRow.IntroPriceRupy = priceRupy; + else productRow.RegularPriceRupy = priceRupy; + } + + // Product rewards: replace wholesale + productRow.Rewards.Clear(); + if (p.TryGetProperty("rewards", out var rewards) && rewards.ValueKind == JsonValueKind.Object) + { + foreach (var r in rewards.EnumerateObject()) + { + if (!int.TryParse(r.Name, out int idx)) continue; + productRow.Rewards.Add(new BuildDeckProductRewardEntry + { + RewardIndex = idx, + RewardType = GetInt(r.Value, "reward_type"), + RewardDetailId = GetLong(r.Value, "reward_detail_id"), + RewardNumber = GetInt(r.Value, "reward_number"), + MessageId = GetInt(r.Value, "message_id"), + }); + } + } + + capturedThisSeries.Add(productRow); + touchedProducts++; + } + + // Second pass: backfill missing tier per-series when sibling products share a unique value. + BackfillSeriesTier(capturedThisSeries); + } + + await db.SaveChangesAsync(); + Console.WriteLine($"[BuildDeckImporter] Catalog: series={touchedSeries}, products={touchedProducts}"); + return touchedSeries + touchedProducts; + } + + private static void BackfillSeriesTier(IReadOnlyList productsInSeries) + { + // For each (Currency, Tier) pair, if all populated values across siblings are the same, + // propagate that value to products that are missing the corresponding tier. + BackfillIntroCrystal(productsInSeries); + BackfillRegularCrystal(productsInSeries); + BackfillIntroRupy(productsInSeries); + BackfillRegularRupy(productsInSeries); + } + + private static void BackfillIntroCrystal(IReadOnlyList products) + { + var distinct = products.Where(p => p.IntroPriceCrystal.HasValue).Select(p => p.IntroPriceCrystal!.Value).Distinct().ToList(); + if (distinct.Count != 1) return; + int value = distinct[0]; + foreach (var p in products) + { + if (p.IntroPriceCrystal is null) p.IntroPriceCrystal = value; + } + } + + private static void BackfillRegularCrystal(IReadOnlyList products) + { + var distinct = products.Where(p => p.RegularPriceCrystal.HasValue).Select(p => p.RegularPriceCrystal!.Value).Distinct().ToList(); + if (distinct.Count != 1) return; + int value = distinct[0]; + foreach (var p in products) + { + // For PurchaseNumMax == 1 products, never backfill the Regular tier — they have no second buy. + if (p.PurchaseNumMax <= 1) continue; + if (p.RegularPriceCrystal is null) p.RegularPriceCrystal = value; + } + } + + private static void BackfillIntroRupy(IReadOnlyList products) + { + var distinct = products.Where(p => p.IntroPriceRupy.HasValue).Select(p => p.IntroPriceRupy!.Value).Distinct().ToList(); + if (distinct.Count != 1) return; + int value = distinct[0]; + foreach (var p in products) + { + if (p.IntroPriceRupy is null) p.IntroPriceRupy = value; + } + } + + private static void BackfillRegularRupy(IReadOnlyList products) + { + var distinct = products.Where(p => p.RegularPriceRupy.HasValue).Select(p => p.RegularPriceRupy!.Value).Distinct().ToList(); + if (distinct.Count != 1) return; + int value = distinct[0]; + foreach (var p in products) + { + if (p.PurchaseNumMax <= 1) continue; + if (p.RegularPriceRupy is null) p.RegularPriceRupy = value; + } + } + + /// + /// Maps a product_id to its series_id using the numeric pattern derived from the /info capture + /// and CSV inspection. + /// Sets 1–7: products 1–7, 201–299, 301–399, 401–499, 501–599, 601–699, 701–799 → series 101–107 + /// Temporary Deck: products 10001–10099 → series 10100 + /// Trial series: products NNxx where NN in [119,…,132] → series NN00 (divide-by-100 * 100) + /// + internal static int? InferSeriesId(int productId) => productId switch + { + >= 1 and <= 7 => 101, + >= 201 and <= 299 => 102, + >= 301 and <= 399 => 103, + >= 401 and <= 499 => 104, + >= 501 and <= 599 => 105, + >= 601 and <= 699 => 106, + >= 701 and <= 799 => 107, + >= 10001 and <= 10099 => 10100, + >= 11901 and <= 13299 => (productId / 100) * 100, + _ => null, + }; + + private static IEnumerable ReadCsv(string path) + { + foreach (var raw in File.ReadAllLines(path, System.Text.Encoding.UTF8)) + { + // Strip UTF-8 BOM on the first line if present + var line = raw.TrimStart(''); + if (string.IsNullOrWhiteSpace(line)) continue; + yield return line.Split(','); + } + } +} diff --git a/SVSim.Bootstrap/Program.cs b/SVSim.Bootstrap/Program.cs index c210d83..e6f2487 100644 --- a/SVSim.Bootstrap/Program.cs +++ b/SVSim.Bootstrap/Program.cs @@ -76,6 +76,14 @@ public static class Program if (!opts.SkipGlobals) { await new GlobalsImporter().ImportAllAsync(context, opts.CapturesDir); + + // 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). + var buildDeck = new BuildDeckImporter(); + await buildDeck.ImportSeriesAsync(context, opts.ReferenceDataDir); + await buildDeck.ImportCatalogAsync(context, opts.CapturesDir); + await buildDeck.ImportPackageAsync(context, opts.ReferenceDataDir); } else { diff --git a/SVSim.Bootstrap/SVSim.Bootstrap.csproj b/SVSim.Bootstrap/SVSim.Bootstrap.csproj index 5573a3d..dc5bf95 100644 --- a/SVSim.Bootstrap/SVSim.Bootstrap.csproj +++ b/SVSim.Bootstrap/SVSim.Bootstrap.csproj @@ -16,6 +16,9 @@ PreserveNewest + + PreserveNewest + diff --git a/SVSim.Database/Migrations/20260526043148_AddBuildDeck.Designer.cs b/SVSim.Database/Migrations/20260526043148_AddBuildDeck.Designer.cs new file mode 100644 index 0000000..e6dfa31 --- /dev/null +++ b/SVSim.Database/Migrations/20260526043148_AddBuildDeck.Designer.cs @@ -0,0 +1,2834 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using SVSim.Database; + +#nullable disable + +namespace SVSim.Database.Migrations +{ + [DbContext(typeof(SVSimDbContext))] + [Migration("20260526043148_AddBuildDeck")] + partial class AddBuildDeck + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.HasSequence("ShortUdidSequence") + .StartsAt(400000000L); + + modelBuilder.Entity("DegreeEntryViewer", b => + { + b.Property("DegreesId") + .HasColumnType("integer"); + + b.Property("ViewersId") + .HasColumnType("bigint"); + + b.HasKey("DegreesId", "ViewersId"); + + b.HasIndex("ViewersId"); + + b.ToTable("DegreeEntryViewer"); + }); + + modelBuilder.Entity("EmblemEntryViewer", b => + { + b.Property("EmblemsId") + .HasColumnType("integer"); + + b.Property("ViewersId") + .HasColumnType("bigint"); + + b.HasKey("EmblemsId", "ViewersId"); + + b.HasIndex("ViewersId"); + + b.ToTable("EmblemEntryViewer"); + }); + + modelBuilder.Entity("LeaderSkinEntryViewer", b => + { + b.Property("LeaderSkinsId") + .HasColumnType("integer"); + + b.Property("ViewersId") + .HasColumnType("bigint"); + + b.HasKey("LeaderSkinsId", "ViewersId"); + + b.HasIndex("ViewersId"); + + b.ToTable("LeaderSkinEntryViewer"); + }); + + modelBuilder.Entity("MyPageBackgroundEntryViewer", b => + { + b.Property("MyPageBackgroundsId") + .HasColumnType("integer"); + + b.Property("ViewersId") + .HasColumnType("bigint"); + + b.HasKey("MyPageBackgroundsId", "ViewersId"); + + b.HasIndex("ViewersId"); + + b.ToTable("MyPageBackgroundEntryViewer"); + }); + + modelBuilder.Entity("SVSim.Database.Entities.Story.SpecialBattleSetting", b => + { + b.Property("Id") + .HasColumnType("integer"); + + b.Property("BanishEffectOverride") + .IsRequired() + .HasColumnType("text"); + + b.Property("ClassDestroyEffectOverride") + .HasColumnType("integer"); + + b.Property("EnemyAttachSkill") + .IsRequired() + .HasColumnType("text"); + + b.Property("EnemyStartLife") + .HasColumnType("integer"); + + b.Property("EnemyStartPp") + .HasColumnType("integer"); + + b.Property("IdOverrideInBattleLog") + .IsRequired() + .HasColumnType("text"); + + b.Property("Note") + .HasColumnType("text"); + + b.Property("PlayerAttachSkill") + .IsRequired() + .HasColumnType("text"); + + b.Property("PlayerFirstTurn") + .HasColumnType("integer"); + + b.Property("PlayerStartLife") + .HasColumnType("integer"); + + b.Property("PlayerStartPp") + .HasColumnType("integer"); + + b.Property("ResultSkip") + .HasColumnType("integer"); + + b.Property("SpecialTokenDrawEffectOverride") + .IsRequired() + .HasColumnType("text"); + + b.Property("TokenDrawEffectOverride") + .IsRequired() + .HasColumnType("text"); + + b.Property("VsEffectOverride") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("SpecialBattleSettings"); + }); + + modelBuilder.Entity("SVSim.Database.Entities.Story.StoryChapter", b => + { + b.Property("StoryId") + .HasColumnType("integer"); + + b.Property("Battle3dFieldId") + .HasColumnType("integer"); + + b.Property("BattleExists") + .HasColumnType("boolean"); + + b.Property("BgFileName") + .IsRequired() + .HasColumnType("text"); + + b.Property("BgmId") + .IsRequired() + .HasColumnType("text"); + + b.Property("ChapterClearTextId") + .HasColumnType("text"); + + b.Property("ChapterEffectPath") + .HasColumnType("text"); + + b.Property("ChapterId") + .IsRequired() + .HasColumnType("text"); + + b.Property("CharaId") + .HasColumnType("integer"); + + b.Property("EnemyAiId") + .HasColumnType("integer"); + + b.Property("EnemyCharaId") + .HasColumnType("integer"); + + b.Property("EnemyClass") + .HasColumnType("integer"); + + b.Property("IsCameraMovable") + .HasColumnType("integer"); + + b.Property("IsMaintenanceChapter") + .HasColumnType("boolean"); + + b.Property("IsPlayAnotherEndAppearanceAnimation") + .HasColumnType("boolean"); + + b.Property("IsReleasedAnotherEnd") + .HasColumnType("boolean"); + + b.Property("IsSkipEnabled") + .HasColumnType("boolean"); + + b.Property("NextChapterId") + .IsRequired() + .HasColumnType("text"); + + b.Property("ReleasePoint") + .HasColumnType("integer"); + + b.Property("RequiredChapterId") + .HasColumnType("text"); + + b.Property("SectionId") + .HasColumnType("integer"); + + b.Property("SelectionDisplayPosition") + .HasColumnType("text"); + + b.Property("SelectionTextId") + .HasColumnType("text"); + + b.Property("ShowCoordinate") + .HasColumnType("integer"); + + b.Property("ShowSubtitles") + .HasColumnType("integer"); + + b.Property("SpecialBattleSettingId") + .HasColumnType("integer"); + + b.Property("UnlockText") + .HasColumnType("text"); + + b.Property("XCoordinate") + .HasColumnType("numeric"); + + b.Property("YCoordinate") + .HasColumnType("numeric"); + + b.HasKey("StoryId"); + + b.HasIndex("NextChapterId"); + + b.HasIndex("SpecialBattleSettingId"); + + b.HasIndex("SectionId", "CharaId", "ChapterId"); + + b.ToTable("StoryChapters"); + }); + + modelBuilder.Entity("SVSim.Database.Entities.Story.StorySection", b => + { + b.Property("Id") + .HasColumnType("integer"); + + b.Property("AllStoryOrderId") + .HasColumnType("integer"); + + b.Property("BackGroundId") + .HasColumnType("integer"); + + b.Property("ChapterSelectType") + .HasColumnType("integer"); + + b.Property("ImageName") + .IsRequired() + .HasColumnType("text"); + + b.Property("IsLeaderSelect") + .HasColumnType("boolean"); + + b.Property("IsPlayAnotherEndAppearanceAnimation") + .HasColumnType("boolean"); + + b.Property("IsSpoiler") + .HasColumnType("integer"); + + b.Property("IsUnderMaintenance") + .HasColumnType("boolean"); + + b.Property("NameTextKey") + .IsRequired() + .HasColumnType("text"); + + b.Property("OrderId") + .HasColumnType("integer"); + + b.Property("SpoilerMessage") + .IsRequired() + .HasColumnType("text"); + + b.Property("StoryApiType") + .HasColumnType("integer"); + + b.Property("StoryTypeOverwrite") + .HasColumnType("integer"); + + b.Property("WorldId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("WorldId"); + + b.ToTable("StorySections"); + }); + + modelBuilder.Entity("SVSim.Database.Entities.Story.StoryWorld", b => + { + b.Property("Id") + .HasColumnType("integer"); + + b.Property("PanelImageName") + .IsRequired() + .HasColumnType("text"); + + b.Property("RibbonText") + .IsRequired() + .HasColumnType("text"); + + b.Property("TitleTextKey") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("StoryWorlds"); + }); + + modelBuilder.Entity("SVSim.Database.Entities.Story.ViewerStoryBranchUnlock", b => + { + b.Property("ViewerId") + .HasColumnType("bigint"); + + b.Property("StoryId") + .HasColumnType("integer"); + + b.Property("UnlockedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("ViewerId", "StoryId"); + + b.ToTable("ViewerStoryBranchUnlocks"); + }); + + modelBuilder.Entity("SVSim.Database.Entities.Story.ViewerStoryProgress", b => + { + b.Property("ViewerId") + .HasColumnType("bigint"); + + b.Property("StoryId") + .HasColumnType("integer"); + + b.Property("FinishedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsFinish") + .HasColumnType("boolean"); + + b.Property("IsSkipped") + .HasColumnType("boolean"); + + b.Property("SkippedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("ViewerId", "StoryId"); + + b.ToTable("ViewerStoryProgress"); + }); + + modelBuilder.Entity("SVSim.Database.Models.ArenaSeasonConfig", b => + { + b.Property("Id") + .HasColumnType("integer"); + + b.Property("Cost") + .HasColumnType("numeric(20,0)"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("DateUpdated") + .HasColumnType("timestamp with time zone"); + + b.Property("Enable") + .HasColumnType("integer"); + + b.Property("FormatInfo") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("IsJoin") + .HasColumnType("boolean"); + + b.Property("Mode") + .HasColumnType("integer"); + + b.Property("RupyCost") + .HasColumnType("numeric(20,0)"); + + b.Property("TicketCost") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("ArenaSeasons"); + }); + + modelBuilder.Entity("SVSim.Database.Models.AvatarAbilityEntry", b => + { + b.Property("Id") + .HasColumnType("integer"); + + b.Property("Ability") + .IsRequired() + .HasColumnType("text"); + + b.Property("AbilityCost") + .IsRequired() + .HasColumnType("text"); + + b.Property("AbilityDesc") + .IsRequired() + .HasColumnType("text"); + + b.Property("BattleStartFirstPlayerTurnBp") + .HasColumnType("integer"); + + b.Property("BattleStartMaxLife") + .HasColumnType("integer"); + + b.Property("BattleStartSecondPlayerTurnBp") + .HasColumnType("integer"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("DateUpdated") + .HasColumnType("timestamp with time zone"); + + b.Property("LeaderSkinId") + .HasColumnType("integer"); + + b.Property("PassiveAbility") + .IsRequired() + .HasColumnType("text"); + + b.Property("PassiveAbilityDesc") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("AvatarAbilities"); + }); + + modelBuilder.Entity("SVSim.Database.Models.BannerEntry", b => + { + b.Property("Id") + .HasColumnType("integer"); + + b.Property("ChangeTime") + .HasColumnType("integer"); + + b.Property("Click") + .IsRequired() + .HasColumnType("text"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("DateUpdated") + .HasColumnType("timestamp with time zone"); + + b.Property("ImageName") + .IsRequired() + .HasColumnType("text"); + + b.Property("ImagePaths") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("RemainingTime") + .HasColumnType("integer"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Banners"); + }); + + modelBuilder.Entity("SVSim.Database.Models.BattlePassLevelEntry", b => + { + b.Property("Id") + .HasColumnType("integer"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("DateUpdated") + .HasColumnType("timestamp with time zone"); + + b.Property("Level") + .HasColumnType("integer"); + + b.Property("RewardData") + .IsRequired() + .HasColumnType("jsonb"); + + b.HasKey("Id"); + + b.ToTable("BattlePassLevels"); + }); + + modelBuilder.Entity("SVSim.Database.Models.BattlefieldEntry", b => + { + b.Property("Id") + .HasColumnType("integer"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("DateUpdated") + .HasColumnType("timestamp with time zone"); + + b.Property("IsOpen") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Battlefields"); + }); + + modelBuilder.Entity("SVSim.Database.Models.BuildDeckProductEntry", b => + { + b.Property("Id") + .HasColumnType("integer"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("DateUpdated") + .HasColumnType("timestamp with time zone"); + + b.Property("DeckCode") + .IsRequired() + .HasColumnType("text"); + + b.Property("FeaturedCardId") + .HasColumnType("bigint"); + + b.Property("IntroPriceCrystal") + .HasColumnType("integer"); + + b.Property("IntroPriceRupy") + .HasColumnType("integer"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("LeaderId") + .HasColumnType("integer"); + + b.Property("ProductNameKey") + .IsRequired() + .HasColumnType("text"); + + b.Property("PurchaseNumMax") + .HasColumnType("integer"); + + b.Property("RegularPriceCrystal") + .HasColumnType("integer"); + + b.Property("RegularPriceRupy") + .HasColumnType("integer"); + + b.Property("SeriesId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("BuildDeckProducts"); + }); + + modelBuilder.Entity("SVSim.Database.Models.BuildDeckSeriesEntry", b => + { + b.Property("Id") + .HasColumnType("integer"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("DateUpdated") + .HasColumnType("timestamp with time zone"); + + b.Property("DrumrollPath") + .IsRequired() + .HasColumnType("text"); + + b.Property("IntroKey") + .IsRequired() + .HasColumnType("text"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("IsNew") + .HasColumnType("boolean"); + + b.Property("NameKey") + .IsRequired() + .HasColumnType("text"); + + b.Property("OrderIndex") + .HasColumnType("integer"); + + b.Property("TitlePath") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("BuildDeckSeries"); + }); + + modelBuilder.Entity("SVSim.Database.Models.CardCosmeticReward", b => + { + b.Property("CardId") + .HasColumnType("bigint"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("CosmeticId") + .HasColumnType("bigint"); + + b.Property("Quantity") + .HasColumnType("integer"); + + b.HasKey("CardId", "Type", "CosmeticId"); + + b.HasIndex("CardId"); + + b.ToTable("CardCosmeticRewards"); + }); + + modelBuilder.Entity("SVSim.Database.Models.ClassEntry", b => + { + b.Property("Id") + .HasColumnType("integer"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("DateUpdated") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Classes"); + }); + + modelBuilder.Entity("SVSim.Database.Models.ClassExpEntry", b => + { + b.Property("Id") + .HasColumnType("integer"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("DateUpdated") + .HasColumnType("timestamp with time zone"); + + b.Property("NecessaryExp") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("ClassExpCurve"); + }); + + modelBuilder.Entity("SVSim.Database.Models.ColosseumConfig", b => + { + b.Property("Id") + .HasColumnType("integer"); + + b.Property("CardPoolName") + .IsRequired() + .HasColumnType("text"); + + b.Property("ColosseumId") + .IsRequired() + .HasColumnType("text"); + + b.Property("ColosseumName") + .IsRequired() + .HasColumnType("text"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("DateUpdated") + .HasColumnType("timestamp with time zone"); + + b.Property("DeckFormat") + .IsRequired() + .HasColumnType("text"); + + b.Property("EndTime") + .HasColumnType("timestamp with time zone"); + + b.Property("IsAllCardEnabled") + .HasColumnType("integer"); + + b.Property("IsColosseumPeriod") + .HasColumnType("boolean"); + + b.Property("IsDisplayTips") + .IsRequired() + .HasColumnType("text"); + + b.Property("IsNormalTwoPick") + .IsRequired() + .HasColumnType("text"); + + b.Property("IsRoundPeriod") + .HasColumnType("boolean"); + + b.Property("IsSpecialMode") + .IsRequired() + .HasColumnType("text"); + + b.Property("NowRound") + .IsRequired() + .HasColumnType("text"); + + b.Property("SalesPeriodInfo") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("StartTime") + .HasColumnType("timestamp with time zone"); + + b.Property("TipsId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Colosseums"); + }); + + modelBuilder.Entity("SVSim.Database.Models.DailyLoginBonusEntry", b => + { + b.Property("Id") + .HasColumnType("integer"); + + b.Property("BonusData") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("BonusId") + .HasColumnType("integer"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("DateUpdated") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("DailyLoginBonuses"); + }); + + modelBuilder.Entity("SVSim.Database.Models.DefaultDeckEntry", b => + { + b.Property("Id") + .HasColumnType("integer"); + + b.Property("CardIdArray") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("ClassId") + .HasColumnType("integer"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("DateUpdated") + .HasColumnType("timestamp with time zone"); + + b.Property("DeckName") + .IsRequired() + .HasColumnType("text"); + + b.Property("DeckNo") + .HasColumnType("integer"); + + b.Property("LeaderSkinId") + .HasColumnType("integer"); + + b.Property("SleeveId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.ToTable("DefaultDecks"); + }); + + modelBuilder.Entity("SVSim.Database.Models.DefaultLeaderSkinSettingEntry", b => + { + b.Property("Id") + .HasColumnType("integer"); + + b.Property("ClassId") + .HasColumnType("integer"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("DateUpdated") + .HasColumnType("timestamp with time zone"); + + b.Property("IsRandomLeaderSkin") + .HasColumnType("integer"); + + b.Property("LeaderSkinId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("DefaultLeaderSkinSettings"); + }); + + modelBuilder.Entity("SVSim.Database.Models.DegreeEntry", b => + { + b.Property("Id") + .HasColumnType("integer"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("DateUpdated") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("Degrees"); + }); + + modelBuilder.Entity("SVSim.Database.Models.EmblemEntry", b => + { + b.Property("Id") + .HasColumnType("integer"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("DateUpdated") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("Emblems"); + }); + + modelBuilder.Entity("SVSim.Database.Models.FeatureMaintenanceEntry", b => + { + b.Property("Id") + .HasColumnType("integer"); + + b.Property("Data") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("DateUpdated") + .HasColumnType("timestamp with time zone"); + + b.Property("FeatureKey") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("FeatureMaintenances"); + }); + + modelBuilder.Entity("SVSim.Database.Models.GameConfigSection", b => + { + b.Property("SectionName") + .HasColumnType("text"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("DateUpdated") + .HasColumnType("timestamp with time zone"); + + b.Property("ValueJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.HasKey("SectionName"); + + b.ToTable("GameConfigs"); + }); + + modelBuilder.Entity("SVSim.Database.Models.ItemEntry", b => + { + b.Property("Id") + .HasColumnType("integer"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("DateUpdated") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Items"); + }); + + modelBuilder.Entity("SVSim.Database.Models.LeaderSkinEntry", b => + { + b.Property("Id") + .HasColumnType("integer"); + + b.Property("ClassId") + .HasColumnType("integer"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("DateUpdated") + .HasColumnType("timestamp with time zone"); + + b.Property("EmoteId") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ClassId"); + + b.ToTable("LeaderSkins"); + }); + + modelBuilder.Entity("SVSim.Database.Models.LoadingExclusionCardEntry", b => + { + b.Property("Id") + .HasColumnType("bigint"); + + b.Property("CardId") + .HasColumnType("bigint"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("DateUpdated") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("LoadingExclusionCards"); + }); + + modelBuilder.Entity("SVSim.Database.Models.MaintenanceCardEntry", b => + { + b.Property("Id") + .HasColumnType("bigint"); + + b.Property("CardId") + .HasColumnType("bigint"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("DateUpdated") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("MaintenanceCards"); + }); + + modelBuilder.Entity("SVSim.Database.Models.MasterPointRankingPeriodEntry", b => + { + b.Property("Id") + .HasColumnType("integer"); + + b.Property("BeginTime") + .HasColumnType("timestamp with time zone"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("DateUpdated") + .HasColumnType("timestamp with time zone"); + + b.Property("EndTime") + .HasColumnType("timestamp with time zone"); + + b.Property("NecessaryScore") + .HasColumnType("bigint"); + + b.Property("PeriodNum") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("MasterPointRankingPeriods"); + }); + + modelBuilder.Entity("SVSim.Database.Models.MyPageBackgroundEntry", b => + { + b.Property("Id") + .HasColumnType("integer"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("DateUpdated") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("MyPageBackgrounds"); + }); + + modelBuilder.Entity("SVSim.Database.Models.MyRotationAbilityEntry", b => + { + b.Property("Id") + .HasColumnType("integer"); + + b.Property("AbilityId") + .HasColumnType("integer"); + + b.Property("Data") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("DateUpdated") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("MyRotationAbilities"); + }); + + modelBuilder.Entity("SVSim.Database.Models.MyRotationSettingEntry", b => + { + b.Property("Id") + .HasColumnType("integer"); + + b.Property("AbilitiesCsv") + .IsRequired() + .HasColumnType("text"); + + b.Property("CardSetIdsCsv") + .IsRequired() + .HasColumnType("text"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("DateUpdated") + .HasColumnType("timestamp with time zone"); + + b.Property("ReprintedCardIds") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("RestrictedCardIds") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("RotationId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("MyRotationSettings"); + }); + + modelBuilder.Entity("SVSim.Database.Models.PackConfigEntry", b => + { + b.Property("Id") + .HasColumnType("integer"); + + b.Property("BasePackId") + .HasColumnType("integer"); + + b.Property("CommenceDate") + .HasColumnType("timestamp with time zone"); + + b.Property("CompleteDate") + .HasColumnType("timestamp with time zone"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("DateUpdated") + .HasColumnType("timestamp with time zone"); + + b.Property("GachaDetail") + .IsRequired() + .HasColumnType("text"); + + b.Property("GachaType") + .HasColumnType("integer"); + + b.Property("IsHide") + .HasColumnType("boolean"); + + b.Property("IsNew") + .HasColumnType("boolean"); + + b.Property("IsPreRelease") + .HasColumnType("boolean"); + + b.Property("OpenCountLimit") + .HasColumnType("integer"); + + b.Property("OverrideDrawEffectPackId") + .HasColumnType("integer"); + + b.Property("OverrideUiEffectPackId") + .HasColumnType("integer"); + + b.Property("PackCategory") + .HasColumnType("integer"); + + b.Property("PosterType") + .HasColumnType("integer"); + + b.Property("SalesPeriodTime") + .HasColumnType("timestamp with time zone"); + + b.Property("SleeveId") + .HasColumnType("integer"); + + b.Property("SpecialSleeveId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("Packs"); + }); + + modelBuilder.Entity("SVSim.Database.Models.PaymentItemEntry", b => + { + b.Property("Id") + .HasColumnType("integer"); + + b.Property("ChargeCrystalNum") + .HasColumnType("integer"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("DateUpdated") + .HasColumnType("timestamp with time zone"); + + b.Property("EndTime") + .HasColumnType("timestamp with time zone"); + + b.Property("FreeCrystalNum") + .HasColumnType("integer"); + + b.Property("ImageName") + .IsRequired() + .HasColumnType("text"); + + b.Property("IsResaleProduct") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Price") + .HasColumnType("numeric"); + + b.Property("ProductId") + .HasColumnType("integer"); + + b.Property("PurchaseLimit") + .HasColumnType("integer"); + + b.Property("RemainingTime") + .HasColumnType("integer"); + + b.Property("ResaleStartDate") + .HasColumnType("timestamp with time zone"); + + b.Property("SpecialShopFlag") + .HasColumnType("integer"); + + b.Property("StartTime") + .HasColumnType("timestamp with time zone"); + + b.Property("StoreProductId") + .HasColumnType("bigint"); + + b.Property("Text") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("PaymentItems"); + }); + + modelBuilder.Entity("SVSim.Database.Models.PracticeOpponentEntry", b => + { + b.Property("Id") + .HasColumnType("integer"); + + b.Property("AiDeckLevel") + .HasColumnType("integer"); + + b.Property("AiLogicLevel") + .HasColumnType("integer"); + + b.Property("AiMaxLife") + .HasColumnType("integer"); + + b.Property("Battle3dFieldId") + .IsRequired() + .HasColumnType("text"); + + b.Property("CharaId") + .HasColumnType("integer"); + + b.Property("ClassId") + .HasColumnType("integer"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("DateUpdated") + .HasColumnType("timestamp with time zone"); + + b.Property("DegreeId") + .HasColumnType("integer"); + + b.Property("IsCampaignPractice") + .HasColumnType("boolean"); + + b.Property("IsMaintenance") + .HasColumnType("boolean"); + + b.Property("PracticeId") + .HasColumnType("integer"); + + b.Property("TextId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("PracticeOpponents"); + }); + + modelBuilder.Entity("SVSim.Database.Models.PreReleaseInfo", b => + { + b.Property("Id") + .HasColumnType("integer"); + + b.Property("CardMasterId") + .HasColumnType("integer"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("DateUpdated") + .HasColumnType("timestamp with time zone"); + + b.Property("DefaultCardMasterId") + .IsRequired() + .HasColumnType("text"); + + b.Property("DisplayEndTime") + .HasColumnType("timestamp with time zone"); + + b.Property("EndTime") + .HasColumnType("timestamp with time zone"); + + b.Property("FreeMatchStartTime") + .HasColumnType("timestamp with time zone"); + + b.Property("IsPreRotationFreeMatchTerm") + .HasColumnType("boolean"); + + b.Property("LatestReprintedBaseCardIds") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("NextCardSetId") + .IsRequired() + .HasColumnType("text"); + + b.Property("PreReleaseCardMasterId") + .IsRequired() + .HasColumnType("text"); + + b.Property("PreReleaseId") + .IsRequired() + .HasColumnType("text"); + + b.Property("ReprintedBaseCardIds") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("RotationCardSetIdList") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("StartTime") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("PreReleaseInfos"); + }); + + modelBuilder.Entity("SVSim.Database.Models.PuzzleEntry", b => + { + b.Property("Id") + .HasColumnType("integer"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("DateUpdated") + .HasColumnType("timestamp with time zone"); + + b.Property("GroupId") + .HasColumnType("integer"); + + b.Property("IsAdditional") + .HasColumnType("boolean"); + + b.Property("IsPlayable") + .HasColumnType("boolean"); + + b.Property("PuzzleDifficulty") + .HasColumnType("integer"); + + b.Property("PuzzleId") + .HasColumnType("integer"); + + b.Property("ReleaseConditionTextId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("GroupId"); + + b.ToTable("Puzzles"); + }); + + modelBuilder.Entity("SVSim.Database.Models.PuzzleGroupEntry", b => + { + b.Property("Id") + .HasColumnType("integer"); + + b.Property("BasicTitleTextId") + .IsRequired() + .HasColumnType("text"); + + b.Property("CharaId") + .HasColumnType("integer"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("DateUpdated") + .HasColumnType("timestamp with time zone"); + + b.Property("DifficultyNameListJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("PuzzleCharaId") + .HasColumnType("integer"); + + b.Property("PuzzleMasterId") + .HasColumnType("integer"); + + b.Property("SortType") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("PuzzleGroups"); + }); + + modelBuilder.Entity("SVSim.Database.Models.PuzzleMissionEntry", b => + { + b.Property("Id") + .HasColumnType("integer"); + + b.Property("AchievedMessage") + .IsRequired() + .HasColumnType("text"); + + b.Property("CampaignCommenceTime") + .HasColumnType("bigint"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("DateUpdated") + .HasColumnType("timestamp with time zone"); + + b.Property("MissionName") + .IsRequired() + .HasColumnType("text"); + + b.Property("OrderId") + .HasColumnType("integer"); + + b.Property("RequireNumber") + .HasColumnType("integer"); + + b.Property("RewardDetailId") + .HasColumnType("bigint"); + + b.Property("RewardNumber") + .HasColumnType("integer"); + + b.Property("RewardType") + .HasColumnType("integer"); + + b.Property("TargetPuzzleGroupId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("PuzzleMissions"); + }); + + modelBuilder.Entity("SVSim.Database.Models.RankInfoEntry", b => + { + b.Property("Id") + .HasColumnType("integer"); + + b.Property("AccumulateMasterPoint") + .HasColumnType("integer"); + + b.Property("AccumulatePoint") + .HasColumnType("integer"); + + b.Property("BaseAddBp") + .HasColumnType("integer"); + + b.Property("BaseDropBp") + .HasColumnType("integer"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("DateUpdated") + .HasColumnType("timestamp with time zone"); + + b.Property("IsPromotionWar") + .HasColumnType("integer"); + + b.Property("LoseBonus") + .HasColumnType("double precision"); + + b.Property("LowerLimitPoint") + .HasColumnType("integer"); + + b.Property("MatchCount") + .HasColumnType("integer"); + + b.Property("MaxLoseBonus") + .HasColumnType("integer"); + + b.Property("MaxWinBonus") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("NecessaryPoint") + .HasColumnType("integer"); + + b.Property("NecessaryWin") + .HasColumnType("integer"); + + b.Property("ResetLose") + .HasColumnType("integer"); + + b.Property("StreakBonusPt") + .HasColumnType("integer"); + + b.Property("WinBonus") + .HasColumnType("double precision"); + + b.HasKey("Id"); + + b.ToTable("RankInfo"); + }); + + modelBuilder.Entity("SVSim.Database.Models.ReprintedCardEntry", b => + { + b.Property("Id") + .HasColumnType("bigint"); + + b.Property("CardId") + .HasColumnType("bigint"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("DateUpdated") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("ReprintedCards"); + }); + + modelBuilder.Entity("SVSim.Database.Models.SealedConfig", b => + { + b.Property("Id") + .HasColumnType("integer"); + + b.Property("CrystalCost") + .HasColumnType("integer"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("DateUpdated") + .HasColumnType("timestamp with time zone"); + + b.Property("DeckUsingNumMin") + .HasColumnType("integer"); + + b.Property("Enable") + .HasColumnType("integer"); + + b.Property("IsDeckCodeMaintenance") + .HasColumnType("boolean"); + + b.Property("IsJoin") + .HasColumnType("boolean"); + + b.Property("PackInfo") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("RupyCost") + .HasColumnType("integer"); + + b.Property("SalesPeriodInfo") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("ScheduleId") + .HasColumnType("integer"); + + b.Property("TicketCost") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("SealedSeasons"); + }); + + modelBuilder.Entity("SVSim.Database.Models.ShadowverseCardEntry", b => + { + b.Property("Id") + .HasColumnType("bigint"); + + b.Property("Attack") + .HasColumnType("integer"); + + b.Property("ClassId") + .HasColumnType("integer"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("DateUpdated") + .HasColumnType("timestamp with time zone"); + + b.Property("Defense") + .HasColumnType("integer"); + + b.Property("IsFoil") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("PrimaryResourceCost") + .HasColumnType("integer"); + + b.Property("Rarity") + .HasColumnType("integer"); + + b.Property("ShadowverseCardSetEntryId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ClassId"); + + b.HasIndex("ShadowverseCardSetEntryId"); + + b.ToTable("Cards"); + }); + + modelBuilder.Entity("SVSim.Database.Models.ShadowverseCardSetEntry", b => + { + b.Property("Id") + .HasColumnType("integer"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("DateUpdated") + .HasColumnType("timestamp with time zone"); + + b.Property("IsBasic") + .HasColumnType("boolean"); + + b.Property("IsInRotation") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("CardSets"); + }); + + modelBuilder.Entity("SVSim.Database.Models.ShadowverseDeckEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ClassId") + .HasColumnType("integer"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("DateUpdated") + .HasColumnType("timestamp with time zone"); + + b.Property("Format") + .HasColumnType("integer"); + + b.Property("LeaderSkinId") + .HasColumnType("integer"); + + b.Property("MyRotationId") + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Number") + .HasColumnType("integer"); + + b.Property("RandomLeaderSkin") + .HasColumnType("boolean"); + + b.Property("SleeveId") + .HasColumnType("integer"); + + b.Property("ViewerId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("ClassId"); + + b.HasIndex("LeaderSkinId"); + + b.HasIndex("SleeveId"); + + b.HasIndex("ViewerId"); + + b.ToTable("Decks"); + }); + + modelBuilder.Entity("SVSim.Database.Models.SleeveEntry", b => + { + b.Property("Id") + .HasColumnType("integer"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("DateUpdated") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("Sleeves"); + }); + + modelBuilder.Entity("SVSim.Database.Models.SpecialDeckFormatEntry", b => + { + b.Property("Id") + .HasColumnType("integer"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("DateUpdated") + .HasColumnType("timestamp with time zone"); + + b.Property("DeckFormat") + .IsRequired() + .HasColumnType("text"); + + b.Property("EndTime") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("SpecialDeckFormats"); + }); + + modelBuilder.Entity("SVSim.Database.Models.SpotCardEntry", b => + { + b.Property("Id") + .HasColumnType("bigint"); + + b.Property("CardId") + .HasColumnType("bigint"); + + b.Property("Cost") + .HasColumnType("integer"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("DateUpdated") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("SpotCards"); + }); + + modelBuilder.Entity("SVSim.Database.Models.UnlimitedRestrictionEntry", b => + { + b.Property("Id") + .HasColumnType("bigint"); + + b.Property("CardId") + .HasColumnType("bigint"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("DateUpdated") + .HasColumnType("timestamp with time zone"); + + b.Property("RestrictionValue") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("UnlimitedRestrictions"); + }); + + modelBuilder.Entity("SVSim.Database.Models.Viewer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("DateUpdated") + .HasColumnType("timestamp with time zone"); + + b.Property("DisplayName") + .IsRequired() + .HasColumnType("text"); + + b.Property("LastLogin") + .HasColumnType("timestamp with time zone"); + + b.Property("ShortUdid") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValueSql("nextval('\"ShortUdidSequence\"')"); + + NpgsqlPropertyBuilderExtensions.UseSequence(b.Property("ShortUdid"), "ShortUdidSequence"); + + b.HasKey("Id"); + + b.HasIndex("ShortUdid"); + + b.ToTable("Viewers"); + }); + + modelBuilder.Entity("SVSim.Database.Models.ViewerPuzzleClear", b => + { + b.Property("ViewerId") + .HasColumnType("bigint"); + + b.Property("PuzzleId") + .HasColumnType("integer"); + + b.Property("BestRetryCount") + .HasColumnType("integer"); + + b.Property("ClearedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("ViewerId", "PuzzleId"); + + b.ToTable("ViewerPuzzleClears"); + }); + + modelBuilder.Entity("SleeveEntryViewer", b => + { + b.Property("SleevesId") + .HasColumnType("integer"); + + b.Property("ViewersId") + .HasColumnType("bigint"); + + b.HasKey("SleevesId", "ViewersId"); + + b.HasIndex("ViewersId"); + + b.ToTable("SleeveEntryViewer"); + }); + + modelBuilder.Entity("DegreeEntryViewer", b => + { + b.HasOne("SVSim.Database.Models.DegreeEntry", null) + .WithMany() + .HasForeignKey("DegreesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("SVSim.Database.Models.Viewer", null) + .WithMany() + .HasForeignKey("ViewersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("EmblemEntryViewer", b => + { + b.HasOne("SVSim.Database.Models.EmblemEntry", null) + .WithMany() + .HasForeignKey("EmblemsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("SVSim.Database.Models.Viewer", null) + .WithMany() + .HasForeignKey("ViewersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("LeaderSkinEntryViewer", b => + { + b.HasOne("SVSim.Database.Models.LeaderSkinEntry", null) + .WithMany() + .HasForeignKey("LeaderSkinsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("SVSim.Database.Models.Viewer", null) + .WithMany() + .HasForeignKey("ViewersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("MyPageBackgroundEntryViewer", b => + { + b.HasOne("SVSim.Database.Models.MyPageBackgroundEntry", null) + .WithMany() + .HasForeignKey("MyPageBackgroundsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("SVSim.Database.Models.Viewer", null) + .WithMany() + .HasForeignKey("ViewersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SVSim.Database.Entities.Story.StoryChapter", b => + { + b.HasOne("SVSim.Database.Entities.Story.StorySection", "Section") + .WithMany() + .HasForeignKey("SectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("SVSim.Database.Entities.Story.SpecialBattleSetting", "SpecialBattleSetting") + .WithMany() + .HasForeignKey("SpecialBattleSettingId"); + + b.OwnsMany("SVSim.Database.Entities.Story.StoryChapterBattleSetting", "BattleSettings", b1 => + { + b1.Property("StoryId") + .HasColumnType("integer"); + + b1.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property("Id")); + + b1.Property("Battle3dFieldIdOverride") + .HasColumnType("integer"); + + b1.Property("BgmIdOverride") + .HasColumnType("integer"); + + b1.Property("DeckClassId") + .HasColumnType("integer"); + + b1.Property("DeckSkinIdOverride") + .HasColumnType("integer"); + + b1.Property("EnemyEmotionOverride") + .HasColumnType("integer"); + + b1.Property("PlayerEmotionOverride") + .HasColumnType("integer"); + + b1.Property("SkinIdOverride") + .HasColumnType("integer"); + + b1.HasKey("StoryId", "Id"); + + b1.ToTable("StoryChapterBattleSetting"); + + b1.WithOwner() + .HasForeignKey("StoryId"); + }); + + b.OwnsMany("SVSim.Database.Entities.Story.StoryChapterReward", "Rewards", b1 => + { + b1.Property("StoryId") + .HasColumnType("integer"); + + b1.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property("Id")); + + b1.Property("RewardDetailId") + .HasColumnType("bigint"); + + b1.Property("RewardNumber") + .HasColumnType("integer"); + + b1.Property("RewardType") + .HasColumnType("integer"); + + b1.HasKey("StoryId", "Id"); + + b1.ToTable("StoryChapterReward"); + + b1.WithOwner() + .HasForeignKey("StoryId"); + }); + + b.OwnsMany("SVSim.Database.Entities.Story.StorySubChapter", "SubChapters", b1 => + { + b1.Property("StoryId") + .HasColumnType("integer"); + + b1.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property("Id")); + + b1.Property("IsMaintenanceChapter") + .HasColumnType("boolean"); + + b1.Property("SubChapterId") + .HasColumnType("integer"); + + b1.Property("SubChapterStoryId") + .HasColumnType("integer"); + + b1.HasKey("StoryId", "Id"); + + b1.ToTable("StorySubChapter"); + + b1.WithOwner() + .HasForeignKey("StoryId"); + }); + + b.Navigation("BattleSettings"); + + b.Navigation("Rewards"); + + b.Navigation("Section"); + + b.Navigation("SpecialBattleSetting"); + + b.Navigation("SubChapters"); + }); + + modelBuilder.Entity("SVSim.Database.Entities.Story.StorySection", b => + { + b.HasOne("SVSim.Database.Entities.Story.StoryWorld", "World") + .WithMany() + .HasForeignKey("WorldId"); + + b.Navigation("World"); + }); + + modelBuilder.Entity("SVSim.Database.Models.BuildDeckProductEntry", b => + { + b.HasOne("SVSim.Database.Models.BuildDeckSeriesEntry", "Series") + .WithMany("Products") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.OwnsMany("SVSim.Database.Models.BuildDeckProductCardEntry", "Cards", b1 => + { + b1.Property("BuildDeckProductEntryId") + .HasColumnType("integer"); + + b1.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property("Id")); + + b1.Property("CardId") + .HasColumnType("bigint"); + + b1.Property("IsSpot") + .HasColumnType("boolean"); + + b1.Property("Number") + .HasColumnType("integer"); + + b1.HasKey("BuildDeckProductEntryId", "Id"); + + b1.ToTable("BuildDeckProductCardEntry"); + + b1.WithOwner() + .HasForeignKey("BuildDeckProductEntryId"); + }); + + b.OwnsMany("SVSim.Database.Models.BuildDeckProductRewardEntry", "Rewards", b1 => + { + b1.Property("BuildDeckProductEntryId") + .HasColumnType("integer"); + + b1.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property("Id")); + + b1.Property("MessageId") + .HasColumnType("integer"); + + b1.Property("RewardDetailId") + .HasColumnType("bigint"); + + b1.Property("RewardIndex") + .HasColumnType("integer"); + + b1.Property("RewardNumber") + .HasColumnType("integer"); + + b1.Property("RewardType") + .HasColumnType("integer"); + + b1.HasKey("BuildDeckProductEntryId", "Id"); + + b1.ToTable("BuildDeckProductRewardEntry"); + + b1.WithOwner() + .HasForeignKey("BuildDeckProductEntryId"); + }); + + b.Navigation("Cards"); + + b.Navigation("Rewards"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("SVSim.Database.Models.BuildDeckSeriesEntry", b => + { + b.OwnsMany("SVSim.Database.Models.BuildDeckSeriesRewardEntry", "SeriesRewards", b1 => + { + b1.Property("BuildDeckSeriesEntryId") + .HasColumnType("integer"); + + b1.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property("Id")); + + b1.Property("ItemIndex") + .HasColumnType("integer"); + + b1.Property("MessageId") + .HasColumnType("integer"); + + b1.Property("RewardDetailId") + .HasColumnType("bigint"); + + b1.Property("RewardNumber") + .HasColumnType("integer"); + + b1.Property("RewardType") + .HasColumnType("integer"); + + b1.Property("TierIndex") + .HasColumnType("integer"); + + b1.HasKey("BuildDeckSeriesEntryId", "Id"); + + b1.ToTable("BuildDeckSeriesRewardEntry"); + + b1.WithOwner() + .HasForeignKey("BuildDeckSeriesEntryId"); + }); + + b.Navigation("SeriesRewards"); + }); + + modelBuilder.Entity("SVSim.Database.Models.CardCosmeticReward", b => + { + b.HasOne("SVSim.Database.Models.ShadowverseCardEntry", "Card") + .WithMany() + .HasForeignKey("CardId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Card"); + }); + + modelBuilder.Entity("SVSim.Database.Models.LeaderSkinEntry", b => + { + b.HasOne("SVSim.Database.Models.ClassEntry", "Class") + .WithMany("LeaderSkins") + .HasForeignKey("ClassId"); + + b.Navigation("Class"); + }); + + modelBuilder.Entity("SVSim.Database.Models.PackConfigEntry", b => + { + b.OwnsMany("SVSim.Database.Models.PackBannerEntry", "Banners", b1 => + { + b1.Property("PackConfigEntryId") + .HasColumnType("integer"); + + b1.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property("Id")); + + b1.Property("BannerName") + .IsRequired() + .HasColumnType("text"); + + b1.Property("DialogTitle") + .IsRequired() + .HasColumnType("text"); + + b1.HasKey("PackConfigEntryId", "Id"); + + b1.ToTable("PackBannerEntry"); + + b1.WithOwner() + .HasForeignKey("PackConfigEntryId"); + }); + + b.OwnsMany("SVSim.Database.Models.PackChildGachaEntry", "ChildGachas", b1 => + { + b1.Property("PackConfigEntryId") + .HasColumnType("integer"); + + b1.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property("Id")); + + b1.Property("CampaignName") + .HasColumnType("text"); + + b1.Property("CardCount") + .HasColumnType("integer"); + + b1.Property("Cost") + .HasColumnType("integer"); + + b1.Property("FreeGachaCampaignId") + .HasColumnType("integer"); + + b1.Property("GachaId") + .HasColumnType("integer"); + + b1.Property("IsDailySingle") + .HasColumnType("boolean"); + + b1.Property("ItemId") + .HasColumnType("bigint"); + + b1.Property("OverrideIncreaseGachaPoint") + .HasColumnType("integer"); + + b1.Property("PurchaseLimitCount") + .HasColumnType("integer"); + + b1.Property("TypeDetail") + .HasColumnType("integer"); + + b1.HasKey("PackConfigEntryId", "Id"); + + b1.ToTable("PackChildGachaEntry"); + + b1.WithOwner() + .HasForeignKey("PackConfigEntryId"); + }); + + b.OwnsOne("SVSim.Database.Models.PackGachaPointConfig", "GachaPointConfig", b1 => + { + b1.Property("PackConfigEntryId") + .HasColumnType("integer"); + + b1.Property("ExchangeablePoint") + .HasColumnType("integer"); + + b1.Property("IncreaseGachaPoint") + .HasColumnType("integer"); + + b1.HasKey("PackConfigEntryId"); + + b1.ToTable("Packs"); + + b1.WithOwner() + .HasForeignKey("PackConfigEntryId"); + }); + + b.Navigation("Banners"); + + b.Navigation("ChildGachas"); + + b.Navigation("GachaPointConfig"); + }); + + modelBuilder.Entity("SVSim.Database.Models.PuzzleEntry", b => + { + b.HasOne("SVSim.Database.Models.PuzzleGroupEntry", "Group") + .WithMany("Puzzles") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("SVSim.Database.Models.ShadowverseCardEntry", b => + { + b.HasOne("SVSim.Database.Models.ClassEntry", "Class") + .WithMany() + .HasForeignKey("ClassId"); + + b.HasOne("SVSim.Database.Models.ShadowverseCardSetEntry", null) + .WithMany("Cards") + .HasForeignKey("ShadowverseCardSetEntryId"); + + b.OwnsOne("SVSim.Database.Models.CardCollectionInfo", "CollectionInfo", b1 => + { + b1.Property("ShadowverseCardEntryId") + .HasColumnType("bigint"); + + b1.Property("CraftCost") + .HasColumnType("integer"); + + b1.Property("DustReward") + .HasColumnType("integer"); + + b1.HasKey("ShadowverseCardEntryId"); + + b1.ToTable("Cards"); + + b1.WithOwner() + .HasForeignKey("ShadowverseCardEntryId"); + }); + + b.Navigation("Class"); + + b.Navigation("CollectionInfo"); + }); + + modelBuilder.Entity("SVSim.Database.Models.ShadowverseDeckEntry", b => + { + b.HasOne("SVSim.Database.Models.ClassEntry", "Class") + .WithMany() + .HasForeignKey("ClassId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("SVSim.Database.Models.LeaderSkinEntry", "LeaderSkin") + .WithMany() + .HasForeignKey("LeaderSkinId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("SVSim.Database.Models.SleeveEntry", "Sleeve") + .WithMany() + .HasForeignKey("SleeveId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("SVSim.Database.Models.Viewer", null) + .WithMany("Decks") + .HasForeignKey("ViewerId"); + + b.OwnsMany("SVSim.Database.Models.DeckCard", "Cards", b1 => + { + b1.Property("ShadowverseDeckEntryId") + .HasColumnType("uuid"); + + b1.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property("Id")); + + b1.Property("CardId") + .HasColumnType("bigint"); + + b1.Property("Count") + .HasColumnType("integer"); + + b1.HasKey("ShadowverseDeckEntryId", "Id"); + + b1.HasIndex("CardId"); + + b1.ToTable("DeckCard"); + + b1.HasOne("SVSim.Database.Models.ShadowverseCardEntry", "Card") + .WithMany() + .HasForeignKey("CardId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b1.WithOwner() + .HasForeignKey("ShadowverseDeckEntryId"); + + b1.Navigation("Card"); + }); + + b.Navigation("Cards"); + + b.Navigation("Class"); + + b.Navigation("LeaderSkin"); + + b.Navigation("Sleeve"); + }); + + modelBuilder.Entity("SVSim.Database.Models.Viewer", b => + { + b.OwnsMany("SVSim.Database.Models.OwnedCardEntry", "Cards", b1 => + { + b1.Property("ViewerId") + .HasColumnType("bigint"); + + b1.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property("Id")); + + b1.Property("CardId") + .HasColumnType("bigint"); + + b1.Property("Count") + .HasColumnType("integer"); + + b1.Property("IsProtected") + .HasColumnType("boolean"); + + b1.HasKey("ViewerId", "Id"); + + b1.HasIndex("CardId"); + + b1.HasIndex("ViewerId", "CardId") + .IsUnique(); + + b1.ToTable("OwnedCardEntry"); + + b1.HasOne("SVSim.Database.Models.ShadowverseCardEntry", "Card") + .WithMany() + .HasForeignKey("CardId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b1.WithOwner() + .HasForeignKey("ViewerId"); + + b1.Navigation("Card"); + }); + + b.OwnsMany("SVSim.Database.Models.OwnedItemEntry", "Items", b1 => + { + b1.Property("ViewerId") + .HasColumnType("bigint"); + + b1.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property("Id")); + + b1.Property("Count") + .HasColumnType("integer"); + + b1.Property("ItemId") + .HasColumnType("integer"); + + b1.HasKey("ViewerId", "Id"); + + b1.HasIndex("ItemId"); + + b1.HasIndex("ViewerId", "ItemId") + .IsUnique(); + + b1.ToTable("OwnedItemEntry"); + + b1.HasOne("SVSim.Database.Models.ItemEntry", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b1.WithOwner("Viewer") + .HasForeignKey("ViewerId"); + + b1.Navigation("Item"); + + b1.Navigation("Viewer"); + }); + + b.OwnsMany("SVSim.Database.Models.SocialAccountConnection", "SocialAccountConnections", b1 => + { + b1.Property("ViewerId") + .HasColumnType("bigint"); + + b1.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property("Id")); + + b1.Property("AccountId") + .HasColumnType("numeric(20,0)"); + + b1.Property("AccountType") + .HasColumnType("integer"); + + b1.HasKey("ViewerId", "Id"); + + b1.ToTable("SocialAccountConnection"); + + b1.WithOwner("Viewer") + .HasForeignKey("ViewerId"); + + b1.Navigation("Viewer"); + }); + + b.OwnsMany("SVSim.Database.Models.ViewerBuildDeckProductPurchase", "BuildDeckPurchases", b1 => + { + b1.Property("ViewerId") + .HasColumnType("bigint"); + + b1.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property("Id")); + + b1.Property("ProductId") + .HasColumnType("integer"); + + b1.Property("PurchaseCount") + .HasColumnType("integer"); + + b1.HasKey("ViewerId", "Id"); + + b1.HasIndex("ViewerId", "ProductId") + .IsUnique(); + + b1.ToTable("ViewerBuildDeckProductPurchase"); + + b1.WithOwner() + .HasForeignKey("ViewerId"); + }); + + b.OwnsMany("SVSim.Database.Models.ViewerClassData", "Classes", b1 => + { + b1.Property("ViewerId") + .HasColumnType("bigint"); + + b1.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property("Id")); + + b1.Property("ClassId") + .HasColumnType("integer"); + + b1.Property("Exp") + .HasColumnType("integer"); + + b1.Property("LeaderSkinId") + .HasColumnType("integer"); + + b1.Property("Level") + .HasColumnType("integer"); + + b1.HasKey("ViewerId", "Id"); + + b1.HasIndex("ClassId"); + + b1.HasIndex("LeaderSkinId"); + + b1.ToTable("ViewerClassData"); + + b1.HasOne("SVSim.Database.Models.ClassEntry", "Class") + .WithMany() + .HasForeignKey("ClassId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b1.HasOne("SVSim.Database.Models.LeaderSkinEntry", "LeaderSkin") + .WithMany() + .HasForeignKey("LeaderSkinId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b1.WithOwner("Viewer") + .HasForeignKey("ViewerId"); + + b1.Navigation("Class"); + + b1.Navigation("LeaderSkin"); + + b1.Navigation("Viewer"); + }); + + b.OwnsOne("SVSim.Database.Models.ViewerCurrency", "Currency", b1 => + { + b1.Property("ViewerId") + .HasColumnType("bigint"); + + b1.Property("AndroidCrystals") + .HasColumnType("numeric(20,0)"); + + b1.Property("Crystals") + .HasColumnType("numeric(20,0)"); + + b1.Property("DmmCrystals") + .HasColumnType("numeric(20,0)"); + + b1.Property("FreeCrystals") + .HasColumnType("numeric(20,0)"); + + b1.Property("IosCrystals") + .HasColumnType("numeric(20,0)"); + + b1.Property("LifeTotalCrystals") + .HasColumnType("numeric(20,0)"); + + b1.Property("RedEther") + .HasColumnType("numeric(20,0)"); + + b1.Property("Rupees") + .HasColumnType("numeric(20,0)"); + + b1.Property("SteamCrystals") + .HasColumnType("numeric(20,0)"); + + b1.HasKey("ViewerId"); + + b1.ToTable("Viewers"); + + b1.WithOwner() + .HasForeignKey("ViewerId"); + }); + + b.OwnsOne("SVSim.Database.Models.ViewerInfo", "Info", b1 => + { + b1.Property("ViewerId") + .HasColumnType("bigint"); + + b1.Property("BirthDate") + .HasColumnType("timestamp with time zone"); + + b1.Property("CountryCode") + .IsRequired() + .HasColumnType("text"); + + b1.Property("IsOfficial") + .HasColumnType("boolean"); + + b1.Property("IsOfficialMarkDisplayed") + .HasColumnType("boolean"); + + b1.Property("MaxFriends") + .HasColumnType("integer"); + + b1.Property("SelectedDegreeId") + .HasColumnType("integer"); + + b1.Property("SelectedEmblemId") + .HasColumnType("integer"); + + b1.HasKey("ViewerId"); + + b1.HasIndex("SelectedDegreeId"); + + b1.HasIndex("SelectedEmblemId"); + + b1.ToTable("Viewers"); + + b1.HasOne("SVSim.Database.Models.DegreeEntry", "SelectedDegree") + .WithMany() + .HasForeignKey("SelectedDegreeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b1.HasOne("SVSim.Database.Models.EmblemEntry", "SelectedEmblem") + .WithMany() + .HasForeignKey("SelectedEmblemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b1.WithOwner() + .HasForeignKey("ViewerId"); + + b1.Navigation("SelectedDegree"); + + b1.Navigation("SelectedEmblem"); + }); + + b.OwnsOne("SVSim.Database.Models.ViewerMissionData", "MissionData", b1 => + { + b1.Property("ViewerId") + .HasColumnType("bigint"); + + b1.Property("HasReceivedPickTwoMission") + .HasColumnType("boolean"); + + b1.Property("MissionChangeTime") + .HasColumnType("timestamp with time zone"); + + b1.Property("MissionReceiveType") + .HasColumnType("integer"); + + b1.Property("TutorialState") + .HasColumnType("integer"); + + b1.HasKey("ViewerId"); + + b1.ToTable("Viewers"); + + b1.WithOwner() + .HasForeignKey("ViewerId"); + }); + + b.OwnsMany("SVSim.Database.Models.ViewerPackOpenCount", "PackOpenCounts", b1 => + { + b1.Property("ViewerId") + .HasColumnType("bigint"); + + b1.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property("Id")); + + b1.Property("LastDailyFreeAt") + .HasColumnType("timestamp with time zone"); + + b1.Property("OpenCount") + .HasColumnType("integer"); + + b1.Property("PackId") + .HasColumnType("integer"); + + b1.HasKey("ViewerId", "Id"); + + b1.ToTable("ViewerPackOpenCount"); + + b1.WithOwner() + .HasForeignKey("ViewerId"); + }); + + b.Navigation("BuildDeckPurchases"); + + b.Navigation("Cards"); + + b.Navigation("Classes"); + + b.Navigation("Currency") + .IsRequired(); + + b.Navigation("Info") + .IsRequired(); + + b.Navigation("Items"); + + b.Navigation("MissionData") + .IsRequired(); + + b.Navigation("PackOpenCounts"); + + b.Navigation("SocialAccountConnections"); + }); + + modelBuilder.Entity("SleeveEntryViewer", b => + { + b.HasOne("SVSim.Database.Models.SleeveEntry", null) + .WithMany() + .HasForeignKey("SleevesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("SVSim.Database.Models.Viewer", null) + .WithMany() + .HasForeignKey("ViewersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SVSim.Database.Models.BuildDeckSeriesEntry", b => + { + b.Navigation("Products"); + }); + + modelBuilder.Entity("SVSim.Database.Models.ClassEntry", b => + { + b.Navigation("LeaderSkins"); + }); + + modelBuilder.Entity("SVSim.Database.Models.PuzzleGroupEntry", b => + { + b.Navigation("Puzzles"); + }); + + modelBuilder.Entity("SVSim.Database.Models.ShadowverseCardSetEntry", b => + { + b.Navigation("Cards"); + }); + + modelBuilder.Entity("SVSim.Database.Models.Viewer", b => + { + b.Navigation("Decks"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/SVSim.Database/Migrations/20260526043148_AddBuildDeck.cs b/SVSim.Database/Migrations/20260526043148_AddBuildDeck.cs new file mode 100644 index 0000000..0928800 --- /dev/null +++ b/SVSim.Database/Migrations/20260526043148_AddBuildDeck.cs @@ -0,0 +1,191 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace SVSim.Database.Migrations +{ + /// + public partial class AddBuildDeck : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "BuildDeckSeries", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false), + OrderIndex = table.Column(type: "integer", nullable: false), + NameKey = table.Column(type: "text", nullable: false), + IntroKey = table.Column(type: "text", nullable: false), + TitlePath = table.Column(type: "text", nullable: false), + DrumrollPath = table.Column(type: "text", nullable: false), + IsNew = table.Column(type: "boolean", nullable: false), + IsEnabled = table.Column(type: "boolean", nullable: false), + DateCreated = table.Column(type: "timestamp with time zone", nullable: false), + DateUpdated = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_BuildDeckSeries", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "ViewerBuildDeckProductPurchase", + columns: table => new + { + ViewerId = table.Column(type: "bigint", nullable: false), + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + ProductId = table.Column(type: "integer", nullable: false), + PurchaseCount = table.Column(type: "integer", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ViewerBuildDeckProductPurchase", x => new { x.ViewerId, x.Id }); + table.ForeignKey( + name: "FK_ViewerBuildDeckProductPurchase_Viewers_ViewerId", + column: x => x.ViewerId, + principalTable: "Viewers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "BuildDeckProducts", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false), + SeriesId = table.Column(type: "integer", nullable: false), + LeaderId = table.Column(type: "integer", nullable: false), + DeckCode = table.Column(type: "text", nullable: false), + ProductNameKey = table.Column(type: "text", nullable: false), + FeaturedCardId = table.Column(type: "bigint", nullable: false), + PurchaseNumMax = table.Column(type: "integer", nullable: false), + IntroPriceCrystal = table.Column(type: "integer", nullable: true), + RegularPriceCrystal = table.Column(type: "integer", nullable: true), + IntroPriceRupy = table.Column(type: "integer", nullable: true), + RegularPriceRupy = table.Column(type: "integer", nullable: true), + IsEnabled = table.Column(type: "boolean", nullable: false), + DateCreated = table.Column(type: "timestamp with time zone", nullable: false), + DateUpdated = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_BuildDeckProducts", x => x.Id); + table.ForeignKey( + name: "FK_BuildDeckProducts_BuildDeckSeries_SeriesId", + column: x => x.SeriesId, + principalTable: "BuildDeckSeries", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "BuildDeckSeriesRewardEntry", + columns: table => new + { + BuildDeckSeriesEntryId = table.Column(type: "integer", nullable: false), + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + TierIndex = table.Column(type: "integer", nullable: false), + ItemIndex = table.Column(type: "integer", nullable: false), + RewardType = table.Column(type: "integer", nullable: false), + RewardDetailId = table.Column(type: "bigint", nullable: false), + RewardNumber = table.Column(type: "integer", nullable: false), + MessageId = table.Column(type: "integer", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_BuildDeckSeriesRewardEntry", x => new { x.BuildDeckSeriesEntryId, x.Id }); + table.ForeignKey( + name: "FK_BuildDeckSeriesRewardEntry_BuildDeckSeries_BuildDeckSeriesE~", + column: x => x.BuildDeckSeriesEntryId, + principalTable: "BuildDeckSeries", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "BuildDeckProductCardEntry", + columns: table => new + { + BuildDeckProductEntryId = table.Column(type: "integer", nullable: false), + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + CardId = table.Column(type: "bigint", nullable: false), + Number = table.Column(type: "integer", nullable: false), + IsSpot = table.Column(type: "boolean", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_BuildDeckProductCardEntry", x => new { x.BuildDeckProductEntryId, x.Id }); + table.ForeignKey( + name: "FK_BuildDeckProductCardEntry_BuildDeckProducts_BuildDeckProduc~", + column: x => x.BuildDeckProductEntryId, + principalTable: "BuildDeckProducts", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "BuildDeckProductRewardEntry", + columns: table => new + { + BuildDeckProductEntryId = table.Column(type: "integer", nullable: false), + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + RewardIndex = table.Column(type: "integer", nullable: false), + RewardType = table.Column(type: "integer", nullable: false), + RewardDetailId = table.Column(type: "bigint", nullable: false), + RewardNumber = table.Column(type: "integer", nullable: false), + MessageId = table.Column(type: "integer", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_BuildDeckProductRewardEntry", x => new { x.BuildDeckProductEntryId, x.Id }); + table.ForeignKey( + name: "FK_BuildDeckProductRewardEntry_BuildDeckProducts_BuildDeckProd~", + column: x => x.BuildDeckProductEntryId, + principalTable: "BuildDeckProducts", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_BuildDeckProducts_SeriesId", + table: "BuildDeckProducts", + column: "SeriesId"); + + migrationBuilder.CreateIndex( + name: "IX_ViewerBuildDeckProductPurchase_ViewerId_ProductId", + table: "ViewerBuildDeckProductPurchase", + columns: new[] { "ViewerId", "ProductId" }, + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "BuildDeckProductCardEntry"); + + migrationBuilder.DropTable( + name: "BuildDeckProductRewardEntry"); + + migrationBuilder.DropTable( + name: "BuildDeckSeriesRewardEntry"); + + migrationBuilder.DropTable( + name: "ViewerBuildDeckProductPurchase"); + + migrationBuilder.DropTable( + name: "BuildDeckProducts"); + + migrationBuilder.DropTable( + name: "BuildDeckSeries"); + } + } +} diff --git a/SVSim.Database/Migrations/SVSimDbContextModelSnapshot.cs b/SVSim.Database/Migrations/SVSimDbContextModelSnapshot.cs index 7d6be25..d30c41a 100644 --- a/SVSim.Database/Migrations/SVSimDbContextModelSnapshot.cs +++ b/SVSim.Database/Migrations/SVSimDbContextModelSnapshot.cs @@ -540,6 +540,100 @@ namespace SVSim.Database.Migrations b.ToTable("Battlefields"); }); + modelBuilder.Entity("SVSim.Database.Models.BuildDeckProductEntry", b => + { + b.Property("Id") + .HasColumnType("integer"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("DateUpdated") + .HasColumnType("timestamp with time zone"); + + b.Property("DeckCode") + .IsRequired() + .HasColumnType("text"); + + b.Property("FeaturedCardId") + .HasColumnType("bigint"); + + b.Property("IntroPriceCrystal") + .HasColumnType("integer"); + + b.Property("IntroPriceRupy") + .HasColumnType("integer"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("LeaderId") + .HasColumnType("integer"); + + b.Property("ProductNameKey") + .IsRequired() + .HasColumnType("text"); + + b.Property("PurchaseNumMax") + .HasColumnType("integer"); + + b.Property("RegularPriceCrystal") + .HasColumnType("integer"); + + b.Property("RegularPriceRupy") + .HasColumnType("integer"); + + b.Property("SeriesId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("BuildDeckProducts"); + }); + + modelBuilder.Entity("SVSim.Database.Models.BuildDeckSeriesEntry", b => + { + b.Property("Id") + .HasColumnType("integer"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("DateUpdated") + .HasColumnType("timestamp with time zone"); + + b.Property("DrumrollPath") + .IsRequired() + .HasColumnType("text"); + + b.Property("IntroKey") + .IsRequired() + .HasColumnType("text"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("IsNew") + .HasColumnType("boolean"); + + b.Property("NameKey") + .IsRequired() + .HasColumnType("text"); + + b.Property("OrderIndex") + .HasColumnType("integer"); + + b.Property("TitlePath") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("BuildDeckSeries"); + }); + modelBuilder.Entity("SVSim.Database.Models.CardCosmeticReward", b => { b.Property("CardId") @@ -1975,6 +2069,125 @@ namespace SVSim.Database.Migrations b.Navigation("World"); }); + modelBuilder.Entity("SVSim.Database.Models.BuildDeckProductEntry", b => + { + b.HasOne("SVSim.Database.Models.BuildDeckSeriesEntry", "Series") + .WithMany("Products") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.OwnsMany("SVSim.Database.Models.BuildDeckProductCardEntry", "Cards", b1 => + { + b1.Property("BuildDeckProductEntryId") + .HasColumnType("integer"); + + b1.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property("Id")); + + b1.Property("CardId") + .HasColumnType("bigint"); + + b1.Property("IsSpot") + .HasColumnType("boolean"); + + b1.Property("Number") + .HasColumnType("integer"); + + b1.HasKey("BuildDeckProductEntryId", "Id"); + + b1.ToTable("BuildDeckProductCardEntry"); + + b1.WithOwner() + .HasForeignKey("BuildDeckProductEntryId"); + }); + + b.OwnsMany("SVSim.Database.Models.BuildDeckProductRewardEntry", "Rewards", b1 => + { + b1.Property("BuildDeckProductEntryId") + .HasColumnType("integer"); + + b1.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property("Id")); + + b1.Property("MessageId") + .HasColumnType("integer"); + + b1.Property("RewardDetailId") + .HasColumnType("bigint"); + + b1.Property("RewardIndex") + .HasColumnType("integer"); + + b1.Property("RewardNumber") + .HasColumnType("integer"); + + b1.Property("RewardType") + .HasColumnType("integer"); + + b1.HasKey("BuildDeckProductEntryId", "Id"); + + b1.ToTable("BuildDeckProductRewardEntry"); + + b1.WithOwner() + .HasForeignKey("BuildDeckProductEntryId"); + }); + + b.Navigation("Cards"); + + b.Navigation("Rewards"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("SVSim.Database.Models.BuildDeckSeriesEntry", b => + { + b.OwnsMany("SVSim.Database.Models.BuildDeckSeriesRewardEntry", "SeriesRewards", b1 => + { + b1.Property("BuildDeckSeriesEntryId") + .HasColumnType("integer"); + + b1.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property("Id")); + + b1.Property("ItemIndex") + .HasColumnType("integer"); + + b1.Property("MessageId") + .HasColumnType("integer"); + + b1.Property("RewardDetailId") + .HasColumnType("bigint"); + + b1.Property("RewardNumber") + .HasColumnType("integer"); + + b1.Property("RewardType") + .HasColumnType("integer"); + + b1.Property("TierIndex") + .HasColumnType("integer"); + + b1.HasKey("BuildDeckSeriesEntryId", "Id"); + + b1.ToTable("BuildDeckSeriesRewardEntry"); + + b1.WithOwner() + .HasForeignKey("BuildDeckSeriesEntryId"); + }); + + b.Navigation("SeriesRewards"); + }); + modelBuilder.Entity("SVSim.Database.Models.CardCosmeticReward", b => { b.HasOne("SVSim.Database.Models.ShadowverseCardEntry", "Card") @@ -2322,6 +2535,34 @@ namespace SVSim.Database.Migrations b1.Navigation("Viewer"); }); + b.OwnsMany("SVSim.Database.Models.ViewerBuildDeckProductPurchase", "BuildDeckPurchases", b1 => + { + b1.Property("ViewerId") + .HasColumnType("bigint"); + + b1.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property("Id")); + + b1.Property("ProductId") + .HasColumnType("integer"); + + b1.Property("PurchaseCount") + .HasColumnType("integer"); + + b1.HasKey("ViewerId", "Id"); + + b1.HasIndex("ViewerId", "ProductId") + .IsUnique(); + + b1.ToTable("ViewerBuildDeckProductPurchase"); + + b1.WithOwner() + .HasForeignKey("ViewerId"); + }); + b.OwnsMany("SVSim.Database.Models.ViewerClassData", "Classes", b1 => { b1.Property("ViewerId") @@ -2523,6 +2764,8 @@ namespace SVSim.Database.Migrations .HasForeignKey("ViewerId"); }); + b.Navigation("BuildDeckPurchases"); + b.Navigation("Cards"); b.Navigation("Classes"); @@ -2558,6 +2801,11 @@ namespace SVSim.Database.Migrations .IsRequired(); }); + modelBuilder.Entity("SVSim.Database.Models.BuildDeckSeriesEntry", b => + { + b.Navigation("Products"); + }); + modelBuilder.Entity("SVSim.Database.Models.ClassEntry", b => { b.Navigation("LeaderSkins"); diff --git a/SVSim.Database/Models/BuildDeckProductCardEntry.cs b/SVSim.Database/Models/BuildDeckProductCardEntry.cs new file mode 100644 index 0000000..dd69418 --- /dev/null +++ b/SVSim.Database/Models/BuildDeckProductCardEntry.cs @@ -0,0 +1,17 @@ +using Microsoft.EntityFrameworkCore; + +namespace SVSim.Database.Models; + +/// +/// One card in a prebuilt-deck product's 40-card list. Owned by BuildDeckProductEntry. +/// Shape mirrors `build_deck_package_master.csv` rows: (ProductId, CardId, Number, IsSpot). +/// IsSpot=true marks the special prize/featured cards rendered in the separate _spotCardRoot +/// panel by BuildDeckProductDetail.cs. +/// +[Owned] +public class BuildDeckProductCardEntry +{ + public long CardId { get; set; } + public int Number { get; set; } + public bool IsSpot { get; set; } +} diff --git a/SVSim.Database/Models/BuildDeckProductEntry.cs b/SVSim.Database/Models/BuildDeckProductEntry.cs new file mode 100644 index 0000000..7202bb7 --- /dev/null +++ b/SVSim.Database/Models/BuildDeckProductEntry.cs @@ -0,0 +1,32 @@ +using SVSim.Database.Common; + +namespace SVSim.Database.Models; + +/// +/// One purchasable prebuilt-deck product. PK = wire product_id. FK SeriesId. +/// Pricing columns are nullable; either Crystal or Rupy pair (or both, both zero for free) must +/// be populated for an enabled product. The Intro/Regular pair captures the two-tier pricing +/// pattern: Intro applies to the first purchase, Regular to subsequent. For PurchaseNumMax=1 +/// products, Regular stays null and only Intro is ever served. +/// +public class BuildDeckProductEntry : BaseEntity +{ + public int SeriesId { get; set; } + public int LeaderId { get; set; } + public string DeckCode { get; set; } = string.Empty; + public string ProductNameKey { get; set; } = string.Empty; // BDPN_* + public long FeaturedCardId { get; set; } + public int PurchaseNumMax { get; set; } + + public int? IntroPriceCrystal { get; set; } + public int? RegularPriceCrystal { get; set; } + public int? IntroPriceRupy { get; set; } + public int? RegularPriceRupy { get; set; } + + public bool IsEnabled { get; set; } + + public List Cards { get; set; } = new(); + public List Rewards { get; set; } = new(); + + public BuildDeckSeriesEntry? Series { get; set; } +} diff --git a/SVSim.Database/Models/BuildDeckProductRewardEntry.cs b/SVSim.Database/Models/BuildDeckProductRewardEntry.cs new file mode 100644 index 0000000..4edd619 --- /dev/null +++ b/SVSim.Database/Models/BuildDeckProductRewardEntry.cs @@ -0,0 +1,18 @@ +using Microsoft.EntityFrameworkCore; + +namespace SVSim.Database.Models; + +/// +/// One per-buy reward attached to a prebuilt-deck product. Owned by BuildDeckProductEntry. +/// Wire shape: one entry of the product-level `rewards` dict in /build_deck/info, keyed by +/// RewardIndex (the wire string keys "1","2","3"). +/// +[Owned] +public class BuildDeckProductRewardEntry +{ + public int RewardIndex { get; set; } + public int RewardType { get; set; } // Wizard.UserGoods.Type + public long RewardDetailId { get; set; } + public int RewardNumber { get; set; } + public int MessageId { get; set; } +} diff --git a/SVSim.Database/Models/BuildDeckSeriesEntry.cs b/SVSim.Database/Models/BuildDeckSeriesEntry.cs new file mode 100644 index 0000000..d17da98 --- /dev/null +++ b/SVSim.Database/Models/BuildDeckSeriesEntry.cs @@ -0,0 +1,22 @@ +using SVSim.Database.Common; + +namespace SVSim.Database.Models; + +/// +/// One prebuilt-deck series ("Structure Deck Set 7", "Trial 19", etc.). PK = wire series_id. +/// IsEnabled gates whether /build_deck/info renders this series — disabled rows are placeholder +/// stubs created from the client CSV until we capture a /info response that enriches them. +/// +public class BuildDeckSeriesEntry : BaseEntity +{ + public int OrderIndex { get; set; } // wire order_id; controls display order + public string NameKey { get; set; } = string.Empty; // BDSSN_* + public string IntroKey { get; set; } = string.Empty; // BDSI_* + public string TitlePath { get; set; } = string.Empty; + public string DrumrollPath { get; set; } = string.Empty; + public bool IsNew { get; set; } + public bool IsEnabled { get; set; } + + public List SeriesRewards { get; set; } = new(); + public List Products { get; set; } = new(); +} diff --git a/SVSim.Database/Models/BuildDeckSeriesRewardEntry.cs b/SVSim.Database/Models/BuildDeckSeriesRewardEntry.cs new file mode 100644 index 0000000..07a64c2 --- /dev/null +++ b/SVSim.Database/Models/BuildDeckSeriesRewardEntry.cs @@ -0,0 +1,20 @@ +using Microsoft.EntityFrameworkCore; + +namespace SVSim.Database.Models; + +/// +/// One tier-reward item attached to a prebuilt-deck series. Owned by BuildDeckSeriesEntry. +/// Wire shape: flattened from /build_deck/info's `series_rewards` dict — each tier (keyed +/// by total-purchases-from-series threshold) carries a list of rewards; this row is one +/// (TierIndex, ItemIndex) cell. +/// +[Owned] +public class BuildDeckSeriesRewardEntry +{ + public int TierIndex { get; set; } // 1, 2, 3, ... — unlock threshold + public int ItemIndex { get; set; } // ordinal within tier + public int RewardType { get; set; } + public long RewardDetailId { get; set; } + public int RewardNumber { get; set; } + public int MessageId { get; set; } +} diff --git a/SVSim.Database/Models/Viewer.cs b/SVSim.Database/Models/Viewer.cs index 73b7c2e..bcf49b2 100644 --- a/SVSim.Database/Models/Viewer.cs +++ b/SVSim.Database/Models/Viewer.cs @@ -57,6 +57,8 @@ public class Viewer : BaseEntity public List PackOpenCounts { get; set; } = new List(); + public List BuildDeckPurchases { get; set; } = new List(); + #endregion #region Navigation Properties diff --git a/SVSim.Database/Models/ViewerBuildDeckProductPurchase.cs b/SVSim.Database/Models/ViewerBuildDeckProductPurchase.cs new file mode 100644 index 0000000..1083810 --- /dev/null +++ b/SVSim.Database/Models/ViewerBuildDeckProductPurchase.cs @@ -0,0 +1,14 @@ +using Microsoft.EntityFrameworkCore; + +namespace SVSim.Database.Models; + +/// +/// Per-viewer, per-product purchase counter. Owned collection on Viewer. +/// Unique (ViewerId, ProductId) enforced in SVSimDbContext per project_owned_collection_unique_index. +/// +[Owned] +public class ViewerBuildDeckProductPurchase +{ + public int ProductId { get; set; } + public int PurchaseCount { get; set; } +} diff --git a/SVSim.Database/Repositories/BuildDeck/BuildDeckRepository.cs b/SVSim.Database/Repositories/BuildDeck/BuildDeckRepository.cs new file mode 100644 index 0000000..108f12f --- /dev/null +++ b/SVSim.Database/Repositories/BuildDeck/BuildDeckRepository.cs @@ -0,0 +1,67 @@ +using Microsoft.EntityFrameworkCore; +using SVSim.Database.Models; + +namespace SVSim.Database.Repositories.BuildDeck; + +public class BuildDeckRepository : IBuildDeckRepository +{ + private readonly SVSimDbContext _db; + public BuildDeckRepository(SVSimDbContext db) { _db = db; } + + public async Task> GetEnabledCatalog(int addSeriesId) + { + var q = _db.BuildDeckSeries + .Include(s => s.SeriesRewards) + .Include(s => s.Products.Where(p => p.IsEnabled)) + .ThenInclude(p => p.Cards) + .Include(s => s.Products.Where(p => p.IsEnabled)) + .ThenInclude(p => p.Rewards) + .Where(s => s.IsEnabled) + .AsSplitQuery(); + + if (addSeriesId != 0) + { + q = q.Where(s => s.Id == addSeriesId); + } + + var list = await q.ToListAsync(); + list.Sort((a, b) => b.OrderIndex.CompareTo(a.OrderIndex)); + return list; + } + + public async Task GetProduct(int productId) => + await _db.BuildDeckProducts + .Include(p => p.Cards) + .Include(p => p.Rewards) + .Include(p => p.Series).ThenInclude(s => s!.SeriesRewards) + .Include(p => p.Series).ThenInclude(s => s!.Products) + .AsSplitQuery() + .FirstOrDefaultAsync(p => p.Id == productId); + + public async Task> GetPurchasesForViewer(long viewerId) + { + var viewer = await _db.Viewers + .Include(v => v.BuildDeckPurchases) + .FirstOrDefaultAsync(v => v.Id == viewerId); + return viewer?.BuildDeckPurchases.ToDictionary(p => p.ProductId) ?? new(); + } + + public async Task IncrementPurchaseCount(long viewerId, int productId) + { + var viewer = await _db.Viewers + .Include(v => v.BuildDeckPurchases) + .FirstAsync(v => v.Id == viewerId); + var row = viewer.BuildDeckPurchases.FirstOrDefault(p => p.ProductId == productId); + if (row is null) + { + row = new ViewerBuildDeckProductPurchase { ProductId = productId, PurchaseCount = 1 }; + viewer.BuildDeckPurchases.Add(row); + } + else + { + row.PurchaseCount += 1; + } + await _db.SaveChangesAsync(); + return row.PurchaseCount; + } +} diff --git a/SVSim.Database/Repositories/BuildDeck/IBuildDeckRepository.cs b/SVSim.Database/Repositories/BuildDeck/IBuildDeckRepository.cs new file mode 100644 index 0000000..06828a3 --- /dev/null +++ b/SVSim.Database/Repositories/BuildDeck/IBuildDeckRepository.cs @@ -0,0 +1,29 @@ +using SVSim.Database.Models; + +namespace SVSim.Database.Repositories.BuildDeck; + +public interface IBuildDeckRepository +{ + /// + /// Load enabled series (filtered by addSeriesId when non-zero) with all owned children + /// for /build_deck/info. Series and per-series products are sorted by OrderIndex desc. + /// + Task> GetEnabledCatalog(int addSeriesId); + + /// + /// Load a single product (with Series + Cards + Rewards + Series.SeriesRewards) by id. + /// Returns null if absent. Used by /build_deck/buy and /build_deck/get_purchase_count. + /// + Task GetProduct(int productId); + + /// + /// Per-viewer purchase counter snapshot. Key = product_id. + /// + Task> GetPurchasesForViewer(long viewerId); + + /// + /// Increment the (ViewerId, ProductId) purchase counter by 1 (insert if absent). + /// Returns the new total. + /// + Task IncrementPurchaseCount(long viewerId, int productId); +} diff --git a/SVSim.Database/SVSimDbContext.cs b/SVSim.Database/SVSimDbContext.cs index 17ec585..618c849 100644 --- a/SVSim.Database/SVSimDbContext.cs +++ b/SVSim.Database/SVSimDbContext.cs @@ -59,6 +59,8 @@ public class SVSimDbContext : DbContext public DbSet SpecialDeckFormats => Set(); public DbSet PaymentItems => Set(); public DbSet Packs => Set(); + public DbSet BuildDeckSeries => Set(); + public DbSet BuildDeckProducts => Set(); public DbSet MaintenanceCards => Set(); public DbSet FeatureMaintenances => Set(); public DbSet PreReleaseInfos => Set(); @@ -141,6 +143,23 @@ public class SVSimDbContext : DbContext b.HasIndex("ViewerId", "ItemId").IsUnique(); }); + modelBuilder.Entity().OwnsMany(v => v.BuildDeckPurchases, b => + { + b.HasIndex("ViewerId", "ProductId").IsUnique(); + }); + + modelBuilder.Entity().OwnsMany(s => s.SeriesRewards); + modelBuilder.Entity().OwnsMany(p => p.Cards); + modelBuilder.Entity().OwnsMany(p => p.Rewards); + + modelBuilder.Entity() + .HasOne(p => p.Series) + .WithMany(s => s.Products) + .HasForeignKey(p => p.SeriesId) + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity().HasIndex(p => p.SeriesId); + modelBuilder.Entity(b => { b.HasKey(r => new { r.CardId, r.Type, r.CosmeticId }); diff --git a/SVSim.Database/Services/RewardGrantService.cs b/SVSim.Database/Services/RewardGrantService.cs index 3694010..e7c5a0f 100644 --- a/SVSim.Database/Services/RewardGrantService.cs +++ b/SVSim.Database/Services/RewardGrantService.cs @@ -14,14 +14,26 @@ namespace SVSim.Database.Services; public sealed record GrantedReward(int RewardType, long RewardId, int RewardNum); /// -/// Single canonical grant primitive. Switch on , mutate the -/// appropriate viewer collection / field, and return the -/// wire-shape entries the caller should embed in its response's reward_list. +/// Single canonical grant primitive for every the server hands to a +/// viewer. Switch on the type, mutate the appropriate viewer collection / +/// field, return the wire-shape entries to embed in the response's reward_list. +/// +/// +/// DO NOT reimplement reward dispatch in a controller or new helper. This service handles +/// RedEther, Crystal, Item, Card (with cascade), Sleeve, Emblem, +/// Degree, Rupy, Skin, MyPageBG — everything except SpotCard (TODO). Endpoint code that takes a +/// list of (type, id, num) tuples should iterate and call +/// per tuple — never switch on type yourself, never filter to "only card-typed rewards", never +/// build a second dispatch table. Past duplicate implementations (ICardAcquisitionService in the +/// EmulatedEntrypoint project, the first pass at /build_deck/buy) all silently dropped subsets of +/// types and produced the same bug: wire reward visible but viewer's collection unchanged. When a +/// new reward type comes up, add a case here. See feedback_reward_grant_service memory. +/// /// /// Card grants additionally run the cascade: any cosmetic -/// associated with the granted card that the viewer doesn't yet own is granted too, and -/// produces an additional entry in the returned list. That's why the return type is a list: -/// most types produce one entry, Card produces 1 + N. +/// associated with the granted card that the viewer doesn't yet own is granted too, and produces +/// an additional entry in the returned list. That's why the return type is a list: most types +/// produce one entry, Card produces 1 + N. /// /// Caller is responsible for — /// this service only mutates the in-memory graph so a controller can stack several grants in diff --git a/SVSim.EmulatedEntrypoint/Controllers/BuildDeckController.cs b/SVSim.EmulatedEntrypoint/Controllers/BuildDeckController.cs new file mode 100644 index 0000000..5fb3075 --- /dev/null +++ b/SVSim.EmulatedEntrypoint/Controllers/BuildDeckController.cs @@ -0,0 +1,329 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using SVSim.Database; +using SVSim.Database.Enums; +using SVSim.Database.Models; +using SVSim.Database.Repositories.BuildDeck; +using SVSim.Database.Services; +using SVSim.EmulatedEntrypoint.Models.Dtos; +using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.BuildDeck; +using SVSim.EmulatedEntrypoint.Models.Dtos.Responses.BuildDeck; + +namespace SVSim.EmulatedEntrypoint.Controllers; + +/// +/// /build_deck/* — the in-game "Structure Deck" prebuilt-deck shop. Catalog + +/// purchase + per-product purchase counter refresh. See +/// docs/superpowers/specs/2026-05-26-prebuilt-decks-design.md. +/// +[Route("build_deck")] +public class BuildDeckController : SVSimController +{ + private readonly IBuildDeckRepository _repo; + private readonly SVSimDbContext _db; + private readonly RewardGrantService _rewards; + + public BuildDeckController( + IBuildDeckRepository repo, + SVSimDbContext db, + RewardGrantService rewards) + { + _repo = repo; + _db = db; + _rewards = rewards; + } + + /// + /// Loads the viewer with the full cosmetic / inventory graph + BuildDeckPurchases. This is + /// the single load /build_deck/buy makes; every subsequent mutation operates on the returned + /// instance and the controller saves once at the end. + /// + private Task LoadViewerGraphAsync(long viewerId) => _db.Viewers + .Include(v => v.Cards).ThenInclude(c => c.Card) + .Include(v => v.LeaderSkins) + .Include(v => v.Sleeves) + .Include(v => v.Emblems) + .Include(v => v.Degrees) + .Include(v => v.MyPageBackgrounds) + .Include(v => v.Items).ThenInclude(i => i.Item) + .Include(v => v.BuildDeckPurchases) + .AsSplitQuery() + .FirstAsync(v => v.Id == viewerId); + + // The wire shape for /build_deck/info has `data` as a bare collection of series, not a + // DTO with a `series_list` field. The client (BuildDeckPurchaseInfoTask.Parse) iterates + // `data` directly via numeric indexer: + // for (int i = 0; i < data.Count; i++) data[i]["series_id"].ToInt(); + // So `data` must be either an array OR an object whose values are series. Wrapping in + // `{series_list: [...]}` breaks the iteration: `data.Count` is 1 and `data[0]` is the + // inner array, so `data[0]["series_id"]` throws "Instance of JsonData is not a dictionary". + // We return a bare array — simpler than the dict-keyed-by-order_id shape prod emits, and + // LitJson's numeric indexer iterates both shapes identically. + [HttpPost("info")] + public async Task>> Info(BuildDeckInfoRequest request) + { + if (!TryGetViewerId(out long viewerId)) return Unauthorized(); + + var series = await _repo.GetEnabledCatalog(request.AddSeriesId); + var purchases = await _repo.GetPurchasesForViewer(viewerId); + + return series.Select(s => ToSeriesDto(s, purchases)).ToList(); + } + + private static BuildDeckSeriesDto ToSeriesDto( + BuildDeckSeriesEntry s, + IReadOnlyDictionary purchases) + { + int totalSeriesPurchases = s.Products + .Sum(p => purchases.TryGetValue(p.Id, out var v) ? v.PurchaseCount : 0); + + return new BuildDeckSeriesDto + { + SeriesId = s.Id, + OrderId = s.OrderIndex, + IsNew = s.IsNew, + Products = s.Products + .OrderBy(p => p.Id) + .Select(p => ToProductDto(p, purchases)) + .ToList(), + SeriesRewards = GroupSeriesRewards(s.SeriesRewards, totalSeriesPurchases), + }; + } + + private static BuildDeckProductDto ToProductDto( + BuildDeckProductEntry p, + IReadOnlyDictionary purchases) + { + int current = purchases.TryGetValue(p.Id, out var v) ? v.PurchaseCount : 0; + bool isFirstPrice = current == 0; + int? priceCrystal = SelectPrice(isFirstPrice, p.IntroPriceCrystal, p.RegularPriceCrystal); + int? priceRupy = SelectPrice(isFirstPrice, p.IntroPriceRupy, p.RegularPriceRupy); + + return new BuildDeckProductDto + { + ProductId = p.Id, + ProductName = p.ProductNameKey, + LeaderId = p.LeaderId, + DeckCode = p.DeckCode, + FeaturedCardId = p.FeaturedCardId, + PurchaseNumMax = p.PurchaseNumMax, + PurchaseNumCurrent = current, + IsFirstPrice = isFirstPrice, + PriceCrystal = priceCrystal, + PriceRupy = priceRupy, + Rewards = p.Rewards + .OrderBy(r => r.RewardIndex) + .Select(r => new BuildDeckProductRewardDto + { + RewardType = r.RewardType, + RewardDetailId = r.RewardDetailId, + RewardNumber = r.RewardNumber, + MessageId = r.MessageId, + }).ToList(), + }; + } + + private static int? SelectPrice(bool isFirstPrice, int? intro, int? regular) + { + if (isFirstPrice) return intro ?? regular; // fall back when only one tier known + return regular ?? intro; + } + + private static List GroupSeriesRewards( + IReadOnlyList rows, + int totalSeriesPurchases) + { + return rows + .GroupBy(r => r.TierIndex) + .OrderBy(g => g.Key) + .Select(g => new BuildDeckSeriesRewardTierDto + { + IsGet = totalSeriesPurchases >= g.Key, + RewardList = g.OrderBy(r => r.ItemIndex).Select(r => new BuildDeckProductRewardDto + { + RewardType = r.RewardType, + RewardDetailId = r.RewardDetailId, + RewardNumber = r.RewardNumber, + MessageId = r.MessageId, + }).ToList(), + }).ToList(); + } + + [HttpPost("buy")] + public async Task> Buy(BuildDeckBuyRequest request) + { + if (!TryGetViewerId(out long viewerId)) return Unauthorized(); + + var product = await _repo.GetProduct(request.ProductId); + if (product is null) return NotFound(new { error = "unknown_product" }); + + if (!product.IsEnabled || product.Series is not { IsEnabled: true }) + return BadRequest(new { error = "product_not_available" }); + + if (request.SalesType is 3) + return StatusCode(StatusCodes.Status501NotImplemented, new { error = "ticket_currency_path_not_implemented" }); + if (request.SalesType is < 0 or > 3) + return BadRequest(new { error = "invalid_sales_type" }); + + var purchases = await _repo.GetPurchasesForViewer(viewerId); + int currentCount = purchases.TryGetValue(product.Id, out var pp) ? pp.PurchaseCount : 0; + if (currentCount >= product.PurchaseNumMax) + return BadRequest(new { error = "purchase_limit_reached" }); + + bool isFirstPrice = currentCount == 0; + int? priceCrystal = SelectPrice(isFirstPrice, product.IntroPriceCrystal, product.RegularPriceCrystal); + int? priceRupy = SelectPrice(isFirstPrice, product.IntroPriceRupy, product.RegularPriceRupy); + + // Currency validation + switch (request.SalesType) + { + case 0: // free + if (!(product.IntroPriceCrystal == 0 && product.IntroPriceRupy == 0)) + return BadRequest(new { error = "price_not_available_for_currency" }); + break; + case 1: // crystal + if (priceCrystal is null) + return BadRequest(new { error = "price_not_available_for_currency" }); + break; + case 2: // rupy + if (priceRupy is null) + return BadRequest(new { error = "price_not_available_for_currency" }); + break; + } + + // Single viewer load with the full graph — every subsequent mutation (currency debit, + // purchase counter, card grants, cosmetic grants) operates on this one in-memory instance + // so we can save once at the end. + var viewer = await LoadViewerGraphAsync(viewerId); + var rewardList = new List(); + + // Debit + post-state currency entry + if (request.SalesType == 1) + { + ulong cost = (ulong)priceCrystal!.Value; + if (viewer.Currency.Crystals < cost) + return BadRequest(new { error = "insufficient_crystals" }); + viewer.Currency.Crystals -= cost; + rewardList.Add(new RewardListEntry { RewardType = 2, RewardId = 0, RewardNum = (int)viewer.Currency.Crystals }); + } + else if (request.SalesType == 2) + { + ulong cost = (ulong)priceRupy!.Value; + if (viewer.Currency.Rupees < cost) + return BadRequest(new { error = "insufficient_rupees" }); + viewer.Currency.Rupees -= cost; + rewardList.Add(new RewardListEntry { RewardType = 9, RewardId = 0, RewardNum = (int)viewer.Currency.Rupees }); + } + // sales_type == 0 (free): no debit, no currency entry + + // Compute series purchase total BEFORE this buy + int prevSeriesCount = product.Series!.Products + .Sum(p => purchases.TryGetValue(p.Id, out var v) ? v.PurchaseCount : 0); + int newSeriesCount = prevSeriesCount + 1; + + // Increment purchase counter directly on the tracked viewer (we already loaded + // BuildDeckPurchases via LoadViewerGraphAsync). The repo's IncrementPurchaseCount would + // re-attach to the same instance and trigger an extra save — inlining keeps the + // controller's single-save model intact. + var purchaseRow = viewer.BuildDeckPurchases.FirstOrDefault(p => p.ProductId == product.Id); + if (purchaseRow is null) + viewer.BuildDeckPurchases.Add(new ViewerBuildDeckProductPurchase { ProductId = product.Id, PurchaseCount = 1 }); + else + purchaseRow.PurchaseCount += 1; + + // Grant the 40 deck cards. Bucket by CardId so duplicate (CardId, IsSpot) rows don't + // emit redundant reward_list entries — ApplyAsync(Card, ...) runs the cosmetic cascade + // and returns a post-state-total entry per call. + var deckGrants = product.Cards + .GroupBy(c => c.CardId) + .Select(g => (UserGoodsType.Card, g.Key, g.Sum(c => c.Number))); + await ApplyRewardsAsync(viewer, deckGrants, rewardList); + + // Per-buy rewards from the product catalog: sleeve, emblem, skin, sometimes extra cards + // (Set 4 grants 3 copies of the featured card as a type=5 reward). + await ApplyRewardsAsync(viewer, product.Rewards + .OrderBy(r => r.RewardIndex) + .Select(r => ((UserGoodsType)r.RewardType, r.RewardDetailId, r.RewardNumber)), + rewardList); + + // Series-reward tier crossings: tiers where prevSeriesCount < TierIndex <= newSeriesCount. + // Captured tiers include type 4 (Item), 5 (Card), 6 (Sleeve), 7 (Emblem) — granting them + // all uniformly avoids the earlier card-only path that dropped non-card tier rewards. + var crossedTiers = product.Series.SeriesRewards + .Where(r => r.TierIndex > prevSeriesCount && r.TierIndex <= newSeriesCount) + .GroupBy(r => r.TierIndex) + .OrderBy(g => g.Key) + .ToList(); + + var seriesRewards = new List(); + foreach (var tier in crossedTiers) + { + await ApplyRewardsAsync(viewer, tier + .OrderBy(r => r.ItemIndex) + .Select(r => ((UserGoodsType)r.RewardType, r.RewardDetailId, r.RewardNumber)), + rewardList); + + foreach (var item in tier.OrderBy(r => r.ItemIndex)) + { + seriesRewards.Add(new BuildDeckProductRewardDto + { + RewardType = item.RewardType, + RewardDetailId = item.RewardDetailId, + RewardNumber = item.RewardNumber, + MessageId = item.MessageId, + }); + } + } + + await _db.SaveChangesAsync(); + + return new BuildDeckBuyResponse + { + RewardList = rewardList, + SeriesRewards = seriesRewards, + }; + } + + /// + /// Dispatches each (type, id, num) tuple through + /// and appends the resulting wire entries to . Caller saves. + /// + private async Task ApplyRewardsAsync( + Viewer viewer, + IEnumerable<(UserGoodsType Type, long DetailId, int Number)> rewards, + List rewardList) + { + foreach (var (type, detailId, number) in rewards) + { + var granted = await _rewards.ApplyAsync(viewer, type, detailId, number); + foreach (var g in granted) + { + rewardList.Add(new RewardListEntry + { + RewardType = g.RewardType, + RewardId = g.RewardId, + RewardNum = g.RewardNum, + }); + } + } + } + + [HttpPost("get_purchase_count")] + public async Task> GetPurchaseCount( + BuildDeckGetPurchaseCountRequest request) + { + if (!TryGetViewerId(out long viewerId)) return Unauthorized(); + + var product = await _repo.GetProduct(request.ProductId); + if (product is null) return NotFound(new { error = "unknown_product" }); + + var purchases = await _repo.GetPurchasesForViewer(viewerId); + int current = purchases.TryGetValue(request.ProductId, out var p) ? p.PurchaseCount : 0; + + return new BuildDeckGetPurchaseCountResponse + { + PurchaseNumCurrent = current, + PurchaseNumMax = product.PurchaseNumMax, + }; + } +} diff --git a/SVSim.EmulatedEntrypoint/Controllers/DeckController.cs b/SVSim.EmulatedEntrypoint/Controllers/DeckController.cs index 305e562..a1f1f71 100644 --- a/SVSim.EmulatedEntrypoint/Controllers/DeckController.cs +++ b/SVSim.EmulatedEntrypoint/Controllers/DeckController.cs @@ -92,7 +92,18 @@ public class DeckController : SVSimController private async Task BuildDeckListResponseAsync(long viewerId, Format requestFormat) { var defaultDecks = await _globalsRepository.GetDefaultDecks(); - var leaderSkinSettings = await _globalsRepository.GetDefaultLeaderSkinSettings(); + + // user_leader_skin_setting_list is PER-VIEWER (the wire `user_` prefix is honest, despite + // the misleading docstring on DefaultLeaderSkinSetting). Source it from the viewer's + // ViewerClassData rows, matching how /load/index's user_class_list reads them. The global + // DefaultLeaderSkinSettings table is now used only as initial seed values for fresh + // viewers (ViewerRepository.RegisterViewer); the per-class current skin is on + // viewer.Classes[i].LeaderSkin and gets mutated by /leader_skin/update. + var viewerClasses = await _dbContext.Viewers + .Where(v => v.Id == viewerId) + .SelectMany(v => v.Classes) + .Select(c => new { c.Class.Id, LeaderSkinId = c.LeaderSkin.Id }) + .ToListAsync(); var response = new DeckListResponse { @@ -112,13 +123,13 @@ public class DeckController : SVSimController IsAvailableDeck = 1, MaintenanceCardIds = new(), }), - UserLeaderSkinSettingList = leaderSkinSettings.ToDictionary( - s => s.Id.ToString(), - s => new DefaultLeaderSkinSetting + UserLeaderSkinSettingList = viewerClasses.ToDictionary( + vc => vc.Id.ToString(), + vc => new DefaultLeaderSkinSetting { - ClassId = s.ClassId, - IsRandomLeaderSkin = s.IsRandomLeaderSkin, - LeaderSkinId = s.LeaderSkinId, + ClassId = vc.Id, + IsRandomLeaderSkin = 0, // random-skin mode (per-class shuffle pool) not yet persisted + LeaderSkinId = vc.LeaderSkinId, }), MaintenanceCardList = new(), // sourced from same place as /load/index when wired }; diff --git a/SVSim.EmulatedEntrypoint/Controllers/LeaderSkinController.cs b/SVSim.EmulatedEntrypoint/Controllers/LeaderSkinController.cs new file mode 100644 index 0000000..4b0771a --- /dev/null +++ b/SVSim.EmulatedEntrypoint/Controllers/LeaderSkinController.cs @@ -0,0 +1,64 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using SVSim.Database; +using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.LeaderSkin; +using SVSim.EmulatedEntrypoint.Models.Dtos.Responses.LeaderSkin; + +namespace SVSim.EmulatedEntrypoint.Controllers; + +/// +/// /leader_skin/* — per-class "active leader skin" preference. The per-CLASS setting is the +/// fallback used when a deck has leader_skin_id == 0; per-deck overrides go through +/// /deck/update_leader_skin instead. +/// +[Route("leader_skin")] +public class LeaderSkinController : SVSimController +{ + private readonly SVSimDbContext _db; + + public LeaderSkinController(SVSimDbContext db) + { + _db = db; + } + + [HttpPost("set")] + public async Task> Set(LeaderSkinSetRequest request) + { + if (!TryGetViewerId(out long viewerId)) return Unauthorized(); + + if (request.IsRandomLeaderSkin) + { + // Random-skin mode needs a per-viewer per-class shuffle pool, which we don't + // persist yet (ViewerClassData has no list field for it). Punt for now. + return StatusCode(StatusCodes.Status501NotImplemented, + new { error = "random_leader_skin_not_implemented" }); + } + + var viewer = await _db.Viewers + .Include(v => v.Classes).ThenInclude(c => c.Class) + .Include(v => v.Classes).ThenInclude(c => c.LeaderSkin) + .Include(v => v.LeaderSkins) + .FirstOrDefaultAsync(v => v.Id == viewerId); + if (viewer is null) return Unauthorized(); + + var classData = viewer.Classes.FirstOrDefault(c => c.Class.Id == request.ClassId); + if (classData is null) return BadRequest(new { error = "unknown_class" }); + + // Skin must (a) exist in the catalog, (b) match the target class, (c) be owned by the viewer. + var skin = await _db.LeaderSkins.FindAsync(request.LeaderSkinId); + if (skin is null) return BadRequest(new { error = "unknown_skin" }); + if (skin.ClassId != request.ClassId) return BadRequest(new { error = "skin_class_mismatch" }); + if (viewer.LeaderSkins.All(s => s.Id != skin.Id)) + return BadRequest(new { error = "skin_not_owned" }); + + classData.LeaderSkin = skin; + await _db.SaveChangesAsync(); + + return new LeaderSkinSetResponse + { + IsRandomLeaderSkin = false, + LeaderSkinId = skin.Id, + LeaderSkinIdList = new(), + }; + } +} diff --git a/SVSim.EmulatedEntrypoint/Models/Dtos/Requests/BuildDeck/BuildDeckBuyRequest.cs b/SVSim.EmulatedEntrypoint/Models/Dtos/Requests/BuildDeck/BuildDeckBuyRequest.cs new file mode 100644 index 0000000..0d6606f --- /dev/null +++ b/SVSim.EmulatedEntrypoint/Models/Dtos/Requests/BuildDeck/BuildDeckBuyRequest.cs @@ -0,0 +1,21 @@ +using System.Text.Json.Serialization; +using MessagePack; +using SVSim.EmulatedEntrypoint.Models.Dtos.Requests; + +namespace SVSim.EmulatedEntrypoint.Models.Dtos.Requests.BuildDeck; + +/// +/// /build_deck/buy request body. sales_type is ShopCommonUtility.SalesType: +/// 0=free, 1=crystal, 2=rupy, 3=ticket (v1: 3 returns 501). +/// +[MessagePackObject] +public class BuildDeckBuyRequest : BaseRequest +{ + [JsonPropertyName("product_id")] + [Key("product_id")] + public int ProductId { get; set; } + + [JsonPropertyName("sales_type")] + [Key("sales_type")] + public int SalesType { get; set; } +} diff --git a/SVSim.EmulatedEntrypoint/Models/Dtos/Requests/BuildDeck/BuildDeckGetPurchaseCountRequest.cs b/SVSim.EmulatedEntrypoint/Models/Dtos/Requests/BuildDeck/BuildDeckGetPurchaseCountRequest.cs new file mode 100644 index 0000000..7c1b794 --- /dev/null +++ b/SVSim.EmulatedEntrypoint/Models/Dtos/Requests/BuildDeck/BuildDeckGetPurchaseCountRequest.cs @@ -0,0 +1,13 @@ +using System.Text.Json.Serialization; +using MessagePack; +using SVSim.EmulatedEntrypoint.Models.Dtos.Requests; + +namespace SVSim.EmulatedEntrypoint.Models.Dtos.Requests.BuildDeck; + +[MessagePackObject] +public class BuildDeckGetPurchaseCountRequest : BaseRequest +{ + [JsonPropertyName("product_id")] + [Key("product_id")] + public int ProductId { get; set; } +} diff --git a/SVSim.EmulatedEntrypoint/Models/Dtos/Requests/BuildDeck/BuildDeckInfoRequest.cs b/SVSim.EmulatedEntrypoint/Models/Dtos/Requests/BuildDeck/BuildDeckInfoRequest.cs new file mode 100644 index 0000000..b13f72e --- /dev/null +++ b/SVSim.EmulatedEntrypoint/Models/Dtos/Requests/BuildDeck/BuildDeckInfoRequest.cs @@ -0,0 +1,17 @@ +using System.Text.Json.Serialization; +using MessagePack; +using SVSim.EmulatedEntrypoint.Models.Dtos.Requests; + +namespace SVSim.EmulatedEntrypoint.Models.Dtos.Requests.BuildDeck; + +/// +/// /build_deck/info request body. add_series_id == 0 means "return all"; non-zero filters +/// to the single matching series (used by the client to re-fetch after a purchase). +/// +[MessagePackObject] +public class BuildDeckInfoRequest : BaseRequest +{ + [JsonPropertyName("add_series_id")] + [Key("add_series_id")] + public int AddSeriesId { get; set; } +} diff --git a/SVSim.EmulatedEntrypoint/Models/Dtos/Requests/LeaderSkin/LeaderSkinSetRequest.cs b/SVSim.EmulatedEntrypoint/Models/Dtos/Requests/LeaderSkin/LeaderSkinSetRequest.cs new file mode 100644 index 0000000..497eb52 --- /dev/null +++ b/SVSim.EmulatedEntrypoint/Models/Dtos/Requests/LeaderSkin/LeaderSkinSetRequest.cs @@ -0,0 +1,33 @@ +using System.Text.Json.Serialization; +using MessagePack; +using SVSim.EmulatedEntrypoint.Models.Dtos.Requests; + +namespace SVSim.EmulatedEntrypoint.Models.Dtos.Requests.LeaderSkin; + +/// +/// POST /leader_skin/set — the per-class "current leader skin" preference used as a fallback +/// when a deck has leader_skin_id == 0. Two modes: +/// - Non-random: is_random_leader_skin=false, leader_skin_id is the chosen skin id. +/// - Random: is_random_leader_skin=true, leader_skin_id_list is the shuffle pool +/// (server picks per-match). Random mode is not implemented in v1 (returns 501). +/// Source: Wizard/LeaderSkinUpdateTask.cs. +/// +[MessagePackObject] +public class LeaderSkinSetRequest : BaseRequest +{ + [JsonPropertyName("class_id")] + [Key("class_id")] + public int ClassId { get; set; } + + [JsonPropertyName("leader_skin_id")] + [Key("leader_skin_id")] + public int LeaderSkinId { get; set; } + + [JsonPropertyName("is_random_leader_skin")] + [Key("is_random_leader_skin")] + public bool IsRandomLeaderSkin { get; set; } + + [JsonPropertyName("leader_skin_id_list")] + [Key("leader_skin_id_list")] + public int[] LeaderSkinIdList { get; set; } = Array.Empty(); +} diff --git a/SVSim.EmulatedEntrypoint/Models/Dtos/Responses/BuildDeck/BuildDeckBuyResponse.cs b/SVSim.EmulatedEntrypoint/Models/Dtos/Responses/BuildDeck/BuildDeckBuyResponse.cs new file mode 100644 index 0000000..bb2d5a4 --- /dev/null +++ b/SVSim.EmulatedEntrypoint/Models/Dtos/Responses/BuildDeck/BuildDeckBuyResponse.cs @@ -0,0 +1,22 @@ +using System.Text.Json.Serialization; +using MessagePack; +using SVSim.EmulatedEntrypoint.Models.Dtos; + +namespace SVSim.EmulatedEntrypoint.Models.Dtos.Responses.BuildDeck; + +/// +/// /build_deck/buy response. reward_list items use reward_id/reward_num (driven by +/// PlayerStaticData.UpdateHaveUserGoodsNumByJsonData with POST-STATE-TOTAL semantics); +/// series_rewards items use reward_detail_id/reward_number — different naming, intentional. +/// +[MessagePackObject] +public class BuildDeckBuyResponse +{ + [JsonPropertyName("reward_list")] + [Key("reward_list")] + public List RewardList { get; set; } = new(); + + [JsonPropertyName("series_rewards")] + [Key("series_rewards")] + public List SeriesRewards { get; set; } = new(); +} diff --git a/SVSim.EmulatedEntrypoint/Models/Dtos/Responses/BuildDeck/BuildDeckGetPurchaseCountResponse.cs b/SVSim.EmulatedEntrypoint/Models/Dtos/Responses/BuildDeck/BuildDeckGetPurchaseCountResponse.cs new file mode 100644 index 0000000..ded2d04 --- /dev/null +++ b/SVSim.EmulatedEntrypoint/Models/Dtos/Responses/BuildDeck/BuildDeckGetPurchaseCountResponse.cs @@ -0,0 +1,16 @@ +using System.Text.Json.Serialization; +using MessagePack; + +namespace SVSim.EmulatedEntrypoint.Models.Dtos.Responses.BuildDeck; + +[MessagePackObject] +public class BuildDeckGetPurchaseCountResponse +{ + [JsonPropertyName("purchase_num_current")] + [Key("purchase_num_current")] + public int PurchaseNumCurrent { get; set; } + + [JsonPropertyName("purchase_num_max")] + [Key("purchase_num_max")] + public int PurchaseNumMax { get; set; } +} diff --git a/SVSim.EmulatedEntrypoint/Models/Dtos/Responses/BuildDeck/BuildDeckInfoResponse.cs b/SVSim.EmulatedEntrypoint/Models/Dtos/Responses/BuildDeck/BuildDeckInfoResponse.cs new file mode 100644 index 0000000..c07594e --- /dev/null +++ b/SVSim.EmulatedEntrypoint/Models/Dtos/Responses/BuildDeck/BuildDeckInfoResponse.cs @@ -0,0 +1,118 @@ +using System.Text.Json.Serialization; +using MessagePack; + +namespace SVSim.EmulatedEntrypoint.Models.Dtos.Responses.BuildDeck; + +// /build_deck/info wire shape: the controller returns `List` directly so +// `data` becomes a bare array `[{series_id:...},...]`. The client iterates `data` via numeric +// indexer; a wrapper object like `{series_list:[...]}` would put the array one level deeper +// and break the iteration. There is no BuildDeckInfoResponse wrapper type — the response IS +// the series list. + +[MessagePackObject] +public class BuildDeckSeriesDto +{ + [JsonPropertyName("series_id")] + [Key("series_id")] + public int SeriesId { get; set; } + + [JsonPropertyName("order_id")] + [Key("order_id")] + public int OrderId { get; set; } + + [JsonPropertyName("is_new")] + [Key("is_new")] + public bool IsNew { get; set; } + + [JsonPropertyName("products")] + [Key("products")] + public List Products { get; set; } = new(); + + [JsonPropertyName("series_rewards")] + [Key("series_rewards")] + public List SeriesRewards { get; set; } = new(); +} + +[MessagePackObject] +public class BuildDeckProductDto +{ + [JsonPropertyName("product_id")] + [Key("product_id")] + public int ProductId { get; set; } + + [JsonPropertyName("product_name")] + [Key("product_name")] + public string ProductName { get; set; } = string.Empty; + + [JsonPropertyName("leader_id")] + [Key("leader_id")] + public int LeaderId { get; set; } + + [JsonPropertyName("deck_code")] + [Key("deck_code")] + public string DeckCode { get; set; } = string.Empty; + + [JsonPropertyName("featured_card_id")] + [Key("featured_card_id")] + public long FeaturedCardId { get; set; } + + [JsonPropertyName("purchase_num_max")] + [Key("purchase_num_max")] + public int PurchaseNumMax { get; set; } + + [JsonPropertyName("purchase_num_current")] + [Key("purchase_num_current")] + public int PurchaseNumCurrent { get; set; } + + [JsonPropertyName("is_first_price")] + [Key("is_first_price")] + public bool IsFirstPrice { get; set; } + + [JsonPropertyName("rewards")] + [Key("rewards")] + public List Rewards { get; set; } = new(); + + [JsonPropertyName("sales_period_info")] + [Key("sales_period_info")] + public List SalesPeriodInfo { get; set; } = new(); // always [] in v1 + + [JsonPropertyName("price_crystal")] + [Key("price_crystal")] + public int? PriceCrystal { get; set; } + + [JsonPropertyName("price_rupy")] + [Key("price_rupy")] + public int? PriceRupy { get; set; } +} + +[MessagePackObject] +public class BuildDeckProductRewardDto +{ + [JsonPropertyName("reward_type")] + [Key("reward_type")] + public int RewardType { get; set; } + + [JsonPropertyName("reward_detail_id")] + [Key("reward_detail_id")] + public long RewardDetailId { get; set; } + + [JsonPropertyName("reward_number")] + [Key("reward_number")] + public int RewardNumber { get; set; } + + [JsonPropertyName("message_id")] + [Key("message_id")] + public int MessageId { get; set; } +} + +[MessagePackObject] +public class BuildDeckSeriesRewardTierDto +{ + [JsonPropertyName("reward_list")] + [Key("reward_list")] + public List RewardList { get; set; } = new(); + + [JsonPropertyName("is_get")] + [Key("is_get")] + public bool IsGet { get; set; } +} diff --git a/SVSim.EmulatedEntrypoint/Models/Dtos/Responses/LeaderSkin/LeaderSkinSetResponse.cs b/SVSim.EmulatedEntrypoint/Models/Dtos/Responses/LeaderSkin/LeaderSkinSetResponse.cs new file mode 100644 index 0000000..b04ad2f --- /dev/null +++ b/SVSim.EmulatedEntrypoint/Models/Dtos/Responses/LeaderSkin/LeaderSkinSetResponse.cs @@ -0,0 +1,27 @@ +using System.Text.Json.Serialization; +using MessagePack; + +namespace SVSim.EmulatedEntrypoint.Models.Dtos.Responses.LeaderSkin; + +/// +/// Response shape for POST /leader_skin/set. Per LeaderSkinUpdateTask.Parse: +/// - is_random_leader_skin echoes the mode the server actually applied. +/// - leader_skin_id is only consumed by the client when random mode is on (it picks +/// one of the pool to display). In non-random mode the client uses the request's id. +/// - leader_skin_id_list is the active shuffle pool (empty for non-random). +/// +[MessagePackObject] +public class LeaderSkinSetResponse +{ + [JsonPropertyName("is_random_leader_skin")] + [Key("is_random_leader_skin")] + public bool IsRandomLeaderSkin { get; set; } + + [JsonPropertyName("leader_skin_id")] + [Key("leader_skin_id")] + public int LeaderSkinId { get; set; } + + [JsonPropertyName("leader_skin_id_list")] + [Key("leader_skin_id_list")] + public List LeaderSkinIdList { get; set; } = new(); +} diff --git a/SVSim.EmulatedEntrypoint/Program.cs b/SVSim.EmulatedEntrypoint/Program.cs index 6f01c73..6e6c187 100644 --- a/SVSim.EmulatedEntrypoint/Program.cs +++ b/SVSim.EmulatedEntrypoint/Program.cs @@ -2,6 +2,7 @@ using System.Text.Json; using System.Text.Json.Serialization; using Microsoft.EntityFrameworkCore; using SVSim.Database; +using SVSim.Database.Repositories.BuildDeck; using SVSim.Database.Repositories.Card; using SVSim.Database.Repositories.Collectibles; using SVSim.Database.Repositories.Deck; @@ -73,6 +74,7 @@ public class Program builder.Services.AddTransient(); builder.Services.AddTransient(); builder.Services.AddTransient(); + builder.Services.AddTransient(); // Scoped (not Singleton) to avoid the singleton-depends-on-scoped-DbContext lifecycle // 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. diff --git a/SVSim.UnitTests/Controllers/BuildDeckControllerBuyTests.cs b/SVSim.UnitTests/Controllers/BuildDeckControllerBuyTests.cs new file mode 100644 index 0000000..f8d1fd5 --- /dev/null +++ b/SVSim.UnitTests/Controllers/BuildDeckControllerBuyTests.cs @@ -0,0 +1,441 @@ +using System.Net; +using System.Text; +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using SVSim.Database; +using SVSim.Database.Enums; +using SVSim.Database.Models; +using SVSim.UnitTests.Infrastructure; + +namespace SVSim.UnitTests.Controllers; + +public class BuildDeckControllerBuyTests +{ + private static StringContent JsonBody(string json) => new(json, Encoding.UTF8, "application/json"); + + /// + /// Seeds: series 101 (enabled), one crystal-priced product 1 (intro=500/regular=750, max=3) + /// containing 2 distinct cards (10001001 ×2, 10001002 ×1). Caller may set viewer crystals. + /// + private static async Task SeedCrystalProduct(SVSimTestFactory f, long viewerId, ulong crystals) + { + using var scope = f.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + db.BuildDeckSeries.Add(new BuildDeckSeriesEntry + { + Id = 101, OrderIndex = 22, IsEnabled = true, NameKey = "BDSSN_test", IntroKey = "BDSI_test", + Products = + { + new BuildDeckProductEntry + { + Id = 1, SeriesId = 101, LeaderId = 1, DeckCode = "pd0101", + PurchaseNumMax = 3, IntroPriceCrystal = 500, RegularPriceCrystal = 750, + IsEnabled = true, + Cards = + { + new BuildDeckProductCardEntry { CardId = 10001001L, Number = 2, IsSpot = false }, + new BuildDeckProductCardEntry { CardId = 10001002L, Number = 1, IsSpot = false }, + }, + }, + }, + }); + var v = await db.Viewers.FirstAsync(x => x.Id == viewerId); + v.Currency.Crystals = crystals; + await db.SaveChangesAsync(); + } + + private static async Task SeedRupyProduct(SVSimTestFactory f, long viewerId, ulong rupees) + { + using var scope = f.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + db.BuildDeckSeries.Add(new BuildDeckSeriesEntry + { + Id = 102, OrderIndex = 23, IsEnabled = true, NameKey = "BDSSN_rupy", IntroKey = "BDSI_rupy", + Products = + { + new BuildDeckProductEntry + { + Id = 10, SeriesId = 102, LeaderId = 2, DeckCode = "pdR", + PurchaseNumMax = 1, IntroPriceRupy = 100, + IsEnabled = true, + Cards = { new BuildDeckProductCardEntry { CardId = 10001001L, Number = 1, IsSpot = false } }, + }, + }, + }); + var v = await db.Viewers.FirstAsync(x => x.Id == viewerId); + v.Currency.Rupees = rupees; + await db.SaveChangesAsync(); + } + + private static async Task SeedFreeProduct(SVSimTestFactory f, long viewerId) + { + using var scope = f.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + db.BuildDeckSeries.Add(new BuildDeckSeriesEntry + { + Id = 103, OrderIndex = 24, IsEnabled = true, NameKey = "BDSSN_free", IntroKey = "BDSI_free", + Products = + { + new BuildDeckProductEntry + { + Id = 20, SeriesId = 103, LeaderId = 3, DeckCode = "pdF", + PurchaseNumMax = 1, IntroPriceCrystal = 0, IntroPriceRupy = 0, + IsEnabled = true, + Cards = { new BuildDeckProductCardEntry { CardId = 10001003L, Number = 1, IsSpot = false } }, + }, + }, + }); + await db.SaveChangesAsync(); + } + + /// + /// Seeds: series 104 + product 100 with a per-buy sleeve reward (id 3000021, a real seeded + /// sleeve master row). Used to verify the per-buy rewards path that drops sleeve/emblem/skin + /// grants if the controller's Rewards iteration is missing. + /// + private static async Task SeedProductWithSleeveReward(SVSimTestFactory f, long viewerId) + { + using var scope = f.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + db.BuildDeckSeries.Add(new BuildDeckSeriesEntry + { + Id = 104, OrderIndex = 19, IsEnabled = true, NameKey = "BDSSN_sleeve", IntroKey = "BDSI_sleeve", + Products = + { + new BuildDeckProductEntry + { + Id = 100, SeriesId = 104, LeaderId = 1, DeckCode = "pd0104", + PurchaseNumMax = 1, IntroPriceCrystal = 0, IntroPriceRupy = 0, // free + IsEnabled = true, + Cards = { new BuildDeckProductCardEntry { CardId = 10001001L, Number = 1, IsSpot = false } }, + Rewards = + { + new BuildDeckProductRewardEntry + { + RewardIndex = 1, RewardType = 6 /* Sleeve */, + RewardDetailId = 3000021, RewardNumber = 1, MessageId = 51004, + }, + }, + }, + }, + }); + await db.SaveChangesAsync(); + } + + [Test] + public async Task Buy_grants_per_buy_sleeve_reward_to_viewer_collection() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(); + await SeedProductWithSleeveReward(factory, viewerId); + + using var client = factory.CreateAuthenticatedClient(viewerId); + var json = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","product_id":100,"sales_type":0}"""; + var response = await client.PostAsync("/build_deck/buy", JsonBody(json)); + + var body = await response.Content.ReadAsStringAsync(); + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), body); + + using var scope = factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var v = await db.Viewers.Include(x => x.Sleeves).FirstAsync(x => x.Id == viewerId); + Assert.That(v.Sleeves.Any(s => s.Id == 3000021), Is.True, + "per-buy sleeve reward must land in viewer's owned collection"); + + using var doc = JsonDocument.Parse(body); + var entries = doc.RootElement.GetProperty("reward_list"); + bool foundSleeve = false; + for (int i = 0; i < entries.GetArrayLength(); i++) + { + var e = entries[i]; + if (e.GetProperty("reward_type").GetInt32() == 6 && e.GetProperty("reward_id").GetInt64() == 3000021) + foundSleeve = true; + } + Assert.That(foundSleeve, Is.True, "reward_list must include the granted sleeve entry"); + } + + [Test] + public async Task Crystal_buy_debits_intro_price_and_grants_cards() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(); + await SeedCrystalProduct(factory, viewerId, crystals: 1000); + + using var client = factory.CreateAuthenticatedClient(viewerId); + var json = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","product_id":1,"sales_type":1}"""; + var response = await client.PostAsync("/build_deck/buy", JsonBody(json)); + + var body = await response.Content.ReadAsStringAsync(); + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), body); + + using var scope = factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var v = await db.Viewers + .Include(x => x.Cards).ThenInclude(c => c.Card) + .Include(x => x.BuildDeckPurchases) + .FirstAsync(x => x.Id == viewerId); + + Assert.That(v.Currency.Crystals, Is.EqualTo(500UL), "1000 - 500 intro"); + Assert.That(v.Cards.Sum(c => c.Count), Is.EqualTo(3), "2 + 1 cards granted"); + Assert.That(v.BuildDeckPurchases.Single(p => p.ProductId == 1).PurchaseCount, Is.EqualTo(1)); + } + + [Test] + public async Task Crystal_buy_emits_post_state_total_for_crystals() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(); + await SeedCrystalProduct(factory, viewerId, crystals: 1000); + + using var client = factory.CreateAuthenticatedClient(viewerId); + var json = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","product_id":1,"sales_type":1}"""; + var response = await client.PostAsync("/build_deck/buy", JsonBody(json)); + var body = await response.Content.ReadAsStringAsync(); + + using var doc = JsonDocument.Parse(body); + var rewardList = doc.RootElement.GetProperty("reward_list"); + bool foundCrystals = false; + for (int i = 0; i < rewardList.GetArrayLength(); i++) + { + var e = rewardList[i]; + if (e.GetProperty("reward_type").GetInt32() == 2) + { + Assert.That(e.GetProperty("reward_num").GetInt32(), Is.EqualTo(500), "post-state crystals total"); + foundCrystals = true; + } + } + Assert.That(foundCrystals, Is.True, "crystal entry must be in reward_list"); + } + + [Test] + public async Task Returns_BadRequest_when_insufficient_crystals() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(); + await SeedCrystalProduct(factory, viewerId, crystals: 100); + + using var client = factory.CreateAuthenticatedClient(viewerId); + var json = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","product_id":1,"sales_type":1}"""; + var response = await client.PostAsync("/build_deck/buy", JsonBody(json)); + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest)); + } + + [Test] + public async Task Returns_BadRequest_for_disabled_product() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(); + using (var scope = factory.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + db.BuildDeckSeries.Add(new BuildDeckSeriesEntry + { + Id = 101, OrderIndex = 22, IsEnabled = true, NameKey = "x", IntroKey = "x", + Products = + { + new BuildDeckProductEntry + { + Id = 999, SeriesId = 101, PurchaseNumMax = 1, IntroPriceCrystal = 500, + IsEnabled = false, + }, + }, + }); + await db.SaveChangesAsync(); + } + using var client = factory.CreateAuthenticatedClient(viewerId); + var json = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","product_id":999,"sales_type":1}"""; + var response = await client.PostAsync("/build_deck/buy", JsonBody(json)); + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest)); + } + + [Test] + public async Task Returns_BadRequest_when_purchase_limit_reached() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(); + await SeedCrystalProduct(factory, viewerId, crystals: 10000); + + using (var scope = factory.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + var v = await db.Viewers.Include(x => x.BuildDeckPurchases).FirstAsync(x => x.Id == viewerId); + v.BuildDeckPurchases.Add(new ViewerBuildDeckProductPurchase { ProductId = 1, PurchaseCount = 3 }); + await db.SaveChangesAsync(); + } + + using var client = factory.CreateAuthenticatedClient(viewerId); + var json = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","product_id":1,"sales_type":1}"""; + var response = await client.PostAsync("/build_deck/buy", JsonBody(json)); + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest)); + } + + [Test] + public async Task Returns_BadRequest_when_paying_in_unsupported_currency_for_product() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(); + await SeedCrystalProduct(factory, viewerId, crystals: 1000); // crystal-only product + + using var client = factory.CreateAuthenticatedClient(viewerId); + // sales_type=2 (rupy) against a crystal-only product + var json = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","product_id":1,"sales_type":2}"""; + var response = await client.PostAsync("/build_deck/buy", JsonBody(json)); + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest)); + } + + [Test] + public async Task Returns_501_for_ticket_sales_type() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(); + await SeedCrystalProduct(factory, viewerId, crystals: 1000); + + using var client = factory.CreateAuthenticatedClient(viewerId); + var json = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","product_id":1,"sales_type":3}"""; + var response = await client.PostAsync("/build_deck/buy", JsonBody(json)); + Assert.That((int)response.StatusCode, Is.EqualTo(501)); + } + + [Test] + public async Task Rupy_buy_debits_and_grants() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(); + await SeedRupyProduct(factory, viewerId, rupees: 200); + + using var client = factory.CreateAuthenticatedClient(viewerId); + var json = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","product_id":10,"sales_type":2}"""; + var response = await client.PostAsync("/build_deck/buy", JsonBody(json)); + + var body = await response.Content.ReadAsStringAsync(); + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), body); + + using var scope = factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var v = await db.Viewers.Include(x => x.Cards).FirstAsync(x => x.Id == viewerId); + Assert.That(v.Currency.Rupees, Is.EqualTo(100UL)); + + using var doc = JsonDocument.Parse(body); + var entries = doc.RootElement.GetProperty("reward_list"); + bool foundRupy = false; + for (int i = 0; i < entries.GetArrayLength(); i++) + { + if (entries[i].GetProperty("reward_type").GetInt32() == 9) + { + Assert.That(entries[i].GetProperty("reward_num").GetInt32(), Is.EqualTo(100)); + foundRupy = true; + } + } + Assert.That(foundRupy, Is.True); + } + + [Test] + public async Task Free_buy_grants_cards_without_currency_entry() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(); + await SeedFreeProduct(factory, viewerId); + + using var client = factory.CreateAuthenticatedClient(viewerId); + var json = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","product_id":20,"sales_type":0}"""; + var response = await client.PostAsync("/build_deck/buy", JsonBody(json)); + + var body = await response.Content.ReadAsStringAsync(); + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), body); + + using var doc = JsonDocument.Parse(body); + var entries = doc.RootElement.GetProperty("reward_list"); + for (int i = 0; i < entries.GetArrayLength(); i++) + { + int t = entries[i].GetProperty("reward_type").GetInt32(); + Assert.That(t, Is.Not.EqualTo(2), "free buy must not emit Crystal entry"); + Assert.That(t, Is.Not.EqualTo(9), "free buy must not emit Rupy entry"); + } + } + + [Test] + public async Task Free_buy_against_nonfree_product_returns_BadRequest() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(); + await SeedCrystalProduct(factory, viewerId, crystals: 1000); + + using var client = factory.CreateAuthenticatedClient(viewerId); + var json = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","product_id":1,"sales_type":0}"""; + var response = await client.PostAsync("/build_deck/buy", JsonBody(json)); + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest)); + } + + [Test] + public async Task Buy_emits_newly_unlocked_series_tier_rewards() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(); + using (var scope = factory.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + db.BuildDeckSeries.Add(new BuildDeckSeriesEntry + { + Id = 105, OrderIndex = 18, IsEnabled = true, NameKey = "x", IntroKey = "x", + SeriesRewards = + { + // Tier 1: one card reward, unlocked on the 1st series purchase. + new BuildDeckSeriesRewardEntry + { + TierIndex = 1, ItemIndex = 0, RewardType = 5, + RewardDetailId = 10001001L, RewardNumber = 1, MessageId = 51004, + }, + // Tier 2: one card reward, unlocked on the 2nd series purchase. + new BuildDeckSeriesRewardEntry + { + TierIndex = 2, ItemIndex = 0, RewardType = 5, + RewardDetailId = 10001002L, RewardNumber = 1, MessageId = 51004, + }, + }, + Products = + { + new BuildDeckProductEntry + { + Id = 501, SeriesId = 105, LeaderId = 1, DeckCode = "pd0501", + PurchaseNumMax = 3, IntroPriceCrystal = 0, RegularPriceCrystal = 0, + IntroPriceRupy = 0, RegularPriceRupy = 0, IsEnabled = true, + Cards = { new BuildDeckProductCardEntry { CardId = 10001003L, Number = 1, IsSpot = false } }, + }, + new BuildDeckProductEntry + { + Id = 502, SeriesId = 105, LeaderId = 2, DeckCode = "pd0502", + PurchaseNumMax = 3, IntroPriceCrystal = 0, RegularPriceCrystal = 0, + IntroPriceRupy = 0, RegularPriceRupy = 0, IsEnabled = true, + Cards = { new BuildDeckProductCardEntry { CardId = 10001003L, Number = 1, IsSpot = false } }, + }, + }, + }); + await db.SaveChangesAsync(); + } + + using var client = factory.CreateAuthenticatedClient(viewerId); + + // 1st series purchase (product 501) should emit tier 1 only. + var r1 = await client.PostAsync("/build_deck/buy", + JsonBody("""{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","product_id":501,"sales_type":0}""")); + Assert.That(r1.StatusCode, Is.EqualTo(HttpStatusCode.OK), await r1.Content.ReadAsStringAsync()); + + using (var doc = JsonDocument.Parse(await r1.Content.ReadAsStringAsync())) + { + var tiers = doc.RootElement.GetProperty("series_rewards"); + Assert.That(tiers.GetArrayLength(), Is.EqualTo(1), "only tier 1 newly crossed"); + Assert.That(tiers[0].GetProperty("reward_detail_id").GetInt64(), Is.EqualTo(10001001L)); + } + + // 2nd series purchase (product 502) should emit tier 2 only. + var r2 = await client.PostAsync("/build_deck/buy", + JsonBody("""{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","product_id":502,"sales_type":0}""")); + using (var doc = JsonDocument.Parse(await r2.Content.ReadAsStringAsync())) + { + var tiers = doc.RootElement.GetProperty("series_rewards"); + Assert.That(tiers.GetArrayLength(), Is.EqualTo(1)); + Assert.That(tiers[0].GetProperty("reward_detail_id").GetInt64(), Is.EqualTo(10001002L)); + } + } +} diff --git a/SVSim.UnitTests/Controllers/BuildDeckControllerGetPurchaseCountTests.cs b/SVSim.UnitTests/Controllers/BuildDeckControllerGetPurchaseCountTests.cs new file mode 100644 index 0000000..948719e --- /dev/null +++ b/SVSim.UnitTests/Controllers/BuildDeckControllerGetPurchaseCountTests.cs @@ -0,0 +1,61 @@ +using System.Net; +using System.Text; +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using SVSim.Database; +using SVSim.Database.Models; +using SVSim.UnitTests.Infrastructure; + +namespace SVSim.UnitTests.Controllers; + +public class BuildDeckControllerGetPurchaseCountTests +{ + private static StringContent JsonBody(string json) => new(json, Encoding.UTF8, "application/json"); + + private static async Task SeedEnabledProduct(SVSimTestFactory f, int productId, int max) + { + using var scope = f.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + db.BuildDeckSeries.Add(new BuildDeckSeriesEntry + { + Id = 101, OrderIndex = 22, IsEnabled = true, NameKey = "BDSSN_test", IntroKey = "BDSI_test", + }); + db.BuildDeckProducts.Add(new BuildDeckProductEntry + { + Id = productId, SeriesId = 101, IsEnabled = true, + PurchaseNumMax = max, IntroPriceCrystal = 500, + }); + await db.SaveChangesAsync(); + } + + [Test] + public async Task Returns_zero_current_and_max_for_unbought_product() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(); + await SeedEnabledProduct(factory, productId: 201, max: 3); + + using var client = factory.CreateAuthenticatedClient(viewerId); + var json = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","product_id":201}"""; + var response = await client.PostAsync("/build_deck/get_purchase_count", JsonBody(json)); + + var body = await response.Content.ReadAsStringAsync(); + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), body); + + using var doc = JsonDocument.Parse(body); + Assert.That(doc.RootElement.GetProperty("purchase_num_current").GetInt32(), Is.EqualTo(0)); + Assert.That(doc.RootElement.GetProperty("purchase_num_max").GetInt32(), Is.EqualTo(3)); + } + + [Test] + public async Task Returns_NotFound_for_unknown_product() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(); + using var client = factory.CreateAuthenticatedClient(viewerId); + var json = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","product_id":99999}"""; + var response = await client.PostAsync("/build_deck/get_purchase_count", JsonBody(json)); + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.NotFound)); + } +} diff --git a/SVSim.UnitTests/Controllers/BuildDeckControllerInfoTests.cs b/SVSim.UnitTests/Controllers/BuildDeckControllerInfoTests.cs new file mode 100644 index 0000000..c025744 --- /dev/null +++ b/SVSim.UnitTests/Controllers/BuildDeckControllerInfoTests.cs @@ -0,0 +1,144 @@ +using System.Net; +using System.Text; +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using SVSim.Database; +using SVSim.Database.Models; +using SVSim.UnitTests.Infrastructure; + +namespace SVSim.UnitTests.Controllers; + +public class BuildDeckControllerInfoTests +{ + private static StringContent JsonBody(string json) => new(json, Encoding.UTF8, "application/json"); + + private static async Task SeedTwoSeries(SVSimTestFactory f) + { + using var scope = f.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var seriesA = new BuildDeckSeriesEntry + { + Id = 101, OrderIndex = 22, IsEnabled = true, IsNew = false, + NameKey = "BDSSN_A", IntroKey = "BDSI_A", + Products = + { + new BuildDeckProductEntry + { + Id = 1, SeriesId = 101, LeaderId = 1, DeckCode = "pd0101", + ProductNameKey = "BDPN_A_elf", FeaturedCardId = 100, + PurchaseNumMax = 3, IntroPriceCrystal = 500, RegularPriceCrystal = 750, + IsEnabled = true, + }, + }, + }; + var seriesB = new BuildDeckSeriesEntry + { + Id = 107, OrderIndex = 15, IsEnabled = true, IsNew = false, + NameKey = "BDSSN_B", IntroKey = "BDSI_B", + Products = + { + new BuildDeckProductEntry + { + Id = 701, SeriesId = 107, LeaderId = 1, DeckCode = "pd0107", + ProductNameKey = "BDPN_B_elf", FeaturedCardId = 200, + PurchaseNumMax = 1, IntroPriceCrystal = 1200, + IsEnabled = true, + }, + }, + }; + var disabled = new BuildDeckSeriesEntry + { + Id = 10100, OrderIndex = 999, IsEnabled = false, NameKey = "BDSSN_TEMP", IntroKey = "BDSI_TEMP", + }; + db.BuildDeckSeries.AddRange(seriesA, seriesB, disabled); + await db.SaveChangesAsync(); + } + + [Test] + public async Task Returns_only_enabled_series_sorted_by_order_index_desc() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(); + await SeedTwoSeries(factory); + + using var client = factory.CreateAuthenticatedClient(viewerId); + var json = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","add_series_id":0}"""; + var response = await client.PostAsync("/build_deck/info", JsonBody(json)); + + var body = await response.Content.ReadAsStringAsync(); + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), body); + + using var doc = JsonDocument.Parse(body); + var list = doc.RootElement; // controller returns a bare array — `data` IS the series list + Assert.That(list.GetArrayLength(), Is.EqualTo(2)); + Assert.That(list[0].GetProperty("series_id").GetInt32(), Is.EqualTo(101), "OrderIndex 22 sorts first"); + Assert.That(list[1].GetProperty("series_id").GetInt32(), Is.EqualTo(107)); + } + + [Test] + public async Task Filters_to_single_series_when_add_series_id_nonzero() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(); + await SeedTwoSeries(factory); + + using var client = factory.CreateAuthenticatedClient(viewerId); + var json = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","add_series_id":107}"""; + var response = await client.PostAsync("/build_deck/info", JsonBody(json)); + + var body = await response.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(body); + var list = doc.RootElement; + Assert.That(list.GetArrayLength(), Is.EqualTo(1)); + Assert.That(list[0].GetProperty("series_id").GetInt32(), Is.EqualTo(107)); + } + + [Test] + public async Task Emits_intro_price_and_is_first_price_true_for_unbought_max3_product() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(); + await SeedTwoSeries(factory); + + using var client = factory.CreateAuthenticatedClient(viewerId); + var json = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","add_series_id":101}"""; + var response = await client.PostAsync("/build_deck/info", JsonBody(json)); + + var body = await response.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(body); + var product = doc.RootElement[0].GetProperty("products")[0]; + Assert.That(product.GetProperty("is_first_price").GetBoolean(), Is.True); + Assert.That(product.GetProperty("price_crystal").GetInt32(), Is.EqualTo(500)); + Assert.That(product.GetProperty("purchase_num_current").GetInt32(), Is.EqualTo(0)); + } + + [Test] + public async Task Emits_regular_price_after_first_purchase_recorded() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(); + await SeedTwoSeries(factory); + + // Record a purchase directly to simulate post-buy state. + using (var scope = factory.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + var v = await db.Viewers.Include(x => x.BuildDeckPurchases).FirstAsync(x => x.Id == viewerId); + v.BuildDeckPurchases.Add(new ViewerBuildDeckProductPurchase { ProductId = 1, PurchaseCount = 1 }); + await db.SaveChangesAsync(); + } + + using var client = factory.CreateAuthenticatedClient(viewerId); + var json = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","add_series_id":101}"""; + var response = await client.PostAsync("/build_deck/info", JsonBody(json)); + + var body = await response.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(body); + var product = doc.RootElement[0].GetProperty("products")[0]; + Assert.That(product.GetProperty("is_first_price").GetBoolean(), Is.False); + Assert.That(product.GetProperty("price_crystal").GetInt32(), Is.EqualTo(750)); + Assert.That(product.GetProperty("purchase_num_current").GetInt32(), Is.EqualTo(1)); + } +} diff --git a/SVSim.UnitTests/Controllers/LeaderSkinControllerTests.cs b/SVSim.UnitTests/Controllers/LeaderSkinControllerTests.cs new file mode 100644 index 0000000..4dd7aff --- /dev/null +++ b/SVSim.UnitTests/Controllers/LeaderSkinControllerTests.cs @@ -0,0 +1,126 @@ +using System.Net; +using System.Text; +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using SVSim.Database; +using SVSim.Database.Models; +using SVSim.UnitTests.Infrastructure; + +namespace SVSim.UnitTests.Controllers; + +public class LeaderSkinControllerTests +{ + private static StringContent JsonBody(string json) => new(json, Encoding.UTF8, "application/json"); + + /// Adds a class-4 leader skin (id 104, "Forte") to the catalog and to the viewer's owned list. + private static async Task SeedOwnedClass4Skin(SVSimTestFactory f, long viewerId, int skinId = 104) + { + using var scope = f.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var skin = await db.LeaderSkins.FindAsync(skinId); + if (skin is null) + { + skin = new LeaderSkinEntry { Id = skinId, Name = "Forte", ClassId = 4 }; + db.LeaderSkins.Add(skin); + await db.SaveChangesAsync(); + } + var viewer = await db.Viewers.Include(v => v.LeaderSkins).FirstAsync(v => v.Id == viewerId); + if (viewer.LeaderSkins.All(s => s.Id != skinId)) viewer.LeaderSkins.Add(skin); + await db.SaveChangesAsync(); + } + + [Test] + public async Task Set_updates_viewer_class_leader_skin() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(); + await SeedOwnedClass4Skin(factory, viewerId, skinId: 104); + + using var client = factory.CreateAuthenticatedClient(viewerId); + var json = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","class_id":4,"leader_skin_id":104,"is_random_leader_skin":false,"leader_skin_id_list":[]}"""; + var response = await client.PostAsync("/leader_skin/set", JsonBody(json)); + + var body = await response.Content.ReadAsStringAsync(); + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), body); + + using var scope = factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var viewer = await db.Viewers + .Include(v => v.Classes).ThenInclude(c => c.LeaderSkin) + .Include(v => v.Classes).ThenInclude(c => c.Class) + .FirstAsync(v => v.Id == viewerId); + var class4 = viewer.Classes.Single(c => c.Class.Id == 4); + Assert.That(class4.LeaderSkin.Id, Is.EqualTo(104)); + } + + [Test] + public async Task Set_is_reflected_in_subsequent_deck_info_response() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(); + await SeedOwnedClass4Skin(factory, viewerId, skinId: 104); + using var client = factory.CreateAuthenticatedClient(viewerId); + + // Switch class 4 leader to skin 104 + await client.PostAsync("/leader_skin/set", + JsonBody("""{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","class_id":4,"leader_skin_id":104,"is_random_leader_skin":false,"leader_skin_id_list":[]}""")); + + // /deck/info should now report class 4 with leader_skin_id=104 + var resp = await client.PostAsync("/deck/info", + JsonBody("""{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","deck_format":0}""")); + var body = await resp.Content.ReadAsStringAsync(); + Assert.That(resp.StatusCode, Is.EqualTo(HttpStatusCode.OK), body); + using var doc = JsonDocument.Parse(body); + var settings = doc.RootElement.GetProperty("user_leader_skin_setting_list"); + Assert.That(settings.TryGetProperty("4", out var class4Setting), Is.True, "class 4 entry must be present"); + Assert.That(class4Setting.GetProperty("leader_skin_id").GetInt32(), Is.EqualTo(104)); + } + + [Test] + public async Task Set_rejects_skin_viewer_doesnt_own() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(); + // Skin 104 (Forte) is in the seeded leaderskins.csv catalog but a fresh viewer only owns + // the 8 class default skins — confirm 104 isn't in viewer.LeaderSkins, then call /set. + using (var scope = factory.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + var skin = await db.LeaderSkins.FindAsync(104); + Assert.That(skin, Is.Not.Null, "leaderskins.csv fixture should include skin 104"); + var viewer = await db.Viewers.Include(v => v.LeaderSkins).FirstAsync(v => v.Id == viewerId); + Assert.That(viewer.LeaderSkins.Any(s => s.Id == 104), Is.False, "fresh viewer must not own skin 104"); + } + + using var client = factory.CreateAuthenticatedClient(viewerId); + var json = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","class_id":4,"leader_skin_id":104,"is_random_leader_skin":false,"leader_skin_id_list":[]}"""; + var resp = await client.PostAsync("/leader_skin/set", JsonBody(json)); + Assert.That(resp.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest), await resp.Content.ReadAsStringAsync()); + } + + [Test] + public async Task Set_rejects_skin_for_wrong_class() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(); + // Skin 104 is class 4 — try to assign it to class 6 + await SeedOwnedClass4Skin(factory, viewerId, skinId: 104); + + using var client = factory.CreateAuthenticatedClient(viewerId); + var json = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","class_id":6,"leader_skin_id":104,"is_random_leader_skin":false,"leader_skin_id_list":[]}"""; + var resp = await client.PostAsync("/leader_skin/set", JsonBody(json)); + Assert.That(resp.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest)); + } + + [Test] + public async Task Set_returns_501_for_random_leader_skin_mode() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(); + using var client = factory.CreateAuthenticatedClient(viewerId); + var json = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","class_id":4,"leader_skin_id":0,"is_random_leader_skin":true,"leader_skin_id_list":[4,104]}"""; + var resp = await client.PostAsync("/leader_skin/set", JsonBody(json)); + Assert.That((int)resp.StatusCode, Is.EqualTo(501)); + } +} diff --git a/SVSim.UnitTests/Importers/BuildDeckImporterTests.cs b/SVSim.UnitTests/Importers/BuildDeckImporterTests.cs new file mode 100644 index 0000000..30f9bd8 --- /dev/null +++ b/SVSim.UnitTests/Importers/BuildDeckImporterTests.cs @@ -0,0 +1,110 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using SVSim.Bootstrap.Importers; +using SVSim.Database; +using SVSim.UnitTests.Infrastructure; + +namespace SVSim.UnitTests.Importers; + +public class BuildDeckImporterTests +{ + private static string DataDir => Path.Combine(AppContext.BaseDirectory, "Data"); + + [Test] + public async Task ImportsAll22Series_with_22_disabled_until_catalog_enables() + { + using var factory = new SVSimTestFactory(); + using var scope = factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + await new BuildDeckImporter().ImportSeriesAsync(db, DataDir); + + var series = await db.BuildDeckSeries.OrderBy(s => s.Id).ToListAsync(); + Assert.That(series.Count, Is.EqualTo(22)); + Assert.That(series.All(s => !s.IsEnabled), Is.True, "all series disabled until catalog importer runs"); + Assert.That(series.Any(s => s.NameKey.StartsWith("BDSSN_")), Is.True); + } + + [Test] + public async Task ImportPackage_creates_stub_products_with_inferred_series_and_full_card_lists() + { + using var factory = new SVSimTestFactory(); + using var scope = factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + await new BuildDeckImporter().ImportSeriesAsync(db, DataDir); + await new BuildDeckImporter().ImportPackageAsync(db, DataDir); + + var products = await db.BuildDeckProducts.Include(p => p.Cards).ToListAsync(); + Assert.That(products.Count, Is.EqualTo(112), "stubs for all 112 products"); + Assert.That(products.All(p => !p.IsEnabled), Is.True, "stubs are disabled until catalog enables"); + Assert.That(products.All(p => p.Cards.Sum(c => c.Number) == 40), Is.True, "every product is a 40-card deck"); + + // Spot-check a known mapping: product 1 -> series 101 via the InferSeriesId helper. + var p1 = products.Single(p => p.Id == 1); + Assert.That(p1.SeriesId, Is.EqualTo(101)); + } + + [Test] + public async Task Importer_is_idempotent_on_rerun() + { + using var factory = new SVSimTestFactory(); + using var scope = factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var importer = new BuildDeckImporter(); + await importer.ImportSeriesAsync(db, DataDir); + await importer.ImportPackageAsync(db, DataDir); + await importer.ImportSeriesAsync(db, DataDir); + await importer.ImportPackageAsync(db, DataDir); + + Assert.That(await db.BuildDeckSeries.CountAsync(), Is.EqualTo(22)); + Assert.That(await db.BuildDeckProducts.CountAsync(), Is.EqualTo(112)); + } + + [Test] + public async Task ImportCatalog_enriches_7_captured_series_with_prices_and_tiers() + { + using var factory = new SVSimTestFactory(); + using var scope = factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var importer = new BuildDeckImporter(); + await importer.ImportSeriesAsync(db, DataDir); + await importer.ImportCatalogAsync(db, Path.Combine(DataDir, "prod-captures")); + await importer.ImportPackageAsync(db, DataDir); + + // Series 101 (Set 1) should be enabled and order_id=22 from capture + var s101 = await db.BuildDeckSeries + .Include(s => s.Products).ThenInclude(p => p.Cards) + .Include(s => s.Products).ThenInclude(p => p.Rewards) + .Include(s => s.SeriesRewards) + .FirstAsync(s => s.Id == 101); + Assert.That(s101.IsEnabled, Is.True); + Assert.That(s101.OrderIndex, Is.EqualTo(22)); + Assert.That(s101.Products.Count, Is.EqualTo(7), "Set 1 has 7 products (no Nemesis)"); + + // Set 1 products: max=3, intro=500 backfilled from siblings, regular=750 backfilled from siblings + var product1 = s101.Products.Single(p => p.Id == 1); + Assert.That(product1.IsEnabled, Is.True); + Assert.That(product1.PurchaseNumMax, Is.EqualTo(3)); + Assert.That(product1.IntroPriceCrystal, Is.EqualTo(500)); + Assert.That(product1.RegularPriceCrystal, Is.EqualTo(750)); + + // Series 107 (Set 7) products: max=1, intro=1200, regular=null + var s107 = await db.BuildDeckSeries + .Include(s => s.Products) + .FirstAsync(s => s.Id == 107); + Assert.That(s107.Products.All(p => p.PurchaseNumMax == 1), Is.True); + Assert.That(s107.Products.All(p => p.IntroPriceCrystal == 1200), Is.True); + Assert.That(s107.Products.All(p => p.RegularPriceCrystal == null), Is.True); + + // Series 105 should have populated series-reward tiers (from the capture) + var s105 = await db.BuildDeckSeries.Include(s => s.SeriesRewards).FirstAsync(s => s.Id == 105); + Assert.That(s105.SeriesRewards.Count, Is.GreaterThan(0), "Set 5 has series-reward tiers"); + + // Series 10100 (Temporary Deck) should still be disabled — not in capture + var sTemp = await db.BuildDeckSeries.FirstAsync(s => s.Id == 10100); + Assert.That(sTemp.IsEnabled, Is.False); + } +} diff --git a/SVSim.UnitTests/SVSim.UnitTests.csproj b/SVSim.UnitTests/SVSim.UnitTests.csproj index c1e621c..4b5cbc4 100644 --- a/SVSim.UnitTests/SVSim.UnitTests.csproj +++ b/SVSim.UnitTests/SVSim.UnitTests.csproj @@ -54,6 +54,17 @@ PreserveNewest + + + Data\build-deck\build_deck_package_master.csv + PreserveNewest + + + Data\build-deck\build_deck_series_master.csv + PreserveNewest +