diff --git a/SVSim.Bootstrap/Data/prod-captures/basic-puzzle-info-2026-05-23.json b/SVSim.Bootstrap/Data/prod-captures/basic-puzzle-info-2026-05-23.json new file mode 100644 index 0000000..30fb69f --- /dev/null +++ b/SVSim.Bootstrap/Data/prod-captures/basic-puzzle-info-2026-05-23.json @@ -0,0 +1 @@ +{"data_headers":{"sid":"41600731e6a1097b3c319a524bd73faf1779643058","short_udid":411054851,"viewer_id":906243102,"servertime":1779643058,"result_code":1},"data":[{"puzzle_master_id":"316","puzzle_data":[{"puzzle_id":"106","puzzle_difficulty":"2","is_cleared":false,"is_additional":false,"is_playable":true,"release_condition_text_id":""},{"puzzle_id":"107","puzzle_difficulty":"2","is_cleared":false,"is_additional":false,"is_playable":true,"release_condition_text_id":""},{"puzzle_id":"108","puzzle_difficulty":"2","is_cleared":false,"is_additional":false,"is_playable":true,"release_condition_text_id":""},{"puzzle_id":"109","puzzle_difficulty":"2","is_cleared":false,"is_additional":true,"is_playable":true,"release_condition_text_id":""},{"puzzle_id":"110","puzzle_difficulty":"2","is_cleared":false,"is_additional":true,"is_playable":true,"release_condition_text_id":""},{"puzzle_id":"111","puzzle_difficulty":"2","is_cleared":false,"is_additional":true,"is_playable":true,"release_condition_text_id":""},{"puzzle_id":"112","puzzle_difficulty":"2","is_cleared":false,"is_additional":true,"is_playable":true,"release_condition_text_id":""},{"puzzle_id":"113","puzzle_difficulty":"2","is_cleared":false,"is_additional":true,"is_playable":true,"release_condition_text_id":""}],"puzzle_chara_id":"3704","puzzle_difficulty_name_list":{"Expert":"2"},"is_all_cleared":false,"chara_id":"3704","sort_type":"1","basic_title_text_id":"Puzzle_QuestSelect_0316","is_mission_target":true},{"puzzle_master_id":"315","puzzle_data":[{"puzzle_id":"103","puzzle_difficulty":"0","is_cleared":false,"is_additional":false,"is_playable":true,"release_condition_text_id":""},{"puzzle_id":"104","puzzle_difficulty":"1","is_cleared":false,"is_additional":false,"is_playable":true,"release_condition_text_id":""},{"puzzle_id":"105","puzzle_difficulty":"2","is_cleared":false,"is_additional":false,"is_playable":true,"release_condition_text_id":""}],"puzzle_chara_id":"3704","puzzle_difficulty_name_list":{"Beginner":"0","Experienced":"1","Expert":"2"},"is_all_cleared":false,"chara_id":"3704","sort_type":"1","basic_title_text_id":"Puzzle_QuestSelect_0315","is_mission_target":true},{"puzzle_master_id":"314","puzzle_data":[{"puzzle_id":"100","puzzle_difficulty":"0","is_cleared":false,"is_additional":false,"is_playable":true,"release_condition_text_id":""},{"puzzle_id":"101","puzzle_difficulty":"1","is_cleared":false,"is_additional":false,"is_playable":true,"release_condition_text_id":""},{"puzzle_id":"102","puzzle_difficulty":"2","is_cleared":false,"is_additional":false,"is_playable":true,"release_condition_text_id":""}],"puzzle_chara_id":"3704","puzzle_difficulty_name_list":{"Beginner":"0","Experienced":"1","Expert":"2"},"is_all_cleared":false,"chara_id":"3704","sort_type":"1","basic_title_text_id":"Puzzle_QuestSelect_0314","is_mission_target":true},{"puzzle_master_id":"313","puzzle_data":[{"puzzle_id":"97","puzzle_difficulty":"0","is_cleared":false,"is_additional":false,"is_playable":true,"release_condition_text_id":""},{"puzzle_id":"98","puzzle_difficulty":"1","is_cleared":false,"is_additional":false,"is_playable":true,"release_condition_text_id":""},{"puzzle_id":"99","puzzle_difficulty":"2","is_cleared":false,"is_additional":false,"is_playable":true,"release_condition_text_id":""}],"puzzle_chara_id":"3704","puzzle_difficulty_name_list":{"Beginner":"0","Experienced":"1","Expert":"2"},"is_all_cleared":false,"chara_id":"3704","sort_type":"1","basic_title_text_id":"Puzzle_QuestSelect_0313","is_mission_target":true},{"puzzle_master_id":"312","puzzle_data":[{"puzzle_id":"94","puzzle_difficulty":"0","is_cleared":false,"is_additional":false,"is_playable":true,"release_condition_text_id":""},{"puzzle_id":"95","puzzle_difficulty":"1","is_cleared":false,"is_additional":false,"is_playable":true,"release_condition_text_id":""},{"puzzle_id":"96","puzzle_difficulty":"2","is_cleared":false,"is_additional":false,"is_playable":true,"release_condition_text_id":""}],"puzzle_chara_id":"3704","puzzle_difficulty_name_list":{"Beginner":"0","Experienced":"1","Expert":"2"},"is_all_cleared":false,"chara_id":"3704","sort_type":"1","basic_title_text_id":"Puzzle_QuestSelect_0312","is_mission_target":true},{"puzzle_master_id":"311","puzzle_data":[{"puzzle_id":"91","puzzle_difficulty":"0","is_cleared":false,"is_additional":false,"is_playable":true,"release_condition_text_id":""},{"puzzle_id":"92","puzzle_difficulty":"1","is_cleared":false,"is_additional":false,"is_playable":true,"release_condition_text_id":""},{"puzzle_id":"93","puzzle_difficulty":"2","is_cleared":false,"is_additional":false,"is_playable":true,"release_condition_text_id":""}],"puzzle_chara_id":"3704","puzzle_difficulty_name_list":{"Beginner":"0","Experienced":"1","Expert":"2"},"is_all_cleared":false,"chara_id":"3704","sort_type":"1","basic_title_text_id":"Puzzle_QuestSelect_0311","is_mission_target":true},{"puzzle_master_id":"310","puzzle_data":[{"puzzle_id":"84","puzzle_difficulty":"0","is_cleared":false,"is_additional":false,"is_playable":true,"release_condition_text_id":""},{"puzzle_id":"85","puzzle_difficulty":"1","is_cleared":false,"is_additional":false,"is_playable":true,"release_condition_text_id":""},{"puzzle_id":"86","puzzle_difficulty":"2","is_cleared":false,"is_additional":false,"is_playable":true,"release_condition_text_id":""}],"puzzle_chara_id":"3704","puzzle_difficulty_name_list":{"Beginner":"0","Experienced":"1","Expert":"2"},"is_all_cleared":false,"chara_id":"3704","sort_type":"1","basic_title_text_id":"Puzzle_QuestSelect_0310","is_mission_target":true},{"puzzle_master_id":"309","puzzle_data":[{"puzzle_id":"77","puzzle_difficulty":"0","is_cleared":false,"is_additional":false,"is_playable":true,"release_condition_text_id":""},{"puzzle_id":"78","puzzle_difficulty":"1","is_cleared":false,"is_additional":false,"is_playable":true,"release_condition_text_id":""},{"puzzle_id":"79","puzzle_difficulty":"2","is_cleared":false,"is_additional":false,"is_playable":true,"release_condition_text_id":""}],"puzzle_chara_id":"3704","puzzle_difficulty_name_list":{"Beginner":"0","Experienced":"1","Expert":"2"},"is_all_cleared":false,"chara_id":"3704","sort_type":"1","basic_title_text_id":"Puzzle_QuestSelect_0309","is_mission_target":true},{"puzzle_master_id":"308","puzzle_data":[{"puzzle_id":"74","puzzle_difficulty":"0","is_cleared":false,"is_additional":false,"is_playable":true,"release_condition_text_id":""},{"puzzle_id":"75","puzzle_difficulty":"1","is_cleared":false,"is_additional":false,"is_playable":true,"release_condition_text_id":""},{"puzzle_id":"76","puzzle_difficulty":"2","is_cleared":false,"is_additional":false,"is_playable":true,"release_condition_text_id":""}],"puzzle_chara_id":"3704","puzzle_difficulty_name_list":{"Beginner":"0","Experienced":"1","Expert":"2"},"is_all_cleared":false,"chara_id":"3704","sort_type":"1","basic_title_text_id":"Puzzle_QuestSelect_0308","is_mission_target":true},{"puzzle_master_id":"307","puzzle_data":[{"puzzle_id":"67","puzzle_difficulty":"0","is_cleared":false,"is_additional":false,"is_playable":true,"release_condition_text_id":""},{"puzzle_id":"68","puzzle_difficulty":"1","is_cleared":false,"is_additional":false,"is_playable":true,"release_condition_text_id":""},{"puzzle_id":"69","puzzle_difficulty":"2","is_cleared":false,"is_additional":false,"is_playable":true,"release_condition_text_id":""}],"puzzle_chara_id":"3704","puzzle_difficulty_name_list":{"Beginner":"0","Experienced":"1","Expert":"2"},"is_all_cleared":false,"chara_id":"3704","sort_type":"1","basic_title_text_id":"Puzzle_QuestSelect_0307","is_mission_target":true},{"puzzle_master_id":"306","puzzle_data":[{"puzzle_id":"64","puzzle_difficulty":"0","is_cleared":false,"is_additional":false,"is_playable":true,"release_condition_text_id":""},{"puzzle_id":"65","puzzle_difficulty":"1","is_cleared":false,"is_additional":false,"is_playable":true,"release_condition_text_id":""},{"puzzle_id":"66","puzzle_difficulty":"2","is_cleared":false,"is_additional":false,"is_playable":true,"release_condition_text_id":""}],"puzzle_chara_id":"3704","puzzle_difficulty_name_list":{"Beginner":"0","Experienced":"1","Expert":"2"},"is_all_cleared":false,"chara_id":"3704","sort_type":"1","basic_title_text_id":"Puzzle_QuestSelect_0306","is_mission_target":true},{"puzzle_master_id":"305","puzzle_data":[{"puzzle_id":"61","puzzle_difficulty":"0","is_cleared":false,"is_additional":false,"is_playable":true,"release_condition_text_id":""},{"puzzle_id":"62","puzzle_difficulty":"1","is_cleared":false,"is_additional":false,"is_playable":true,"release_condition_text_id":""},{"puzzle_id":"63","puzzle_difficulty":"2","is_cleared":false,"is_additional":false,"is_playable":true,"release_condition_text_id":""}],"puzzle_chara_id":"3704","puzzle_difficulty_name_list":{"Beginner":"0","Experienced":"1","Expert":"2"},"is_all_cleared":false,"chara_id":"3704","sort_type":"1","basic_title_text_id":"Puzzle_QuestSelect_0305","is_mission_target":true},{"puzzle_master_id":"304","puzzle_data":[{"puzzle_id":"46","puzzle_difficulty":"0","is_cleared":false,"is_additional":false,"is_playable":true,"release_condition_text_id":""},{"puzzle_id":"47","puzzle_difficulty":"1","is_cleared":false,"is_additional":false,"is_playable":true,"release_condition_text_id":""},{"puzzle_id":"48","puzzle_difficulty":"2","is_cleared":false,"is_additional":false,"is_playable":true,"release_condition_text_id":""}],"puzzle_chara_id":"3704","puzzle_difficulty_name_list":{"Beginner":"0","Experienced":"1","Expert":"2"},"is_all_cleared":false,"chara_id":"3704","sort_type":"1","basic_title_text_id":"Puzzle_QuestSelect_0304","is_mission_target":true},{"puzzle_master_id":"303","puzzle_data":[{"puzzle_id":"43","puzzle_difficulty":"0","is_cleared":false,"is_additional":false,"is_playable":true,"release_condition_text_id":""},{"puzzle_id":"44","puzzle_difficulty":"1","is_cleared":false,"is_additional":false,"is_playable":true,"release_condition_text_id":""},{"puzzle_id":"45","puzzle_difficulty":"2","is_cleared":false,"is_additional":false,"is_playable":true,"release_condition_text_id":""}],"puzzle_chara_id":"3704","puzzle_difficulty_name_list":{"Beginner":"0","Experienced":"1","Expert":"2"},"is_all_cleared":false,"chara_id":"3704","sort_type":"1","basic_title_text_id":"Puzzle_QuestSelect_0303","is_mission_target":true},{"puzzle_master_id":"302","puzzle_data":[{"puzzle_id":"40","puzzle_difficulty":"0","is_cleared":false,"is_additional":false,"is_playable":true,"release_condition_text_id":""},{"puzzle_id":"41","puzzle_difficulty":"1","is_cleared":false,"is_additional":false,"is_playable":true,"release_condition_text_id":""},{"puzzle_id":"42","puzzle_difficulty":"2","is_cleared":false,"is_additional":false,"is_playable":true,"release_condition_text_id":""}],"puzzle_chara_id":"3704","puzzle_difficulty_name_list":{"Beginner":"0","Experienced":"1","Expert":"2"},"is_all_cleared":false,"chara_id":"3704","sort_type":"1","basic_title_text_id":"Puzzle_QuestSelect_0302","is_mission_target":true},{"puzzle_master_id":"301","puzzle_data":[{"puzzle_id":"37","puzzle_difficulty":"0","is_cleared":false,"is_additional":false,"is_playable":true,"release_condition_text_id":""},{"puzzle_id":"38","puzzle_difficulty":"1","is_cleared":false,"is_additional":false,"is_playable":true,"release_condition_text_id":""},{"puzzle_id":"39","puzzle_difficulty":"2","is_cleared":false,"is_additional":false,"is_playable":true,"release_condition_text_id":""}],"puzzle_chara_id":"3704","puzzle_difficulty_name_list":{"Beginner":"0","Experienced":"1","Expert":"2"},"is_all_cleared":false,"chara_id":"3704","sort_type":"1","basic_title_text_id":"Puzzle_QuestSelect_0301","is_mission_target":true},{"puzzle_master_id":"9","puzzle_data":[{"puzzle_id":"87","puzzle_difficulty":"0","is_cleared":false,"is_additional":false,"is_playable":true,"release_condition_text_id":""},{"puzzle_id":"88","puzzle_difficulty":"1","is_cleared":false,"is_additional":false,"is_playable":true,"release_condition_text_id":""},{"puzzle_id":"89","puzzle_difficulty":"2","is_cleared":false,"is_additional":false,"is_playable":true,"release_condition_text_id":""},{"puzzle_id":"90","puzzle_difficulty":"3","is_cleared":false,"is_additional":false,"is_playable":true,"release_condition_text_id":""}],"puzzle_chara_id":"600090","puzzle_difficulty_name_list":{"Beginner":"0","Experienced":"1","Expert":"2","":"3"},"is_all_cleared":false,"chara_id":"600090","sort_type":"2","basic_title_text_id":"Puzzle_QuestSelect_0109","is_mission_target":false},{"puzzle_master_id":"8","puzzle_data":[{"puzzle_id":"80","puzzle_difficulty":"0","is_cleared":false,"is_additional":false,"is_playable":true,"release_condition_text_id":""},{"puzzle_id":"81","puzzle_difficulty":"1","is_cleared":false,"is_additional":false,"is_playable":true,"release_condition_text_id":""},{"puzzle_id":"82","puzzle_difficulty":"2","is_cleared":false,"is_additional":false,"is_playable":true,"release_condition_text_id":""},{"puzzle_id":"83","puzzle_difficulty":"3","is_cleared":false,"is_additional":false,"is_playable":true,"release_condition_text_id":""}],"puzzle_chara_id":"600080","puzzle_difficulty_name_list":{"Beginner":"0","Experienced":"1","Expert":"2","":"3"},"is_all_cleared":false,"chara_id":"600080","sort_type":"2","basic_title_text_id":"Puzzle_QuestSelect_0108","is_mission_target":false},{"puzzle_master_id":"7","puzzle_data":[{"puzzle_id":"70","puzzle_difficulty":"0","is_cleared":false,"is_additional":false,"is_playable":true,"release_condition_text_id":""},{"puzzle_id":"71","puzzle_difficulty":"1","is_cleared":false,"is_additional":false,"is_playable":true,"release_condition_text_id":""},{"puzzle_id":"72","puzzle_difficulty":"1","is_cleared":false,"is_additional":false,"is_playable":true,"release_condition_text_id":""},{"puzzle_id":"73","puzzle_difficulty":"2","is_cleared":false,"is_additional":false,"is_playable":true,"release_condition_text_id":""}],"puzzle_chara_id":"600070","puzzle_difficulty_name_list":{"Beginner":"0","Experienced":"1","Expert":"2"},"is_all_cleared":false,"chara_id":"600070","sort_type":"2","basic_title_text_id":"Puzzle_QuestSelect_0107","is_mission_target":false},{"puzzle_master_id":"6","puzzle_data":[{"puzzle_id":"52","puzzle_difficulty":"0","is_cleared":false,"is_additional":false,"is_playable":true,"release_condition_text_id":""},{"puzzle_id":"53","puzzle_difficulty":"0","is_cleared":false,"is_additional":false,"is_playable":true,"release_condition_text_id":""},{"puzzle_id":"54","puzzle_difficulty":"1","is_cleared":false,"is_additional":false,"is_playable":true,"release_condition_text_id":""},{"puzzle_id":"55","puzzle_difficulty":"1","is_cleared":false,"is_additional":false,"is_playable":true,"release_condition_text_id":""},{"puzzle_id":"56","puzzle_difficulty":"1","is_cleared":false,"is_additional":false,"is_playable":true,"release_condition_text_id":""},{"puzzle_id":"57","puzzle_difficulty":"2","is_cleared":false,"is_additional":false,"is_playable":true,"release_condition_text_id":""},{"puzzle_id":"58","puzzle_difficulty":"2","is_cleared":false,"is_additional":false,"is_playable":true,"release_condition_text_id":""},{"puzzle_id":"59","puzzle_difficulty":"3","is_cleared":false,"is_additional":false,"is_playable":true,"release_condition_text_id":""},{"puzzle_id":"60","puzzle_difficulty":"3","is_cleared":false,"is_additional":false,"is_playable":true,"release_condition_text_id":""}],"puzzle_chara_id":"600060","puzzle_difficulty_name_list":{"Beginner":"0","Experienced":"1","Expert":"2","":"3"},"is_all_cleared":false,"chara_id":"600060","sort_type":"2","basic_title_text_id":"Puzzle_QuestSelect_0106","is_mission_target":false},{"puzzle_master_id":"5","puzzle_data":[{"puzzle_id":"49","puzzle_difficulty":"0","is_cleared":false,"is_additional":false,"is_playable":true,"release_condition_text_id":""},{"puzzle_id":"50","puzzle_difficulty":"1","is_cleared":false,"is_additional":false,"is_playable":true,"release_condition_text_id":""},{"puzzle_id":"51","puzzle_difficulty":"2","is_cleared":false,"is_additional":false,"is_playable":true,"release_condition_text_id":""}],"puzzle_chara_id":"3801","puzzle_difficulty_name_list":{"Beginner":"0","Experienced":"1","Expert":"2"},"is_all_cleared":false,"chara_id":"3801","sort_type":"2","basic_title_text_id":"Puzzle_QuestSelect_0105","is_mission_target":false},{"puzzle_master_id":"4","puzzle_data":[{"puzzle_id":"28","puzzle_difficulty":"0","is_cleared":false,"is_additional":false,"is_playable":true,"release_condition_text_id":""},{"puzzle_id":"29","puzzle_difficulty":"0","is_cleared":false,"is_additional":false,"is_playable":true,"release_condition_text_id":""},{"puzzle_id":"30","puzzle_difficulty":"1","is_cleared":false,"is_additional":false,"is_playable":true,"release_condition_text_id":""},{"puzzle_id":"31","puzzle_difficulty":"1","is_cleared":false,"is_additional":false,"is_playable":true,"release_condition_text_id":""},{"puzzle_id":"32","puzzle_difficulty":"1","is_cleared":false,"is_additional":false,"is_playable":true,"release_condition_text_id":""},{"puzzle_id":"33","puzzle_difficulty":"2","is_cleared":false,"is_additional":false,"is_playable":true,"release_condition_text_id":""},{"puzzle_id":"34","puzzle_difficulty":"2","is_cleared":false,"is_additional":false,"is_playable":true,"release_condition_text_id":""},{"puzzle_id":"35","puzzle_difficulty":"2","is_cleared":false,"is_additional":false,"is_playable":true,"release_condition_text_id":""},{"puzzle_id":"36","puzzle_difficulty":"3","is_cleared":false,"is_additional":false,"is_playable":true,"release_condition_text_id":""}],"puzzle_chara_id":"3603","puzzle_difficulty_name_list":{"Beginner":"0","Experienced":"1","Expert":"2","":"3"},"is_all_cleared":false,"chara_id":"3603","sort_type":"2","basic_title_text_id":"Puzzle_QuestSelect_0104","is_mission_target":false},{"puzzle_master_id":"3","puzzle_data":[{"puzzle_id":"20","puzzle_difficulty":"0","is_cleared":false,"is_additional":false,"is_playable":true,"release_condition_text_id":""},{"puzzle_id":"21","puzzle_difficulty":"0","is_cleared":false,"is_additional":false,"is_playable":true,"release_condition_text_id":""},{"puzzle_id":"22","puzzle_difficulty":"1","is_cleared":false,"is_additional":false,"is_playable":true,"release_condition_text_id":""},{"puzzle_id":"23","puzzle_difficulty":"1","is_cleared":false,"is_additional":false,"is_playable":true,"release_condition_text_id":""},{"puzzle_id":"24","puzzle_difficulty":"1","is_cleared":false,"is_additional":false,"is_playable":true,"release_condition_text_id":""},{"puzzle_id":"25","puzzle_difficulty":"2","is_cleared":false,"is_additional":false,"is_playable":true,"release_condition_text_id":""},{"puzzle_id":"26","puzzle_difficulty":"2","is_cleared":false,"is_additional":false,"is_playable":true,"release_condition_text_id":""},{"puzzle_id":"27","puzzle_difficulty":"2","is_cleared":false,"is_additional":false,"is_playable":true,"release_condition_text_id":""}],"puzzle_chara_id":"3403","puzzle_difficulty_name_list":{"Beginner":"0","Experienced":"1","Expert":"2"},"is_all_cleared":false,"chara_id":"3403","sort_type":"2","basic_title_text_id":"Puzzle_QuestSelect_0103","is_mission_target":false},{"puzzle_master_id":"2","puzzle_data":[{"puzzle_id":"10","puzzle_difficulty":"0","is_cleared":false,"is_additional":false,"is_playable":true,"release_condition_text_id":""},{"puzzle_id":"11","puzzle_difficulty":"0","is_cleared":false,"is_additional":false,"is_playable":true,"release_condition_text_id":""},{"puzzle_id":"12","puzzle_difficulty":"1","is_cleared":false,"is_additional":false,"is_playable":true,"release_condition_text_id":""},{"puzzle_id":"13","puzzle_difficulty":"1","is_cleared":false,"is_additional":false,"is_playable":true,"release_condition_text_id":""},{"puzzle_id":"14","puzzle_difficulty":"1","is_cleared":false,"is_additional":false,"is_playable":true,"release_condition_text_id":""},{"puzzle_id":"15","puzzle_difficulty":"2","is_cleared":false,"is_additional":false,"is_playable":true,"release_condition_text_id":""},{"puzzle_id":"16","puzzle_difficulty":"2","is_cleared":false,"is_additional":false,"is_playable":true,"release_condition_text_id":""},{"puzzle_id":"17","puzzle_difficulty":"3","is_cleared":false,"is_additional":false,"is_playable":true,"release_condition_text_id":"Puzzle_Unlock_Condition_0001"},{"puzzle_id":"18","puzzle_difficulty":"3","is_cleared":false,"is_additional":false,"is_playable":true,"release_condition_text_id":"Puzzle_Unlock_Condition_0001"},{"puzzle_id":"19","puzzle_difficulty":"3","is_cleared":false,"is_additional":false,"is_playable":true,"release_condition_text_id":"Puzzle_Unlock_Condition_0001"}],"puzzle_chara_id":"3208","puzzle_difficulty_name_list":{"Beginner":"0","Experienced":"1","Expert":"2","":"3"},"is_all_cleared":false,"chara_id":"2703","sort_type":"2","basic_title_text_id":"Puzzle_QuestSelect_0102","is_mission_target":false},{"puzzle_master_id":"1","puzzle_data":[{"puzzle_id":"1","puzzle_difficulty":"0","is_cleared":false,"is_additional":false,"is_playable":true,"release_condition_text_id":""},{"puzzle_id":"2","puzzle_difficulty":"0","is_cleared":false,"is_additional":false,"is_playable":true,"release_condition_text_id":""},{"puzzle_id":"3","puzzle_difficulty":"0","is_cleared":false,"is_additional":false,"is_playable":true,"release_condition_text_id":""},{"puzzle_id":"4","puzzle_difficulty":"1","is_cleared":false,"is_additional":false,"is_playable":true,"release_condition_text_id":""},{"puzzle_id":"5","puzzle_difficulty":"1","is_cleared":false,"is_additional":false,"is_playable":true,"release_condition_text_id":""},{"puzzle_id":"6","puzzle_difficulty":"1","is_cleared":false,"is_additional":false,"is_playable":true,"release_condition_text_id":""},{"puzzle_id":"7","puzzle_difficulty":"2","is_cleared":false,"is_additional":false,"is_playable":true,"release_condition_text_id":""},{"puzzle_id":"8","puzzle_difficulty":"2","is_cleared":false,"is_additional":false,"is_playable":true,"release_condition_text_id":""},{"puzzle_id":"9","puzzle_difficulty":"2","is_cleared":false,"is_additional":false,"is_playable":true,"release_condition_text_id":""}],"puzzle_chara_id":"600050","puzzle_difficulty_name_list":{"Beginner":"0","Experienced":"1","Expert":"2"},"is_all_cleared":false,"chara_id":"600050","sort_type":"2","basic_title_text_id":"Puzzle_QuestSelect_0101","is_mission_target":false}]} diff --git a/SVSim.Bootstrap/Data/prod-captures/basic-puzzle-mission-2026-05-23.json b/SVSim.Bootstrap/Data/prod-captures/basic-puzzle-mission-2026-05-23.json new file mode 100644 index 0000000..0686c10 --- /dev/null +++ b/SVSim.Bootstrap/Data/prod-captures/basic-puzzle-mission-2026-05-23.json @@ -0,0 +1 @@ +{"data_headers":{"sid":"079f239bb83de281ebc6b2f68dbb2cd11779683743","short_udid":411054851,"viewer_id":906243102,"servertime":1779683743,"result_code":1},"data":[{"mission_name":"Clear all Dragoncraft and Portalcraft puzzles puzzles in the Special Round","require_number":"2","campaign_commence_time":1725670800,"reward_list":[{"reward_type":"4","reward_detail_id":"90001","reward_number":"1"}],"order_id":"5","total_count":"0","is_achieved":false},{"mission_name":"Clear all Forestcraft, Shadowcraft and Bloodcraft puzzles in the Special Round","require_number":"3","campaign_commence_time":1722646800,"reward_list":[{"reward_type":"4","reward_detail_id":"90001","reward_number":"1"}],"order_id":"4","total_count":"0","is_achieved":false},{"mission_name":"Clear all Swordcraft, Runecraft and Havencraft puzzles in the Special Round","require_number":"3","campaign_commence_time":1720227600,"reward_list":[{"reward_type":"4","reward_detail_id":"90001","reward_number":"1"}],"order_id":"3","total_count":"0","is_achieved":false},{"mission_name":"Clear all Special Round puzzles","require_number":"8","campaign_commence_time":1720227600,"reward_list":[{"reward_type":"7","reward_detail_id":"400004315","reward_number":"1"}],"order_id":"2","total_count":"0","is_achieved":false},{"mission_name":"Clear all Round 15 puzzles","require_number":"3","campaign_commence_time":1716598800,"reward_list":[{"reward_type":"7","reward_detail_id":"400004314","reward_number":"1"}],"order_id":"1","total_count":"0","is_achieved":false},{"mission_name":"Clear all Round 14 puzzles","require_number":"3","campaign_commence_time":1711760400,"reward_list":[{"reward_type":"6","reward_detail_id":"3065004","reward_number":"1"}],"order_id":"1","total_count":"0","is_achieved":false},{"mission_name":"Clear all Round 13 puzzles","require_number":"3","campaign_commence_time":1708736400,"reward_list":[{"reward_type":"7","reward_detail_id":"400004313","reward_number":"1"}],"order_id":"1","total_count":"0","is_achieved":false},{"mission_name":"Clear all Round 12 puzzles","require_number":"3","campaign_commence_time":1703898000,"reward_list":[{"reward_type":"6","reward_detail_id":"3074009","reward_number":"1"}],"order_id":"1","total_count":"0","is_achieved":false},{"mission_name":"Clear all Round 11 puzzles","require_number":"3","campaign_commence_time":1700269200,"reward_list":[{"reward_type":"6","reward_detail_id":"3074008","reward_number":"1"}],"order_id":"1","total_count":"0","is_achieved":false},{"mission_name":"Clear all Round 10 puzzles","require_number":"3","campaign_commence_time":1692406800,"reward_list":[{"reward_type":"6","reward_detail_id":"3074007","reward_number":"1"}],"order_id":"1","total_count":"0","is_achieved":false},{"mission_name":"Clear all Round 9 puzzles","require_number":"3","campaign_commence_time":1688173200,"reward_list":[{"reward_type":"6","reward_detail_id":"3074006","reward_number":"1"}],"order_id":"1","total_count":"0","is_achieved":false},{"mission_name":"Clear all Round 8 puzzles","require_number":"3","campaign_commence_time":1684544400,"reward_list":[{"reward_type":"6","reward_detail_id":"3074005","reward_number":"1"}],"order_id":"1","total_count":"0","is_achieved":false},{"mission_name":"Clear all Round 7 puzzles","require_number":"3","campaign_commence_time":1677286800,"reward_list":[{"reward_type":"6","reward_detail_id":"3074004","reward_number":"1"}],"order_id":"1","total_count":"0","is_achieved":false},{"mission_name":"Clear all Round 6 puzzles","require_number":"3","campaign_commence_time":1672448400,"reward_list":[{"reward_type":"6","reward_detail_id":"3074003","reward_number":"1"}],"order_id":"1","total_count":"0","is_achieved":false},{"mission_name":"Clear all Round 5 puzzles","require_number":"3","campaign_commence_time":1669424400,"reward_list":[{"reward_type":"6","reward_detail_id":"3074002","reward_number":"1"}],"order_id":"1","total_count":"0","is_achieved":false},{"mission_name":"Clear all Round 4 puzzles","require_number":"3","campaign_commence_time":1660959000,"reward_list":[{"reward_type":"6","reward_detail_id":"3074001","reward_number":"1"}],"order_id":"1","total_count":"0","is_achieved":false},{"mission_name":"Clear all Round 3 puzzles","require_number":"3","campaign_commence_time":1656725400,"reward_list":[{"reward_type":"7","reward_detail_id":"400004105","reward_number":"1"}],"order_id":"1","total_count":"0","is_achieved":false},{"mission_name":"Clear all Round 2 puzzles","require_number":"3","campaign_commence_time":1653096600,"reward_list":[{"reward_type":"7","reward_detail_id":"400004104","reward_number":"1"}],"order_id":"1","total_count":"0","is_achieved":false},{"mission_name":"Clear all Round 1 puzzles","require_number":"3","campaign_commence_time":1651282200,"reward_list":[{"reward_type":"10","reward_detail_id":"3704","reward_number":"1"}],"order_id":"1","total_count":"3","is_achieved":true}]} diff --git a/SVSim.Bootstrap/Importers/GlobalsImporter.cs b/SVSim.Bootstrap/Importers/GlobalsImporter.cs index 694d347..161f8e1 100644 --- a/SVSim.Bootstrap/Importers/GlobalsImporter.cs +++ b/SVSim.Bootstrap/Importers/GlobalsImporter.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using System.Text.RegularExpressions; using Microsoft.EntityFrameworkCore; using SVSim.Database; using SVSim.Database.Enums; @@ -32,6 +33,8 @@ public class GlobalsImporter JsonElement? paymentItemList = LoadCapture(capturesDir, "payment-item-list"); JsonElement? practiceInfo = LoadCapture(capturesDir, "practice-info"); JsonElement? packInfo = LoadCapture(capturesDir, "pack-info"); + JsonElement? basicPuzzleInfo = LoadCapture(capturesDir, "basic-puzzle-info"); + JsonElement? basicPuzzleMission = LoadCapture(capturesDir, "basic-puzzle-mission"); int total = 0; @@ -83,6 +86,17 @@ public class GlobalsImporter total += await ImportPacks(context, packInfo.Value); } + if (basicPuzzleInfo.HasValue) + { + total += await ImportPuzzleGroups(context, basicPuzzleInfo.Value); + total += await ImportPuzzles(context, basicPuzzleInfo.Value); + } + + if (basicPuzzleMission.HasValue) + { + total += await ImportPuzzleMissions(context, basicPuzzleMission.Value); + } + await context.SaveChangesAsync(); Console.WriteLine($"[GlobalsImporter] Done: {total} total rows changed."); return total; @@ -929,6 +943,140 @@ public class GlobalsImporter return created + updated; } + // ---------- Basic Puzzle Groups + Puzzles ---------- + + /// + /// /basic_puzzle/info capture is an array of group objects keyed on puzzle_master_id. + /// Numeric wire fields come through as strings — GetInt tolerates both. Idempotent upsert + /// by puzzle_master_id; rows missing from a partial capture are left intact. + /// + private async Task ImportPuzzleGroups(SVSimDbContext context, JsonElement infoData) + { + if (infoData.ValueKind != JsonValueKind.Array) return 0; + + var existing = await context.PuzzleGroups.ToDictionaryAsync(e => e.Id); + int created = 0, updated = 0; + + foreach (var row in infoData.EnumerateArray()) + { + int masterId = GetInt(row, "puzzle_master_id"); + if (masterId == 0) continue; + + var entry = existing.TryGetValue(masterId, out var ex) ? ex : new PuzzleGroupEntry { Id = masterId }; + entry.BasicTitleTextId = GetString(row, "basic_title_text_id"); + entry.PuzzleCharaId = GetInt(row, "puzzle_chara_id"); + entry.CharaId = GetInt(row, "chara_id"); + entry.SortType = GetInt(row, "sort_type"); + entry.DifficultyNameListJson = row.TryGetProperty("puzzle_difficulty_name_list", out var d) + ? Serialize(d) + : "{}"; + + if (ex is null) { context.PuzzleGroups.Add(entry); created++; } + else updated++; + } + + Console.WriteLine($"[GlobalsImporter] PuzzleGroups: +{created}/~{updated}"); + return created + updated; + } + + /// + /// Walks each group's puzzle_data array and upserts PuzzleEntry rows keyed on puzzle_id. + /// Groups must have been imported first (FK PuzzleEntry.GroupId → PuzzleGroupEntry.Id). + /// + private async Task ImportPuzzles(SVSimDbContext context, JsonElement infoData) + { + if (infoData.ValueKind != JsonValueKind.Array) return 0; + + var existing = await context.Puzzles.ToDictionaryAsync(e => e.Id); + int created = 0, updated = 0; + + foreach (var group in infoData.EnumerateArray()) + { + int masterId = GetInt(group, "puzzle_master_id"); + if (masterId == 0 || !group.TryGetProperty("puzzle_data", out var puzzleArray)) continue; + if (puzzleArray.ValueKind != JsonValueKind.Array) continue; + + foreach (var p in puzzleArray.EnumerateArray()) + { + int puzzleId = GetInt(p, "puzzle_id"); + if (puzzleId == 0) continue; + + var entry = existing.TryGetValue(puzzleId, out var ex) ? ex : new PuzzleEntry { Id = puzzleId }; + entry.GroupId = masterId; + entry.PuzzleDifficulty = GetInt(p, "puzzle_difficulty"); + entry.IsAdditional = GetBool(p, "is_additional"); + entry.IsPlayable = GetBool(p, "is_playable"); + entry.ReleaseConditionTextId = GetString(p, "release_condition_text_id"); + + if (ex is null) { context.Puzzles.Add(entry); created++; } + else updated++; + } + } + + Console.WriteLine($"[GlobalsImporter] Puzzles: +{created}/~{updated}"); + return created + updated; + } + + // ---------- Basic Puzzle Missions ---------- + + private static readonly Regex RoundMissionPattern = + new(@"^Clear all Round (\d+) puzzles$", RegexOptions.Compiled); + + /// Maps the captured mission_name to its target puzzle_master_id. Returns null for + /// Special-Round entries — Phase 1 surfaces them with total_count=0 (see design § Out of Scope). + internal static int? DeriveTargetPuzzleGroupId(string missionName) + { + var m = RoundMissionPattern.Match(missionName); + return m.Success ? 300 + int.Parse(m.Groups[1].Value) : null; + } + + private async Task ImportPuzzleMissions(SVSimDbContext context, JsonElement missionData) + { + if (missionData.ValueKind != JsonValueKind.Array) return 0; + + // Key by 1-based sequence (the wire has no stable id); first run inserts, re-runs match by index. + var existing = await context.PuzzleMissions.ToDictionaryAsync(e => e.Id); + int created = 0, updated = 0, unmapped = 0; + + int seq = 1; + foreach (var row in missionData.EnumerateArray()) + { + string name = GetString(row, "mission_name"); + if (string.IsNullOrEmpty(name)) { seq++; continue; } + + var entry = existing.TryGetValue(seq, out var ex) ? ex : new PuzzleMissionEntry { Id = seq }; + entry.MissionName = name; + entry.AchievedMessage = RoundMissionPattern.IsMatch(name) + ? RoundMissionPattern.Replace(name, m => $"Cleared all Round {m.Groups[1].Value} puzzles") + : "Mission achieved"; // Special-Round fallback; only surfaces if a Special mission ever flips, which won't in Phase 1. + entry.RequireNumber = GetInt(row, "require_number"); + entry.CampaignCommenceTime = GetLong(row, "campaign_commence_time"); + entry.OrderId = GetInt(row, "order_id"); + + // reward_list[0] — single reward per mission. Skip if missing/empty. + if (row.TryGetProperty("reward_list", out var rl) && rl.ValueKind == JsonValueKind.Array && rl.GetArrayLength() > 0) + { + var r = rl[0]; + entry.RewardType = GetInt(r, "reward_type"); + entry.RewardDetailId = GetLong(r, "reward_detail_id"); + entry.RewardNumber = GetInt(r, "reward_number"); + } + + entry.TargetPuzzleGroupId = DeriveTargetPuzzleGroupId(name); + if (entry.TargetPuzzleGroupId is null) unmapped++; + + if (ex is null) { context.PuzzleMissions.Add(entry); created++; } + else updated++; + + seq++; + } + + if (unmapped > 0) + Console.WriteLine($"[GlobalsImporter] PuzzleMissions: {unmapped} Special-Round missions left unmapped (Phase 1 deferral)."); + Console.WriteLine($"[GlobalsImporter] PuzzleMissions: +{created}/~{updated}"); + return created + updated; + } + // ---------- Helpers ---------- private static void WarnOrphans(string label, int count) diff --git a/SVSim.Database/Enums/UserGoodsType.cs b/SVSim.Database/Enums/UserGoodsType.cs new file mode 100644 index 0000000..84b99c5 --- /dev/null +++ b/SVSim.Database/Enums/UserGoodsType.cs @@ -0,0 +1,26 @@ +namespace SVSim.Database.Enums; + +/// +/// Mirrors the client's Wizard.UserGoods.Type enum (Shadowverse_Code/Wizard/UserGoods.cs). +/// These integers travel on the wire as reward_type on reward_list entries; the +/// client uses them in PlayerStaticData.UpdateHaveUserGoodsNumByJsonData to route the +/// grant into the right collection / currency total. +/// +public enum UserGoodsType +{ + RedEther = 1, + Crystal = 2, + // 3 is unused / placeholder in the client enum. + Item = 4, + Card = 5, + Sleeve = 6, + Emblem = 7, + Degree = 8, + Rupy = 9, + Skin = 10, // LeaderSkin in our schema + SpotCard = 11, + SpotCardPoint = 12, + SpotCardOnlyLatestCardPack = 13, + FreeGachaCount = 14, + MyPageBG = 15, +} diff --git a/SVSim.Database/Migrations/20260525055824_AddBasicPuzzle.Designer.cs b/SVSim.Database/Migrations/20260525055824_AddBasicPuzzle.Designer.cs new file mode 100644 index 0000000..ec656c4 --- /dev/null +++ b/SVSim.Database/Migrations/20260525055824_AddBasicPuzzle.Designer.cs @@ -0,0 +1,2164 @@ +// +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("20260525055824_AddBasicPuzzle")] + partial class AddBasicPuzzle + { + /// + 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.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.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("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.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.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.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.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("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.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/20260525055824_AddBasicPuzzle.cs b/SVSim.Database/Migrations/20260525055824_AddBasicPuzzle.cs new file mode 100644 index 0000000..9015aed --- /dev/null +++ b/SVSim.Database/Migrations/20260525055824_AddBasicPuzzle.cs @@ -0,0 +1,116 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace SVSim.Database.Migrations +{ + /// + public partial class AddBasicPuzzle : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "PuzzleGroups", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false), + PuzzleMasterId = table.Column(type: "integer", nullable: false), + BasicTitleTextId = table.Column(type: "text", nullable: false), + PuzzleCharaId = table.Column(type: "integer", nullable: false), + CharaId = table.Column(type: "integer", nullable: false), + SortType = table.Column(type: "integer", nullable: false), + DifficultyNameListJson = table.Column(type: "text", 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_PuzzleGroups", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "PuzzleMissions", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false), + MissionName = table.Column(type: "text", nullable: false), + AchievedMessage = table.Column(type: "text", nullable: false), + RequireNumber = table.Column(type: "integer", nullable: false), + CampaignCommenceTime = table.Column(type: "bigint", nullable: false), + OrderId = 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), + TargetPuzzleGroupId = table.Column(type: "integer", nullable: true), + 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_PuzzleMissions", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "ViewerPuzzleClears", + columns: table => new + { + ViewerId = table.Column(type: "bigint", nullable: false), + PuzzleId = table.Column(type: "integer", nullable: false), + ClearedAt = table.Column(type: "timestamp with time zone", nullable: false), + BestRetryCount = table.Column(type: "integer", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ViewerPuzzleClears", x => new { x.ViewerId, x.PuzzleId }); + }); + + migrationBuilder.CreateTable( + name: "Puzzles", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false), + PuzzleId = table.Column(type: "integer", nullable: false), + GroupId = table.Column(type: "integer", nullable: false), + PuzzleDifficulty = table.Column(type: "integer", nullable: false), + IsAdditional = table.Column(type: "boolean", nullable: false), + IsPlayable = table.Column(type: "boolean", nullable: false), + ReleaseConditionTextId = table.Column(type: "text", 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_Puzzles", x => x.Id); + table.ForeignKey( + name: "FK_Puzzles_PuzzleGroups_GroupId", + column: x => x.GroupId, + principalTable: "PuzzleGroups", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_Puzzles_GroupId", + table: "Puzzles", + column: "GroupId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "PuzzleMissions"); + + migrationBuilder.DropTable( + name: "Puzzles"); + + migrationBuilder.DropTable( + name: "ViewerPuzzleClears"); + + migrationBuilder.DropTable( + name: "PuzzleGroups"); + } + } +} diff --git a/SVSim.Database/Migrations/20260525143340_AddDeckMyRotationId.Designer.cs b/SVSim.Database/Migrations/20260525143340_AddDeckMyRotationId.Designer.cs new file mode 100644 index 0000000..3e2b438 --- /dev/null +++ b/SVSim.Database/Migrations/20260525143340_AddDeckMyRotationId.Designer.cs @@ -0,0 +1,2167 @@ +// +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("20260525143340_AddDeckMyRotationId")] + partial class AddDeckMyRotationId + { + /// + 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.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.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.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.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.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.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("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.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/20260525143340_AddDeckMyRotationId.cs b/SVSim.Database/Migrations/20260525143340_AddDeckMyRotationId.cs new file mode 100644 index 0000000..91bc74c --- /dev/null +++ b/SVSim.Database/Migrations/20260525143340_AddDeckMyRotationId.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace SVSim.Database.Migrations +{ + /// + public partial class AddDeckMyRotationId : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "MyRotationId", + table: "Decks", + type: "text", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "MyRotationId", + table: "Decks"); + } + } +} diff --git a/SVSim.Database/Migrations/SVSimDbContextModelSnapshot.cs b/SVSim.Database/Migrations/SVSimDbContextModelSnapshot.cs index 88d5e18..eb58c80 100644 --- a/SVSim.Database/Migrations/SVSimDbContextModelSnapshot.cs +++ b/SVSim.Database/Migrations/SVSimDbContextModelSnapshot.cs @@ -976,6 +976,124 @@ namespace SVSim.Database.Migrations 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") @@ -1200,6 +1318,9 @@ namespace SVSim.Database.Migrations b.Property("LeaderSkinId") .HasColumnType("integer"); + b.Property("MyRotationId") + .HasColumnType("text"); + b.Property("Name") .IsRequired() .HasColumnType("text"); @@ -1347,6 +1468,25 @@ namespace SVSim.Database.Migrations 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") @@ -1546,6 +1686,17 @@ namespace SVSim.Database.Migrations 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") @@ -1993,6 +2144,11 @@ namespace SVSim.Database.Migrations b.Navigation("LeaderSkins"); }); + modelBuilder.Entity("SVSim.Database.Models.PuzzleGroupEntry", b => + { + b.Navigation("Puzzles"); + }); + modelBuilder.Entity("SVSim.Database.Models.ShadowverseCardSetEntry", b => { b.Navigation("Cards"); diff --git a/SVSim.Database/Models/PuzzleEntry.cs b/SVSim.Database/Models/PuzzleEntry.cs new file mode 100644 index 0000000..ac80e66 --- /dev/null +++ b/SVSim.Database/Models/PuzzleEntry.cs @@ -0,0 +1,25 @@ +using SVSim.Database.Common; + +namespace SVSim.Database.Models; + +/// +/// One row per basic_puzzle within a group. Static catalog seeded by SVSim.Bootstrap. +/// See docs/api-spec/endpoints/post-login/basic-puzzle/info.md (PuzzleEntry). +/// +public class PuzzleEntry : BaseEntity +{ + /// puzzle_id on the wire. PK. + public int PuzzleId { get => Id; set => Id = value; } + + /// FK to . Index this column for mission evaluation. + public int GroupId { get; set; } + + public PuzzleGroupEntry Group { get; set; } = null!; + + /// 0..3 difficulty band. + public int PuzzleDifficulty { get; set; } + + public bool IsAdditional { get; set; } + public bool IsPlayable { get; set; } = true; + public string ReleaseConditionTextId { get; set; } = string.Empty; +} diff --git a/SVSim.Database/Models/PuzzleGroupEntry.cs b/SVSim.Database/Models/PuzzleGroupEntry.cs new file mode 100644 index 0000000..94824ba --- /dev/null +++ b/SVSim.Database/Models/PuzzleGroupEntry.cs @@ -0,0 +1,32 @@ +using SVSim.Database.Common; + +namespace SVSim.Database.Models; + +/// +/// One row per basic_puzzle group (puzzle_master_id). Static catalog seeded by +/// SVSim.Bootstrap.GlobalsImporter from prod-captures/basic-puzzle-info-*.json. +/// See docs/api-spec/endpoints/post-login/basic-puzzle/info.md. +/// +public class PuzzleGroupEntry : BaseEntity +{ + /// puzzle_master_id on the wire. PK + display order key. + public int PuzzleMasterId { get => Id; set => Id = value; } + + /// SystemText id. "Puzzle_QuestSelect_0301" etc. Client resolves with Data.SystemText.Get. + public string BasicTitleTextId { get; set; } = string.Empty; + + /// Character id for the group portrait. Wire as string but stored as int. + public int PuzzleCharaId { get; set; } + + /// Mission-attribution chara. Usually == PuzzleCharaId but observed group 2 has 3208/2703 split. + public int CharaId { get; set; } + + /// 1 = Special/Expert rounds, 2 = Regular numbered rounds. Drives client display ordering. + public int SortType { get; set; } + + /// Difficulty-name dict serialized as JSON (e.g. {"Beginner":"0","Experienced":"1","Expert":"2"}). + public string DifficultyNameListJson { get; set; } = "{}"; + + // Navigation + public List Puzzles { get; set; } = new(); +} diff --git a/SVSim.Database/Models/PuzzleMissionEntry.cs b/SVSim.Database/Models/PuzzleMissionEntry.cs new file mode 100644 index 0000000..b414b7e --- /dev/null +++ b/SVSim.Database/Models/PuzzleMissionEntry.cs @@ -0,0 +1,33 @@ +using SVSim.Database.Common; + +namespace SVSim.Database.Models; + +/// +/// One row per basic_puzzle mission (e.g. "Clear all Round 1 puzzles"). Static catalog +/// seeded by SVSim.Bootstrap from prod-captures/basic-puzzle-mission-*.json. The wire has no +/// stable id; importer assigns 1-based by capture order via the inherited . +/// See docs/api-spec/endpoints/post-login/basic-puzzle/mission.md. +/// +public class PuzzleMissionEntry : BaseEntity +{ + /// Pre-localized name on the wire. "Clear all Round 1 puzzles". + public string MissionName { get; set; } = string.Empty; + + /// Pre-localized achievement banner ("Cleared all Round 1 puzzles"). Derived by importer. + public string AchievedMessage { get; set; } = string.Empty; + + public int RequireNumber { get; set; } + public long CampaignCommenceTime { get; set; } + public int OrderId { get; set; } + + // Reward (single-entry per mission) + public int RewardType { get; set; } // UserGoodsType + public long RewardDetailId { get; set; } + public int RewardNumber { get; set; } + + /// + /// Maps Round-N missions to their target group (300+N). NULL for Special-Round missions + /// (deferred per Phase 1; they always surface as total_count=0). + /// + public int? TargetPuzzleGroupId { get; set; } +} diff --git a/SVSim.Database/Models/ShadowverseDeckEntry.cs b/SVSim.Database/Models/ShadowverseDeckEntry.cs index ee798a4..b40299a 100644 --- a/SVSim.Database/Models/ShadowverseDeckEntry.cs +++ b/SVSim.Database/Models/ShadowverseDeckEntry.cs @@ -21,6 +21,14 @@ public class ShadowverseDeckEntry : BaseEntity public Format Format { get; set; } public bool RandomLeaderSkin { get; set; } + /// + /// MyRotation period id (key into ). Required when + /// is so the client can resolve the + /// deck's pack range; null for every other format. If null on a MyRotation deck, clicking + /// the deck NREs inside DeckData.CreateMyRotationClassName (info.LastPackText on null). + /// + public string? MyRotationId { get; set; } + #region Navigation Properties public ClassEntry Class { get; set; } = new ClassEntry(); diff --git a/SVSim.Database/Models/ViewerPuzzleClear.cs b/SVSim.Database/Models/ViewerPuzzleClear.cs new file mode 100644 index 0000000..4d81333 --- /dev/null +++ b/SVSim.Database/Models/ViewerPuzzleClear.cs @@ -0,0 +1,22 @@ +using System.ComponentModel.DataAnnotations.Schema; +using Microsoft.EntityFrameworkCore; +using SVSim.Database.Common; + +namespace SVSim.Database.Models; + +/// +/// Per-viewer record of a cleared puzzle. Composite PK (ViewerId, PuzzleId) — at most one +/// row per (viewer, puzzle). NOT a Viewer owned collection on purpose (see CLAUDE.md +/// "EF nav include pitfall" — owned collection joins cartesian-explode the viewer graph). +/// +[PrimaryKey(nameof(ViewerId), nameof(PuzzleId))] +public class ViewerPuzzleClear +{ + public long ViewerId { get; set; } + public int PuzzleId { get; set; } + + public DateTime ClearedAt { get; set; } + + /// Min retry_count across all wins. RetryCount = in-battle reset count, not loss retries. + public int BestRetryCount { get; set; } +} diff --git a/SVSim.Database/Repositories/Globals/IPuzzleCatalogRepository.cs b/SVSim.Database/Repositories/Globals/IPuzzleCatalogRepository.cs new file mode 100644 index 0000000..e423e96 --- /dev/null +++ b/SVSim.Database/Repositories/Globals/IPuzzleCatalogRepository.cs @@ -0,0 +1,10 @@ +using SVSim.Database.Models; + +namespace SVSim.Database.Repositories.Globals; + +public interface IPuzzleCatalogRepository +{ + Task> GetAllGroupsWithPuzzles(); + Task GetGroupWithPuzzles(int puzzleMasterId); + Task> GetAllMissionsOrdered(); +} diff --git a/SVSim.Database/Repositories/Globals/PuzzleCatalogRepository.cs b/SVSim.Database/Repositories/Globals/PuzzleCatalogRepository.cs new file mode 100644 index 0000000..785d1f0 --- /dev/null +++ b/SVSim.Database/Repositories/Globals/PuzzleCatalogRepository.cs @@ -0,0 +1,31 @@ +using Microsoft.EntityFrameworkCore; +using SVSim.Database.Models; + +namespace SVSim.Database.Repositories.Globals; + +public class PuzzleCatalogRepository : IPuzzleCatalogRepository +{ + private readonly SVSimDbContext _db; + public PuzzleCatalogRepository(SVSimDbContext db) => _db = db; + + public Task> GetAllGroupsWithPuzzles() => + _db.PuzzleGroups + .Include(g => g.Puzzles) + .AsNoTracking() + .AsSplitQuery() // avoid the cartesian-explode pitfall (CLAUDE.md) + .OrderBy(g => g.Id) + .ToListAsync(); + + public Task GetGroupWithPuzzles(int puzzleMasterId) => + _db.PuzzleGroups + .Include(g => g.Puzzles) + .AsNoTracking() + .FirstOrDefaultAsync(g => g.Id == puzzleMasterId); + + public Task> GetAllMissionsOrdered() => + _db.PuzzleMissions + .AsNoTracking() + .OrderBy(m => m.OrderId) + .ThenByDescending(m => m.CampaignCommenceTime) + .ToListAsync(); +} diff --git a/SVSim.Database/Repositories/Viewer/IPuzzleClearRepository.cs b/SVSim.Database/Repositories/Viewer/IPuzzleClearRepository.cs new file mode 100644 index 0000000..ac2803e --- /dev/null +++ b/SVSim.Database/Repositories/Viewer/IPuzzleClearRepository.cs @@ -0,0 +1,15 @@ +namespace SVSim.Database.Repositories.Viewer; + +public interface IPuzzleClearRepository +{ + /// Returns the set of puzzle_ids this viewer has cleared. + Task> GetClearedPuzzleIds(long viewerId); + + /// Returns cleared puzzle_ids grouped by their PuzzleEntry.GroupId. Only groups + /// with at least one clear appear in the dictionary. + Task>> GetClearedPuzzleIdsByGroup(long viewerId); + + /// Inserts or updates the (viewer, puzzle) clear row. BestRetryCount keeps the + /// minimum retry_count across all wins. + Task UpsertClearAsync(long viewerId, int puzzleId, int retryCount); +} diff --git a/SVSim.Database/Repositories/Viewer/PuzzleClearRepository.cs b/SVSim.Database/Repositories/Viewer/PuzzleClearRepository.cs new file mode 100644 index 0000000..c4ba9be --- /dev/null +++ b/SVSim.Database/Repositories/Viewer/PuzzleClearRepository.cs @@ -0,0 +1,59 @@ +using Microsoft.EntityFrameworkCore; +using SVSim.Database.Models; + +namespace SVSim.Database.Repositories.Viewer; + +public class PuzzleClearRepository : IPuzzleClearRepository +{ + private readonly SVSimDbContext _db; + public PuzzleClearRepository(SVSimDbContext db) => _db = db; + + public async Task> GetClearedPuzzleIds(long viewerId) + { + var ids = await _db.ViewerPuzzleClears + .Where(c => c.ViewerId == viewerId) + .Select(c => c.PuzzleId) + .ToListAsync(); + return ids.ToHashSet(); + } + + public async Task>> GetClearedPuzzleIdsByGroup(long viewerId) + { + // Join via Puzzles to resolve each cleared PuzzleId to its GroupId. + var rows = await ( + from c in _db.ViewerPuzzleClears + where c.ViewerId == viewerId + join p in _db.Puzzles on c.PuzzleId equals p.Id + select new { p.GroupId, c.PuzzleId } + ).ToListAsync(); + + return rows + .GroupBy(r => r.GroupId) + .ToDictionary(g => g.Key, g => g.Select(r => r.PuzzleId).ToHashSet()); + } + + public async Task UpsertClearAsync(long viewerId, int puzzleId, int retryCount) + { + // CONCURRENCY: this read-then-write is not isolated. Two simultaneous /finish calls + // for the same (viewer, puzzle) could both insert and one will lose to the PK. The + // wider mission-completion concurrency note lives on PuzzleController.Finish. + var existing = await _db.ViewerPuzzleClears + .FirstOrDefaultAsync(c => c.ViewerId == viewerId && c.PuzzleId == puzzleId); + + if (existing is null) + { + _db.ViewerPuzzleClears.Add(new ViewerPuzzleClear + { + ViewerId = viewerId, + PuzzleId = puzzleId, + ClearedAt = DateTime.UtcNow, + BestRetryCount = retryCount, + }); + } + else + { + existing.BestRetryCount = Math.Min(existing.BestRetryCount, retryCount); + } + await _db.SaveChangesAsync(); + } +} diff --git a/SVSim.Database/SVSimDbContext.cs b/SVSim.Database/SVSimDbContext.cs index ac4ed83..34c71cf 100644 --- a/SVSim.Database/SVSimDbContext.cs +++ b/SVSim.Database/SVSimDbContext.cs @@ -62,6 +62,10 @@ public class SVSimDbContext : DbContext public DbSet FeatureMaintenances => Set(); public DbSet PreReleaseInfos => Set(); public DbSet PracticeOpponents => Set(); + public DbSet PuzzleGroups => Set(); + public DbSet Puzzles => Set(); + public DbSet PuzzleMissions => Set(); + public DbSet ViewerPuzzleClears => Set(); #endregion diff --git a/SVSim.Database/Services/RewardGrantService.cs b/SVSim.Database/Services/RewardGrantService.cs new file mode 100644 index 0000000..ff8527c --- /dev/null +++ b/SVSim.Database/Services/RewardGrantService.cs @@ -0,0 +1,118 @@ +using Microsoft.EntityFrameworkCore; +using SVSim.Database.Enums; +using SVSim.Database.Models; + +namespace SVSim.Database.Services; + +/// +/// Wire-shape returned by . Field names match the +/// reward_list entries used by /pack/open and /basic_puzzle/finish. +/// reward_num is a POST-STATE TOTAL for currencies and a count for collection grants — see +/// ... see SVSim.EmulatedEntrypoint.Models.Dtos.RewardListEntry +/// for the on-the-wire DTO and the rationale. +/// +public sealed record GrantedReward(int RewardType, long RewardId, int RewardNum); + +/// +/// General reward-grant primitive. Switches on , mutates the +/// appropriate viewer collection or field, and returns the +/// wire-shape entry the caller should embed in its response's reward_list. +/// +/// Caller is responsible for SaveChangesAsync — this service only mutates the in-memory +/// graph so a controller can stack several grants in a single transaction. +/// +public sealed class RewardGrantService +{ + private readonly SVSimDbContext _db; + public RewardGrantService(SVSimDbContext db) => _db = db; + + public GrantedReward Apply(Viewer viewer, UserGoodsType type, long detailId, int num) + { + switch (type) + { + case UserGoodsType.Sleeve: + AddCosmeticIfMissing(viewer.Sleeves, detailId, _db.Sleeves); + return new GrantedReward((int)type, detailId, 1); + + case UserGoodsType.Emblem: + AddCosmeticIfMissing(viewer.Emblems, detailId, _db.Emblems); + return new GrantedReward((int)type, detailId, 1); + + case UserGoodsType.Skin: // LeaderSkin in our schema + AddCosmeticIfMissing(viewer.LeaderSkins, detailId, _db.LeaderSkins); + return new GrantedReward((int)type, detailId, 1); + + case UserGoodsType.Degree: + AddCosmeticIfMissing(viewer.Degrees, detailId, _db.Degrees); + return new GrantedReward((int)type, detailId, 1); + + case UserGoodsType.MyPageBG: + AddCosmeticIfMissing(viewer.MyPageBackgrounds, detailId, _db.MyPageBackgrounds); + return new GrantedReward((int)type, detailId, 1); + + case UserGoodsType.Rupy: + viewer.Currency.Rupees += (ulong)num; + return new GrantedReward((int)type, detailId, checked((int)viewer.Currency.Rupees)); + + case UserGoodsType.Crystal: + viewer.Currency.Crystals += (ulong)num; + return new GrantedReward((int)type, detailId, checked((int)viewer.Currency.Crystals)); + + case UserGoodsType.RedEther: + viewer.Currency.RedEther += (ulong)num; + return new GrantedReward((int)type, detailId, checked((int)viewer.Currency.RedEther)); + + case UserGoodsType.Item: + { + var owned = viewer.Items.FirstOrDefault(i => i.Item.Id == (int)detailId); + if (owned is null) + { + var item = _db.Items.Find((int)detailId) + ?? throw new InvalidOperationException($"Item {detailId} not in catalog"); + viewer.Items.Add(new OwnedItemEntry { Item = item, Count = num, Viewer = viewer }); + return new GrantedReward((int)type, detailId, num); + } + owned.Count += num; + return new GrantedReward((int)type, detailId, owned.Count); + } + + case UserGoodsType.Card: + case UserGoodsType.SpotCard: + case UserGoodsType.SpotCardOnlyLatestCardPack: + throw new NotSupportedException( + $"{type} rewards are out of Phase 1 scope — extend RewardGrantService when /pack/open or similar needs them."); + + default: + throw new NotSupportedException($"UserGoodsType {type} not yet handled by RewardGrantService"); + } + } + + private static void AddCosmeticIfMissing(List collection, long detailId, DbSet catalog) where T : class + { + // Cosmetic ownership is binary — if the viewer already owns it, the grant is a no-op + // (matches client UpdateHaveUserGoodsNum behaviour which just calls .Acquired() each time). + bool alreadyOwned = collection.Any(e => GetId(e) == detailId); + if (alreadyOwned) return; + + // Wire reward_detail_id is long, but every cosmetic catalog in this codebase uses + // BaseEntity; downcast for Find. The checked() throws OverflowException if a + // future capture ships a real long id rather than silently truncating it. + var entity = catalog.Find(checked((int)detailId)) + ?? throw new InvalidOperationException( + $"Cosmetic id {detailId} not in catalog for type {typeof(T).Name}"); + collection.Add(entity); + } + + /// + /// Reflectively reads an entity's Id property — works for both BaseEntity<int> + /// (cosmetics) and BaseEntity<long> (e.g. Viewer/Card) without forcing two + /// non-generic overloads of . + /// + private static long GetId(T e) + { + var prop = typeof(T).GetProperty("Id") + ?? throw new InvalidOperationException($"Type {typeof(T).Name} missing Id property"); + var val = prop.GetValue(e); + return val switch { long l => l, int i => i, _ => 0 }; + } +} diff --git a/SVSim.EmulatedEntrypoint/Controllers/AdminController.cs b/SVSim.EmulatedEntrypoint/Controllers/AdminController.cs index 653fed0..5cc6fb2 100644 --- a/SVSim.EmulatedEntrypoint/Controllers/AdminController.cs +++ b/SVSim.EmulatedEntrypoint/Controllers/AdminController.cs @@ -198,6 +198,16 @@ public class AdminController : SVSimController .ToList(); var cards = await _dbContext.Cards.Where(c => allCardIds.Contains(c.Id)).ToDictionaryAsync(c => c.Id); + // Seeded MyRotation placeholder decks need a real rotation_id, otherwise the client's + // DeckData.GetMyRotationClassName NREs on `info.LastPackText` when the user clicks one + // (info is null because Data.MyRotationAllInfo.Get(null) returns null). Pick the highest + // rotation id available — it includes the most recent pack and therefore covers every + // class (including class_id=8 Nemesis, which requires last_pack >= 10007). + var latestMyRotationId = (await _dbContext.MyRotationSettings.AsNoTracking() + .Select(s => (int?)s.Id) + .OrderByDescending(id => id) + .FirstOrDefaultAsync())?.ToString(); + foreach (var format in SeededDeckFormats) { int slot = 1; @@ -224,6 +234,7 @@ public class AdminController : SVSimController LeaderSkin = leaderSkin, RandomLeaderSkin = false, Cards = deckCards, + MyRotationId = format == Format.MyRotation ? latestMyRotationId : null, }); } } diff --git a/SVSim.EmulatedEntrypoint/Controllers/DeckController.cs b/SVSim.EmulatedEntrypoint/Controllers/DeckController.cs index 3fdb645..305e562 100644 --- a/SVSim.EmulatedEntrypoint/Controllers/DeckController.cs +++ b/SVSim.EmulatedEntrypoint/Controllers/DeckController.cs @@ -181,6 +181,9 @@ public class DeckController : SVSimController if (skin is not null) deck.LeaderSkin = skin; deck.RandomLeaderSkin = request.IsRandomLeaderSkin; deck.Cards = cards; + // Clear stale rotation_id if the deck moved to a non-MyRotation format; + // otherwise persist the chosen period so it survives the next /load/index. + deck.MyRotationId = format == Format.MyRotation ? request.RotationId : null; }); } diff --git a/SVSim.EmulatedEntrypoint/Controllers/MyPageController.cs b/SVSim.EmulatedEntrypoint/Controllers/MyPageController.cs index 7028e81..44b4116 100644 --- a/SVSim.EmulatedEntrypoint/Controllers/MyPageController.cs +++ b/SVSim.EmulatedEntrypoint/Controllers/MyPageController.cs @@ -98,7 +98,7 @@ public class MyPageController : SVSimController { UserMyPageSetting = new MyPageBgSetting(), }, - BasicPuzzle = new BasicPuzzle { IsDisplayBadge = false }, // TODO(mypage-stub): viewer practice-puzzle progress + BasicPuzzle = new BasicPuzzleBadge { IsDisplayBadge = false }, // TODO(mypage-stub): viewer practice-puzzle progress IsBattlePassPeriod = rotation.IsBattlePassPeriod, SpecialCrystalInfo = new(), // TODO(mypage-stub): same shape/source as /load/index // CompetitionInfo, ShopNotification, StoryNotification, GuildNotification, GatheringInfo, diff --git a/SVSim.EmulatedEntrypoint/Controllers/PuzzleController.cs b/SVSim.EmulatedEntrypoint/Controllers/PuzzleController.cs new file mode 100644 index 0000000..d93d231 --- /dev/null +++ b/SVSim.EmulatedEntrypoint/Controllers/PuzzleController.cs @@ -0,0 +1,292 @@ +using System.Text.Json; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using SVSim.Database; +using SVSim.Database.Repositories.Globals; +using SVSim.Database.Repositories.Viewer; +using SVSim.Database.Services; +using SVSim.EmulatedEntrypoint.Models.Dtos.Common.BasicPuzzle; +using SVSim.EmulatedEntrypoint.Models.Dtos.Requests; +using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.BasicPuzzle; +using SVSim.EmulatedEntrypoint.Models.Dtos.Responses.BasicPuzzle; +using SVSim.EmulatedEntrypoint.Services; + +namespace SVSim.EmulatedEntrypoint.Controllers; + +/// +/// /basic_puzzle/* — solo puzzle subsystem (the "Practice Match" puzzle catalog visible from +/// the home screen). Explicit [Route] override because the base SVSimController's [controller] +/// token would resolve to /puzzle. +/// +[Route("basic_puzzle")] +public class PuzzleController : SVSimController +{ + private readonly IPuzzleCatalogRepository _catalog; + private readonly IPuzzleClearRepository _clears; + private readonly PuzzleMissionEvaluator _evaluator; + private readonly RewardGrantService _rewards; + + public PuzzleController( + IPuzzleCatalogRepository catalog, + IPuzzleClearRepository clears, + PuzzleMissionEvaluator evaluator, + RewardGrantService rewards) + { + _catalog = catalog; + _clears = clears; + _evaluator = evaluator; + _rewards = rewards; + } + + /// /basic_puzzle/info — full catalog of groups + per-viewer clear flags. + [HttpPost("info")] + public async Task> Info(BaseRequest _) + { + if (!TryGetViewerId(out long viewerId)) viewerId = 0; + + var groups = await _catalog.GetAllGroupsWithPuzzles(); + var missions = await _catalog.GetAllMissionsOrdered(); + var clearedByGroup = await _clears.GetClearedPuzzleIdsByGroup(viewerId); + + return ProjectGroups(groups, missions, clearedByGroup); + } + + /// /basic_puzzle/open_puzzle_dialog — per-group detail. Unknown puzzle_master_id + /// returns 200 with an empty puzzle_quest array (matches client PuzzleQuestInfo fallback). + [HttpPost("open_puzzle_dialog")] + public async Task OpenPuzzleDialog(OpenPuzzleDialogRequest req) + { + if (!TryGetViewerId(out long viewerId)) viewerId = 0; + var group = await _catalog.GetGroupWithPuzzles(req.PuzzleMasterId); + if (group is null) return new OpenPuzzleDialogResponse(); + + var cleared = await _clears.GetClearedPuzzleIds(viewerId); + return new OpenPuzzleDialogResponse + { + PuzzleQuest = group.Puzzles + .OrderBy(p => p.Id) + .Select(p => new PuzzleEntryResponse + { + PuzzleId = p.Id, + PuzzleDifficulty = p.PuzzleDifficulty, + IsCleared = cleared.Contains(p.Id), + IsAdditional = p.IsAdditional, + IsPlayable = p.IsPlayable, + ReleaseConditionTextId = p.ReleaseConditionTextId, + }) + .ToList(), + PuzzleQuestCharaId = group.PuzzleCharaId, + PuzzleDifficultyNameList = JsonSerializer.Deserialize>(group.DifficultyNameListJson) ?? new(), + IsDisplayBadge = false, + IsDisplayPuzzleNew = false, + }; + } + + /// /basic_puzzle/start — server is essentially a no-op. Wire data is the literal empty array `[]`. + [HttpPost("start")] + public Task Start(StartRequest _) => Task.FromResult(Array.Empty()); + + /// /basic_puzzle/mission — catalog + per-viewer progress on each mission. + /// Special-Round missions always surface with total_count=0 (Phase 1 deferral). + [HttpPost("mission")] + public async Task> Mission(BaseRequest _) + { + if (!TryGetViewerId(out long viewerId)) viewerId = 0; + + var missions = await _catalog.GetAllMissionsOrdered(); + var clearedByGroup = await _clears.GetClearedPuzzleIdsByGroup(viewerId); + var statuses = _evaluator.Evaluate(missions, clearedByGroup); + + return statuses.Select(s => new PuzzleMissionResponse + { + MissionName = s.Mission.MissionName, + RequireNumber = s.Mission.RequireNumber, + CampaignCommenceTime = s.Mission.CampaignCommenceTime, + RewardList = new List + { + new() { + RewardType = s.Mission.RewardType, + RewardDetailId = s.Mission.RewardDetailId, + RewardNumber = s.Mission.RewardNumber, + }, + }, + OrderId = s.Mission.OrderId, + TotalCount = s.TotalCount, + IsAchieved = s.IsAchieved, + }).ToList(); + } + + /// + /// /basic_puzzle/finish — record a puzzle attempt outcome. Wins persist a ViewerPuzzleClear + /// row and may grant a mission reward; losses are fully stateless (the client only sends + /// is_win=false on user-initiated retire, not on in-battle resets). + /// + /// CONCURRENCY: this controller does not serialize concurrent finishes for the same viewer. + /// The ViewerPuzzleClear PK protects per-row idempotency but two simultaneous finishes for + /// different puzzles in the same group could both observe "this is the last clear" and + /// double-grant the mission reward. The same race exists across many viewer-mutating + /// endpoints in this codebase — address with a holistic audit, not a puzzle-specific fix. + /// + [HttpPost("finish")] + public async Task Finish(FinishRequest req) + { + if (!TryGetViewerId(out long viewerId)) viewerId = 0; + + var response = new FinishResponse(); + var groups = await _catalog.GetAllGroupsWithPuzzles(); + var missions = await _catalog.GetAllMissionsOrdered(); + + if (!req.IsWin) + { + // Loss: no DB writes. Loss-specific wire quirks: win_count is the NUMBER 0 + // (not string "1"), and mission_start_data is empty. + response.WinCount = 0; + response.AchievedInfo.MissionStartData = new(); + response.PuzzleList = ProjectGroups(groups, missions, await _clears.GetClearedPuzzleIdsByGroup(viewerId)); + return response; + } + + // ---- Win path ---- + var beforeByGroup = await _clears.GetClearedPuzzleIdsByGroup(viewerId); + await _clears.UpsertClearAsync(viewerId, req.PuzzleId, req.RetryCount); + + // Recompute clearedByGroup by adding the freshly cleared puzzle to its group. + var puzzleLocation = groups + .SelectMany(g => g.Puzzles.Select(p => (GroupId: g.Id, PuzzleId: p.Id))) + .FirstOrDefault(x => x.PuzzleId == req.PuzzleId); + var afterByGroup = beforeByGroup.ToDictionary(k => k.Key, v => new HashSet(v.Value)); + if (puzzleLocation.PuzzleId != 0) + { + if (!afterByGroup.TryGetValue(puzzleLocation.GroupId, out var groupSet)) + { + groupSet = new HashSet(); + afterByGroup[puzzleLocation.GroupId] = groupSet; + } + groupSet.Add(req.PuzzleId); + } + + var fresh = _evaluator.FreshlyCompleted(missions, beforeByGroup, afterByGroup); + var freshlyAchievedIds = new HashSet(fresh.Select(s => s.Mission.Id)); + + if (fresh.Count > 0) + { + // Load viewer with all the collections RewardGrantService might mutate. Split-query + // to avoid the cartesian-explode pitfall (CLAUDE.md "EF split query"). + var ctx = HttpContext.RequestServices.GetRequiredService(); + var viewer = await ctx.Viewers + .Include(v => v.Sleeves) + .Include(v => v.Emblems) + .Include(v => v.LeaderSkins) + .Include(v => v.Degrees) + .Include(v => v.MyPageBackgrounds) + .Include(v => v.Items).ThenInclude(i => i.Item) + .AsSplitQuery() + .FirstAsync(v => v.Id == viewerId); + + foreach (var status in fresh) + { + var granted = _rewards.Apply( + viewer, + (SVSim.Database.Enums.UserGoodsType)status.Mission.RewardType, + status.Mission.RewardDetailId, + status.Mission.RewardNumber); + + response.AchievedInfo.AchievedMissionList.Add(new PuzzleAchievedMissionEntry + { + AchievedMessage = status.Mission.AchievedMessage, + }); + response.AchievedInfo.AchievedMissionRewardList.Add(new PuzzleAchievedMissionReward + { + MissionRewardType = status.Mission.RewardType, + MissionRewardDetailId = status.Mission.RewardDetailId, + MissionRewardNumber = status.Mission.RewardNumber, + }); + response.RewardList.Add(new TreasureRewardResponse + { + RewardType = granted.RewardType, + RewardId = granted.RewardId, + RewardNum = granted.RewardNum, + }); + } + + await ctx.SaveChangesAsync(); + } + + response.WinCount = "1"; + response.AchievedInfo.MissionStartData = BuildMissionStartData(missions, afterByGroup, freshlyAchievedIds); + response.PuzzleList = ProjectGroups(groups, missions, afterByGroup); + return response; + } + + private List BuildMissionStartData( + IEnumerable missions, + IReadOnlyDictionary> clearedByGroup, + ISet freshlyAchieved) + { + var statuses = _evaluator.Evaluate(missions, clearedByGroup); + return statuses + .Where(s => !s.IsAchieved && !freshlyAchieved.Contains(s.Mission.Id)) + .Select(s => new MissionStartEntry + { + MissionName = s.Mission.MissionName, + StartTime = s.Mission.CampaignCommenceTime, + LotType = "3", // puzzle-group-clear; Phase 1 only emits puzzle missions + }) + .ToList(); + } + + /// Shared projection used by /info and /finish.puzzle_list. Applies per-viewer clear + /// flags, computes is_all_cleared, and toggles is_mission_target based on mission progress. + internal List ProjectGroups( + IEnumerable groups, + IEnumerable missions, + IReadOnlyDictionary> clearedByGroup) + { + var statuses = _evaluator.Evaluate(missions, clearedByGroup); + var achievedGroupIds = statuses + .Where(s => s.IsAchieved && s.Mission.TargetPuzzleGroupId is int) + .Select(s => s.Mission.TargetPuzzleGroupId!.Value) + .ToHashSet(); + var mappedGroupIds = missions + .Where(m => m.TargetPuzzleGroupId is int) + .Select(m => m.TargetPuzzleGroupId!.Value) + .ToHashSet(); + + var result = new List(); + foreach (var g in groups) + { + var cleared = clearedByGroup.TryGetValue(g.Id, out var c) ? c : new HashSet(); + var puzzleEntries = g.Puzzles + .OrderBy(p => p.Id) + .Select(p => new PuzzleEntryResponse + { + PuzzleId = p.Id, + PuzzleDifficulty = p.PuzzleDifficulty, + IsCleared = cleared.Contains(p.Id), + IsAdditional = p.IsAdditional, + IsPlayable = p.IsPlayable, + ReleaseConditionTextId = p.ReleaseConditionTextId, + }) + .ToList(); + + bool isAllCleared = puzzleEntries.All(p => p.IsCleared) && puzzleEntries.Count > 0; + bool isMissionTarget = mappedGroupIds.Contains(g.Id) && !achievedGroupIds.Contains(g.Id); + + result.Add(new PuzzleGroupResponse + { + PuzzleMasterId = g.Id, + PuzzleData = puzzleEntries, + PuzzleCharaId = g.PuzzleCharaId, + PuzzleDifficultyNameList = JsonSerializer.Deserialize>(g.DifficultyNameListJson) ?? new(), + IsAllCleared = isAllCleared, + CharaId = g.CharaId, + SortType = g.SortType, + BasicTitleTextId = g.BasicTitleTextId, + IsMissionTarget = isMissionTarget, + }); + } + // Captured order in prod is descending by puzzle_master_id; mirror that for the wire. + return result.OrderByDescending(r => r.PuzzleMasterId).ToList(); + } +} diff --git a/SVSim.EmulatedEntrypoint/Models/Dtos/BasicPuzzle.cs b/SVSim.EmulatedEntrypoint/Models/Dtos/BasicPuzzleBadge.cs similarity index 64% rename from SVSim.EmulatedEntrypoint/Models/Dtos/BasicPuzzle.cs rename to SVSim.EmulatedEntrypoint/Models/Dtos/BasicPuzzleBadge.cs index e469b89..e71a26b 100644 --- a/SVSim.EmulatedEntrypoint/Models/Dtos/BasicPuzzle.cs +++ b/SVSim.EmulatedEntrypoint/Models/Dtos/BasicPuzzleBadge.cs @@ -6,9 +6,13 @@ namespace SVSim.EmulatedEntrypoint.Models.Dtos; /// /// basic_puzzle.is_display_badge — drives the "practice puzzle" badge on the /// footer. Read by MyPageTask.cs:177. +/// +/// Named with the "Badge" suffix to avoid colliding with the +/// Models.Dtos.{Common,Requests,Responses}.BasicPuzzle sub-namespaces +/// that hold the /basic_puzzle/* endpoint DTOs. /// [MessagePackObject] -public class BasicPuzzle +public class BasicPuzzleBadge { [JsonPropertyName("is_display_badge")] [Key("is_display_badge")] diff --git a/SVSim.EmulatedEntrypoint/Models/Dtos/Common/BasicPuzzle/PuzzleEntryResponse.cs b/SVSim.EmulatedEntrypoint/Models/Dtos/Common/BasicPuzzle/PuzzleEntryResponse.cs new file mode 100644 index 0000000..bdab209 --- /dev/null +++ b/SVSim.EmulatedEntrypoint/Models/Dtos/Common/BasicPuzzle/PuzzleEntryResponse.cs @@ -0,0 +1,27 @@ +using System.Text.Json.Serialization; +using MessagePack; +using SVSim.EmulatedEntrypoint.Models.Dtos.Common; + +namespace SVSim.EmulatedEntrypoint.Models.Dtos.Common.BasicPuzzle; + +[MessagePackObject] +public class PuzzleEntryResponse +{ + [JsonPropertyName("puzzle_id")] [JsonConverter(typeof(StringifiedIntConverter))] [Key("puzzle_id")] + public int PuzzleId { get; set; } + + [JsonPropertyName("puzzle_difficulty")] [JsonConverter(typeof(StringifiedIntConverter))] [Key("puzzle_difficulty")] + public int PuzzleDifficulty { get; set; } + + [JsonPropertyName("is_cleared")] [Key("is_cleared")] + public bool IsCleared { get; set; } + + [JsonPropertyName("is_additional")] [Key("is_additional")] + public bool IsAdditional { get; set; } + + [JsonPropertyName("is_playable")] [Key("is_playable")] + public bool IsPlayable { get; set; } = true; + + [JsonPropertyName("release_condition_text_id")] [Key("release_condition_text_id")] + public string ReleaseConditionTextId { get; set; } = string.Empty; +} diff --git a/SVSim.EmulatedEntrypoint/Models/Dtos/Common/BasicPuzzle/PuzzleGroupResponse.cs b/SVSim.EmulatedEntrypoint/Models/Dtos/Common/BasicPuzzle/PuzzleGroupResponse.cs new file mode 100644 index 0000000..6a0468c --- /dev/null +++ b/SVSim.EmulatedEntrypoint/Models/Dtos/Common/BasicPuzzle/PuzzleGroupResponse.cs @@ -0,0 +1,36 @@ +using System.Text.Json.Serialization; +using MessagePack; +using SVSim.EmulatedEntrypoint.Models.Dtos.Common; + +namespace SVSim.EmulatedEntrypoint.Models.Dtos.Common.BasicPuzzle; + +[MessagePackObject] +public class PuzzleGroupResponse +{ + [JsonPropertyName("puzzle_master_id")] [JsonConverter(typeof(StringifiedIntConverter))] [Key("puzzle_master_id")] + public int PuzzleMasterId { get; set; } + + [JsonPropertyName("puzzle_data")] [Key("puzzle_data")] + public List PuzzleData { get; set; } = new(); + + [JsonPropertyName("puzzle_chara_id")] [JsonConverter(typeof(StringifiedIntConverter))] [Key("puzzle_chara_id")] + public int PuzzleCharaId { get; set; } + + [JsonPropertyName("puzzle_difficulty_name_list")] [Key("puzzle_difficulty_name_list")] + public Dictionary PuzzleDifficultyNameList { get; set; } = new(); + + [JsonPropertyName("is_all_cleared")] [Key("is_all_cleared")] + public bool IsAllCleared { get; set; } + + [JsonPropertyName("chara_id")] [JsonConverter(typeof(StringifiedIntConverter))] [Key("chara_id")] + public int CharaId { get; set; } + + [JsonPropertyName("sort_type")] [JsonConverter(typeof(StringifiedIntConverter))] [Key("sort_type")] + public int SortType { get; set; } + + [JsonPropertyName("basic_title_text_id")] [Key("basic_title_text_id")] + public string BasicTitleTextId { get; set; } = string.Empty; + + [JsonPropertyName("is_mission_target")] [Key("is_mission_target")] + public bool IsMissionTarget { get; set; } +} diff --git a/SVSim.EmulatedEntrypoint/Models/Dtos/Common/StringifiedIntConverter.cs b/SVSim.EmulatedEntrypoint/Models/Dtos/Common/StringifiedIntConverter.cs new file mode 100644 index 0000000..7a00592 --- /dev/null +++ b/SVSim.EmulatedEntrypoint/Models/Dtos/Common/StringifiedIntConverter.cs @@ -0,0 +1,33 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SVSim.EmulatedEntrypoint.Models.Dtos.Common; + +/// Serializes int as a JSON string ("123"), deserializes from either form. Several +/// /basic_puzzle/* fields use this on the wire (puzzle_master_id, total_count, reward_type, etc.). +public sealed class StringifiedIntConverter : JsonConverter +{ + public override int Read(ref Utf8JsonReader r, Type _, JsonSerializerOptions __) => + r.TokenType switch + { + JsonTokenType.String when int.TryParse(r.GetString(), out var v) => v, + JsonTokenType.Number => r.GetInt32(), + _ => 0 + }; + public override void Write(Utf8JsonWriter w, int v, JsonSerializerOptions _) => + w.WriteStringValue(v.ToString()); +} + +/// Same for long. Reward ids fit in int but the client uses long internally. +public sealed class StringifiedLongConverter : JsonConverter +{ + public override long Read(ref Utf8JsonReader r, Type _, JsonSerializerOptions __) => + r.TokenType switch + { + JsonTokenType.String when long.TryParse(r.GetString(), out var v) => v, + JsonTokenType.Number => r.GetInt64(), + _ => 0 + }; + public override void Write(Utf8JsonWriter w, long v, JsonSerializerOptions _) => + w.WriteStringValue(v.ToString()); +} diff --git a/SVSim.EmulatedEntrypoint/Models/Dtos/Requests/BasicPuzzle/FinishRequest.cs b/SVSim.EmulatedEntrypoint/Models/Dtos/Requests/BasicPuzzle/FinishRequest.cs new file mode 100644 index 0000000..9aaf4ff --- /dev/null +++ b/SVSim.EmulatedEntrypoint/Models/Dtos/Requests/BasicPuzzle/FinishRequest.cs @@ -0,0 +1,20 @@ +using MessagePack; +using System.Text.Json.Serialization; + +namespace SVSim.EmulatedEntrypoint.Models.Dtos.Requests.BasicPuzzle; + +[MessagePackObject] +public class FinishRequest : BaseRequest +{ + [JsonPropertyName("puzzle_id")] + [Key("puzzle_id")] + public int PuzzleId { get; set; } + + [JsonPropertyName("retry_count")] + [Key("retry_count")] + public int RetryCount { get; set; } + + [JsonPropertyName("is_win")] + [Key("is_win")] + public bool IsWin { get; set; } +} diff --git a/SVSim.EmulatedEntrypoint/Models/Dtos/Requests/BasicPuzzle/OpenPuzzleDialogRequest.cs b/SVSim.EmulatedEntrypoint/Models/Dtos/Requests/BasicPuzzle/OpenPuzzleDialogRequest.cs new file mode 100644 index 0000000..318394c --- /dev/null +++ b/SVSim.EmulatedEntrypoint/Models/Dtos/Requests/BasicPuzzle/OpenPuzzleDialogRequest.cs @@ -0,0 +1,12 @@ +using MessagePack; +using System.Text.Json.Serialization; + +namespace SVSim.EmulatedEntrypoint.Models.Dtos.Requests.BasicPuzzle; + +[MessagePackObject] +public class OpenPuzzleDialogRequest : BaseRequest +{ + [JsonPropertyName("puzzle_master_id")] + [Key("puzzle_master_id")] + public int PuzzleMasterId { get; set; } +} diff --git a/SVSim.EmulatedEntrypoint/Models/Dtos/Requests/BasicPuzzle/StartRequest.cs b/SVSim.EmulatedEntrypoint/Models/Dtos/Requests/BasicPuzzle/StartRequest.cs new file mode 100644 index 0000000..d8cd657 --- /dev/null +++ b/SVSim.EmulatedEntrypoint/Models/Dtos/Requests/BasicPuzzle/StartRequest.cs @@ -0,0 +1,12 @@ +using MessagePack; +using System.Text.Json.Serialization; + +namespace SVSim.EmulatedEntrypoint.Models.Dtos.Requests.BasicPuzzle; + +[MessagePackObject] +public class StartRequest : BaseRequest +{ + [JsonPropertyName("puzzle_id")] + [Key("puzzle_id")] + public int PuzzleId { get; set; } +} diff --git a/SVSim.EmulatedEntrypoint/Models/Dtos/Responses/BasicPuzzle/FinishResponse.cs b/SVSim.EmulatedEntrypoint/Models/Dtos/Responses/BasicPuzzle/FinishResponse.cs new file mode 100644 index 0000000..0e5a53a --- /dev/null +++ b/SVSim.EmulatedEntrypoint/Models/Dtos/Responses/BasicPuzzle/FinishResponse.cs @@ -0,0 +1,140 @@ +using System.Text.Json.Serialization; +using MessagePack; +using SVSim.EmulatedEntrypoint.Models.Dtos.Common; +using SVSim.EmulatedEntrypoint.Models.Dtos.Common.BasicPuzzle; + +namespace SVSim.EmulatedEntrypoint.Models.Dtos.Responses.BasicPuzzle; + +[MessagePackObject] +public class FinishResponse +{ + [JsonPropertyName("add_point")] [Key("add_point")] + public int? AddPoint { get; set; } = null; + + /// STRING "1" on wins, NUMBER 0 on losses — both observed in prod. Per-call wire type + /// quirk; controller writes the right one based on is_win. + [JsonPropertyName("win_count")] [Key("win_count")] + public object WinCount { get; set; } = 0; + + [JsonPropertyName("class_experience")] [JsonConverter(typeof(StringifiedIntConverter))] [Key("class_experience")] + public int ClassExperience { get; set; } = 0; + + [JsonPropertyName("class_level")] [JsonConverter(typeof(StringifiedIntConverter))] [Key("class_level")] + public int ClassLevel { get; set; } = 1; + + [JsonPropertyName("achieved_info")] [Key("achieved_info")] + public AchievedInfoResponse AchievedInfo { get; set; } = new(); + + [JsonPropertyName("reward_list")] [Key("reward_list")] + public List RewardList { get; set; } = new(); + + [JsonPropertyName("class_bonus_point")] [Key("class_bonus_point")] + public int ClassBonusPoint { get; set; } = 0; + + [JsonPropertyName("format_bonus_point")] [Key("format_bonus_point")] + public int FormatBonusPoint { get; set; } = 0; + + [JsonPropertyName("required_win_count_for_win_bonus_point")] [Key("required_win_count_for_win_bonus_point")] + public int RequiredWinCountForWinBonusPoint { get; set; } = 0; + + [JsonPropertyName("win_bonus_point")] [Key("win_bonus_point")] + public int WinBonusPoint { get; set; } = 0; + + [JsonPropertyName("win_bonus_point_status")] [Key("win_bonus_point_status")] + public int WinBonusPointStatus { get; set; } = 0; + + [JsonPropertyName("get_class_experience")] [Key("get_class_experience")] + public int GetClassExperience { get; set; } = 0; + + [JsonPropertyName("clear_mission_list")] [Key("clear_mission_list")] + public ClearMissionListResponse ClearMissionList { get; set; } = new(); + + [JsonPropertyName("spot_point_data")] [Key("spot_point_data")] + public SpotPointDataResponse SpotPointData { get; set; } = new(); + + [JsonPropertyName("puzzle_list")] [Key("puzzle_list")] + public List PuzzleList { get; set; } = new(); +} + +[MessagePackObject] +public class AchievedInfoResponse +{ + [JsonPropertyName("achieved_mission_list")] [Key("achieved_mission_list")] + public List AchievedMissionList { get; set; } = new(); + + [JsonPropertyName("achieved_mission_reward_list")] [Key("achieved_mission_reward_list")] + public List AchievedMissionRewardList { get; set; } = new(); + + [JsonPropertyName("mission_start_data")] [Key("mission_start_data")] + public List MissionStartData { get; set; } = new(); + + [JsonPropertyName("battle_pass_reward_list")] [Key("battle_pass_reward_list")] + public List BattlePassRewardList { get; set; } = new(); + + [JsonPropertyName("battle_pass_message_list")] [Key("battle_pass_message_list")] + public List BattlePassMessageList { get; set; } = new(); +} + +[MessagePackObject] +public class PuzzleAchievedMissionEntry +{ + [JsonPropertyName("achieved_message")] [Key("achieved_message")] + public string AchievedMessage { get; set; } = string.Empty; +} + +[MessagePackObject] +public class PuzzleAchievedMissionReward +{ + [JsonPropertyName("mission_reward_type")] [JsonConverter(typeof(StringifiedIntConverter))] [Key("mission_reward_type")] + public int MissionRewardType { get; set; } + + [JsonPropertyName("mission_reward_detail_id")] [JsonConverter(typeof(StringifiedLongConverter))] [Key("mission_reward_detail_id")] + public long MissionRewardDetailId { get; set; } + + [JsonPropertyName("mission_reward_number")] [JsonConverter(typeof(StringifiedIntConverter))] [Key("mission_reward_number")] + public int MissionRewardNumber { get; set; } +} + +[MessagePackObject] +public class MissionStartEntry +{ + [JsonPropertyName("mission_name")] [Key("mission_name")] + public string MissionName { get; set; } = string.Empty; + + [JsonPropertyName("start_time")] [Key("start_time")] + public long StartTime { get; set; } + + [JsonPropertyName("lot_type")] [Key("lot_type")] + public string LotType { get; set; } = "3"; // Phase 1 only emits puzzle-mission lot_type +} + +[MessagePackObject] +public class TreasureRewardResponse +{ + [JsonPropertyName("reward_type")] [JsonConverter(typeof(StringifiedIntConverter))] [Key("reward_type")] + public int RewardType { get; set; } + + [JsonPropertyName("reward_id")] [JsonConverter(typeof(StringifiedLongConverter))] [Key("reward_id")] + public long RewardId { get; set; } + + [JsonPropertyName("reward_num")] [JsonConverter(typeof(StringifiedIntConverter))] [Key("reward_num")] + public int RewardNum { get; set; } +} + +[MessagePackObject] +public class ClearMissionListResponse +{ + [JsonPropertyName("common_mission")] [Key("common_mission")] + public List CommonMission { get; set; } = new(); + + [JsonPropertyName("character_mission")] [Key("character_mission")] + public List CharacterMission { get; set; } = new(); +} + +[MessagePackObject] +public class SpotPointDataResponse +{ + [JsonPropertyName("before_spot_point")] [Key("before_spot_point")] public int BeforeSpotPoint { get; set; } + [JsonPropertyName("add_spot_point")] [Key("add_spot_point")] public int AddSpotPoint { get; set; } + [JsonPropertyName("after_spot_point")] [Key("after_spot_point")] public int AfterSpotPoint { get; set; } +} diff --git a/SVSim.EmulatedEntrypoint/Models/Dtos/Responses/BasicPuzzle/OpenPuzzleDialogResponse.cs b/SVSim.EmulatedEntrypoint/Models/Dtos/Responses/BasicPuzzle/OpenPuzzleDialogResponse.cs new file mode 100644 index 0000000..93453b3 --- /dev/null +++ b/SVSim.EmulatedEntrypoint/Models/Dtos/Responses/BasicPuzzle/OpenPuzzleDialogResponse.cs @@ -0,0 +1,25 @@ +using System.Text.Json.Serialization; +using MessagePack; +using SVSim.EmulatedEntrypoint.Models.Dtos.Common; +using SVSim.EmulatedEntrypoint.Models.Dtos.Common.BasicPuzzle; + +namespace SVSim.EmulatedEntrypoint.Models.Dtos.Responses.BasicPuzzle; + +[MessagePackObject] +public class OpenPuzzleDialogResponse +{ + [JsonPropertyName("puzzle_quest")] [Key("puzzle_quest")] + public List PuzzleQuest { get; set; } = new(); + + [JsonPropertyName("puzzle_quest_chara_id")] [JsonConverter(typeof(StringifiedIntConverter))] [Key("puzzle_quest_chara_id")] + public int PuzzleQuestCharaId { get; set; } + + [JsonPropertyName("puzzle_difficulty_name_list")] [Key("puzzle_difficulty_name_list")] + public Dictionary PuzzleDifficultyNameList { get; set; } = new(); + + [JsonPropertyName("is_display_badge")] [Key("is_display_badge")] + public bool IsDisplayBadge { get; set; } = false; + + [JsonPropertyName("is_display_puzzle_new")] [Key("is_display_puzzle_new")] + public bool IsDisplayPuzzleNew { get; set; } = false; +} diff --git a/SVSim.EmulatedEntrypoint/Models/Dtos/Responses/BasicPuzzle/PuzzleMissionResponse.cs b/SVSim.EmulatedEntrypoint/Models/Dtos/Responses/BasicPuzzle/PuzzleMissionResponse.cs new file mode 100644 index 0000000..9000846 --- /dev/null +++ b/SVSim.EmulatedEntrypoint/Models/Dtos/Responses/BasicPuzzle/PuzzleMissionResponse.cs @@ -0,0 +1,43 @@ +using System.Text.Json.Serialization; +using MessagePack; +using SVSim.EmulatedEntrypoint.Models.Dtos.Common; + +namespace SVSim.EmulatedEntrypoint.Models.Dtos.Responses.BasicPuzzle; + +[MessagePackObject] +public class PuzzleMissionResponse +{ + [JsonPropertyName("mission_name")] [Key("mission_name")] + public string MissionName { get; set; } = string.Empty; + + [JsonPropertyName("require_number")] [JsonConverter(typeof(StringifiedIntConverter))] [Key("require_number")] + public int RequireNumber { get; set; } + + [JsonPropertyName("campaign_commence_time")] [Key("campaign_commence_time")] + public long CampaignCommenceTime { get; set; } + + [JsonPropertyName("reward_list")] [Key("reward_list")] + public List RewardList { get; set; } = new(); + + [JsonPropertyName("order_id")] [JsonConverter(typeof(StringifiedIntConverter))] [Key("order_id")] + public int OrderId { get; set; } + + [JsonPropertyName("total_count")] [JsonConverter(typeof(StringifiedIntConverter))] [Key("total_count")] + public int TotalCount { get; set; } + + [JsonPropertyName("is_achieved")] [Key("is_achieved")] + public bool IsAchieved { get; set; } +} + +[MessagePackObject] +public class PuzzleMissionRewardResponse +{ + [JsonPropertyName("reward_type")] [JsonConverter(typeof(StringifiedIntConverter))] [Key("reward_type")] + public int RewardType { get; set; } + + [JsonPropertyName("reward_detail_id")] [JsonConverter(typeof(StringifiedLongConverter))] [Key("reward_detail_id")] + public long RewardDetailId { get; set; } + + [JsonPropertyName("reward_number")] [JsonConverter(typeof(StringifiedIntConverter))] [Key("reward_number")] + public int RewardNumber { get; set; } +} diff --git a/SVSim.EmulatedEntrypoint/Models/Dtos/Responses/MyPageIndexResponse.cs b/SVSim.EmulatedEntrypoint/Models/Dtos/Responses/MyPageIndexResponse.cs index db68c3c..6d84e9f 100644 --- a/SVSim.EmulatedEntrypoint/Models/Dtos/Responses/MyPageIndexResponse.cs +++ b/SVSim.EmulatedEntrypoint/Models/Dtos/Responses/MyPageIndexResponse.cs @@ -222,7 +222,7 @@ public class MyPageIndexResponse [JsonPropertyName("basic_puzzle")] [Key("basic_puzzle")] - public BasicPuzzle BasicPuzzle { get; set; } = new(); + public BasicPuzzleBadge BasicPuzzle { get; set; } = new(); // ── Battle Pass period flag ──────────────────────────────────────────── diff --git a/SVSim.EmulatedEntrypoint/Models/Dtos/UserDeck.cs b/SVSim.EmulatedEntrypoint/Models/Dtos/UserDeck.cs index c56b03b..7be2dcc 100644 --- a/SVSim.EmulatedEntrypoint/Models/Dtos/UserDeck.cs +++ b/SVSim.EmulatedEntrypoint/Models/Dtos/UserDeck.cs @@ -56,6 +56,16 @@ public class UserDeck [Key("create_deck_time")] public DateTime? DeckCreateTime { get; set; } + /// + /// MyRotation period id. Emitted only for Format.MyRotation decks; the client's + /// DeckData.Initialize reads it via GetValueOrDefault("rotation_id", null) and resolves + /// against Data.MyRotationAllInfo. A MyRotation deck without this field crashes the + /// deck-detail dialog inside DeckData.CreateMyRotationClassName (info.LastPackText on null). + /// + [JsonPropertyName("rotation_id")] + [Key("rotation_id")] + public string? RotationId { get; set; } + /// /// Empty placeholder matching the wire shape prod uses to pad deck-list responses up to the /// per-format cap. The client's DeckUI.DeckViewData.CreateDeckViewList converts the @@ -93,6 +103,7 @@ public class UserDeck this.IsRandomLeaderSkin = deck.RandomLeaderSkin ? 1 : 0; this.Order = deck.Number; this.DeckCreateTime = deck.DateCreated; + this.RotationId = deck.MyRotationId; //TODO probably want to calc some of these on demand this.IsCompleteDeck = 1; diff --git a/SVSim.EmulatedEntrypoint/Program.cs b/SVSim.EmulatedEntrypoint/Program.cs index b127d54..51136cd 100644 --- a/SVSim.EmulatedEntrypoint/Program.cs +++ b/SVSim.EmulatedEntrypoint/Program.cs @@ -8,6 +8,7 @@ using SVSim.Database.Repositories.Deck; using SVSim.Database.Repositories.Globals; using SVSim.Database.Repositories.Pack; using SVSim.Database.Repositories.Viewer; +using SVSim.Database.Services; using SVSim.EmulatedEntrypoint.Configuration; using SVSim.EmulatedEntrypoint.Extensions; using SVSim.EmulatedEntrypoint.Middlewares; @@ -58,10 +59,12 @@ public class Program opt.UseNpgsql(builder.Configuration.GetConnectionString("ApplicationDb")); }); builder.Services.AddTransient(); + builder.Services.AddTransient(); builder.Services.AddTransient(); builder.Services.AddTransient(); builder.Services.AddTransient(); builder.Services.AddTransient(); + builder.Services.AddTransient(); builder.Services.AddTransient(); builder.Services.AddTransient(); // Scoped (not Singleton) to avoid the singleton-depends-on-scoped-DbContext lifecycle @@ -71,7 +74,9 @@ public class Program builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddSingleton(); + builder.Services.AddSingleton(); #endregion diff --git a/SVSim.EmulatedEntrypoint/Services/PuzzleMissionEvaluator.cs b/SVSim.EmulatedEntrypoint/Services/PuzzleMissionEvaluator.cs new file mode 100644 index 0000000..74381fd --- /dev/null +++ b/SVSim.EmulatedEntrypoint/Services/PuzzleMissionEvaluator.cs @@ -0,0 +1,53 @@ +using SVSim.Database.Models; + +namespace SVSim.EmulatedEntrypoint.Services; + +/// +/// Pure service — maps the puzzle mission catalog against a viewer's cleared-puzzle set and +/// produces per-mission (total_count, is_achieved) statuses. Used by both /basic_puzzle/mission +/// (snapshot) and /basic_puzzle/finish (post-clear delta detection). +/// +public sealed class PuzzleMissionEvaluator +{ + public sealed record MissionStatus(PuzzleMissionEntry Mission, int TotalCount, bool IsAchieved); + + public IReadOnlyList Evaluate( + IEnumerable catalog, + IReadOnlyDictionary> clearedByGroup) + { + var result = new List(); + foreach (var mission in catalog) + { + int count = ComputeTotalCount(mission, clearedByGroup); + result.Add(new MissionStatus(mission, count, count >= mission.RequireNumber)); + } + return result; + } + + /// Returns ONLY the missions whose status flipped from not-achieved to achieved + /// between before and after. Other missions (already-achieved, still-incomplete) are omitted. + public IReadOnlyList FreshlyCompleted( + IEnumerable catalog, + IReadOnlyDictionary> clearedByGroupBefore, + IReadOnlyDictionary> clearedByGroupAfter) + { + var result = new List(); + foreach (var mission in catalog) + { + int before = ComputeTotalCount(mission, clearedByGroupBefore); + int after = ComputeTotalCount(mission, clearedByGroupAfter); + bool wasAchieved = before >= mission.RequireNumber; + bool isAchieved = after >= mission.RequireNumber; + if (!wasAchieved && isAchieved) + result.Add(new MissionStatus(mission, after, true)); + } + return result; + } + + private static int ComputeTotalCount(PuzzleMissionEntry mission, IReadOnlyDictionary> clearedByGroup) + { + if (mission.TargetPuzzleGroupId is not int groupId) return 0; + if (!clearedByGroup.TryGetValue(groupId, out var cleared)) return 0; + return Math.Min(cleared.Count, mission.RequireNumber); + } +} diff --git a/SVSim.UnitTests/Controllers/PuzzleControllerTests.cs b/SVSim.UnitTests/Controllers/PuzzleControllerTests.cs new file mode 100644 index 0000000..67767f5 --- /dev/null +++ b/SVSim.UnitTests/Controllers/PuzzleControllerTests.cs @@ -0,0 +1,290 @@ +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.Database.Repositories.Viewer; +using SVSim.UnitTests.Infrastructure; + +namespace SVSim.UnitTests.Controllers; + +public class PuzzleControllerTests +{ + private const string BaseRequestJson = + """{"viewer_id":"0","steam_id":0,"steam_session_ticket":""}"""; + + [Test] + public async Task Info_returns_25_groups_with_puzzles() + { + using var factory = new SVSimTestFactory(); + await factory.SeedGlobalsAsync(); + long viewerId = await factory.SeedViewerAsync(); + using var client = factory.CreateAuthenticatedClient(viewerId); + + var response = await client.PostAsync("/basic_puzzle/info", + new StringContent(BaseRequestJson, Encoding.UTF8, "application/json")); + + var body = await response.Content.ReadAsStringAsync(); + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), body); + + using var doc = JsonDocument.Parse(body); + // Controllers return the inner data payload; the wrapping {data_headers, data} envelope + // is added by ShadowverseTranslationMiddleware which the test factory bypasses, so the + // root element here IS the array (see PracticeControllerTests for the same pattern). + var data = doc.RootElement; + Assert.That(data.ValueKind, Is.EqualTo(JsonValueKind.Array)); + Assert.That(data.GetArrayLength(), Is.EqualTo(25)); + + var g301 = data.EnumerateArray().Single(g => g.GetProperty("puzzle_master_id").GetString() == "301"); + Assert.That(g301.GetProperty("is_all_cleared").GetBoolean(), Is.False); + Assert.That(g301.GetProperty("puzzle_data").GetArrayLength(), Is.EqualTo(3)); + // String-on-wire assertion: puzzle_master_id ships as a JSON string, not number. + Assert.That(g301.GetProperty("puzzle_master_id").ValueKind, Is.EqualTo(JsonValueKind.String)); + } + + [Test] + public async Task Info_reflects_per_viewer_clears() + { + using var factory = new SVSimTestFactory(); + await factory.SeedGlobalsAsync(); + long viewerId = await factory.SeedViewerAsync(); + // Resolve the repo from a scope, not factory.Services directly (scoped service constraint). + using (var scope = factory.Services.CreateScope()) + { + var clearRepo = scope.ServiceProvider.GetRequiredService(); + await clearRepo.UpsertClearAsync(viewerId, 37, 0); + } + using var client = factory.CreateAuthenticatedClient(viewerId); + + var body = await (await client.PostAsync("/basic_puzzle/info", + new StringContent(BaseRequestJson, Encoding.UTF8, "application/json"))) + .Content.ReadAsStringAsync(); + + using var doc = JsonDocument.Parse(body); + var g301 = doc.RootElement.EnumerateArray() + .Single(g => g.GetProperty("puzzle_master_id").GetString() == "301"); + var p37 = g301.GetProperty("puzzle_data").EnumerateArray() + .Single(p => p.GetProperty("puzzle_id").GetString() == "37"); + Assert.That(p37.GetProperty("is_cleared").GetBoolean(), Is.True); + + Assert.That(g301.GetProperty("is_mission_target").GetBoolean(), Is.True, + "Round 1 mission still incomplete (1/3) so group 301 is still a mission target"); + } + + [Test] + public async Task OpenPuzzleDialog_returns_one_group() + { + using var factory = new SVSimTestFactory(); + await factory.SeedGlobalsAsync(); + long viewerId = await factory.SeedViewerAsync(); + using var client = factory.CreateAuthenticatedClient(viewerId); + + var req = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","puzzle_master_id":301}"""; + var body = await (await client.PostAsync("/basic_puzzle/open_puzzle_dialog", + new StringContent(req, Encoding.UTF8, "application/json"))).Content.ReadAsStringAsync(); + + using var doc = JsonDocument.Parse(body); + var root = doc.RootElement; + Assert.That(root.GetProperty("puzzle_quest").GetArrayLength(), Is.EqualTo(3)); + Assert.That(root.GetProperty("puzzle_quest_chara_id").GetString(), Is.EqualTo("3704")); + Assert.That(root.GetProperty("is_display_badge").GetBoolean(), Is.False); + Assert.That(root.GetProperty("is_display_puzzle_new").GetBoolean(), Is.False); + } + + [Test] + public async Task OpenPuzzleDialog_unknown_group_returns_empty_payload() + { + using var factory = new SVSimTestFactory(); + await factory.SeedGlobalsAsync(); + long viewerId = await factory.SeedViewerAsync(); + using var client = factory.CreateAuthenticatedClient(viewerId); + + var req = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","puzzle_master_id":99999}"""; + var resp = await client.PostAsync("/basic_puzzle/open_puzzle_dialog", + new StringContent(req, Encoding.UTF8, "application/json")); + + Assert.That(resp.StatusCode, Is.EqualTo(HttpStatusCode.OK)); + var root = JsonDocument.Parse(await resp.Content.ReadAsStringAsync()).RootElement; + Assert.That(root.GetProperty("puzzle_quest").GetArrayLength(), Is.EqualTo(0)); + } + + [Test] + public async Task Start_returns_empty_array() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(); + using var client = factory.CreateAuthenticatedClient(viewerId); + + var req = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","puzzle_id":1}"""; + var body = await (await client.PostAsync("/basic_puzzle/start", + new StringContent(req, Encoding.UTF8, "application/json"))).Content.ReadAsStringAsync(); + + using var doc = JsonDocument.Parse(body); + Assert.That(doc.RootElement.ValueKind, Is.EqualTo(JsonValueKind.Array)); + Assert.That(doc.RootElement.GetArrayLength(), Is.EqualTo(0)); + } + + [Test] + public async Task Mission_returns_19_entries_ordered_and_progress_tracked() + { + using var factory = new SVSimTestFactory(); + await factory.SeedGlobalsAsync(); + long viewerId = await factory.SeedViewerAsync(); + // Clear 2 of 3 puzzles in group 301 (the Round-1 mission target). + using (var scope = factory.Services.CreateScope()) + { + var clearRepo = scope.ServiceProvider.GetRequiredService(); + await clearRepo.UpsertClearAsync(viewerId, 37, 0); + await clearRepo.UpsertClearAsync(viewerId, 38, 0); + } + using var client = factory.CreateAuthenticatedClient(viewerId); + + var body = await (await client.PostAsync("/basic_puzzle/mission", + new StringContent(BaseRequestJson, Encoding.UTF8, "application/json"))).Content.ReadAsStringAsync(); + + using var doc = JsonDocument.Parse(body); + var data = doc.RootElement; + Assert.That(data.GetArrayLength(), Is.EqualTo(19)); + + var round1 = data.EnumerateArray() + .Single(m => m.GetProperty("mission_name").GetString() == "Clear all Round 1 puzzles"); + Assert.That(round1.GetProperty("total_count").GetString(), Is.EqualTo("2")); + Assert.That(round1.GetProperty("require_number").GetString(), Is.EqualTo("3")); + Assert.That(round1.GetProperty("is_achieved").GetBoolean(), Is.False); + + var special = data.EnumerateArray() + .Single(m => m.GetProperty("mission_name").GetString() == "Clear all Special Round puzzles"); + Assert.That(special.GetProperty("total_count").GetString(), Is.EqualTo("0"), + "Special-Round missions always surface as 0 in Phase 1"); + } + + [Test] + public async Task Finish_loss_is_stateless_and_returns_loss_shape() + { + using var factory = new SVSimTestFactory(); + await factory.SeedGlobalsAsync(); + long viewerId = await factory.SeedViewerAsync(); + using var client = factory.CreateAuthenticatedClient(viewerId); + + var req = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","puzzle_id":37,"retry_count":0,"is_win":false}"""; + var body = await (await client.PostAsync("/basic_puzzle/finish", + new StringContent(req, Encoding.UTF8, "application/json"))).Content.ReadAsStringAsync(); + + using var doc = JsonDocument.Parse(body); + var data = doc.RootElement; + + // Loss-specific: win_count is the NUMBER 0, not the string "1". + Assert.That(data.GetProperty("win_count").ValueKind, Is.EqualTo(JsonValueKind.Number)); + Assert.That(data.GetProperty("win_count").GetInt32(), Is.EqualTo(0)); + Assert.That(data.GetProperty("achieved_info").GetProperty("mission_start_data").GetArrayLength(), Is.EqualTo(0)); + Assert.That(data.GetProperty("reward_list").GetArrayLength(), Is.EqualTo(0)); + + // No DB writes. + using var scope = factory.Services.CreateScope(); + var ctx = scope.ServiceProvider.GetRequiredService(); + Assert.That(await ctx.ViewerPuzzleClears.CountAsync(), Is.EqualTo(0)); + } + + [Test] + public async Task Finish_win_persists_clear_and_returns_win_shape() + { + using var factory = new SVSimTestFactory(); + await factory.SeedGlobalsAsync(); + long viewerId = await factory.SeedViewerAsync(); + using var client = factory.CreateAuthenticatedClient(viewerId); + + var req = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","puzzle_id":37,"retry_count":0,"is_win":true}"""; + var body = await (await client.PostAsync("/basic_puzzle/finish", + new StringContent(req, Encoding.UTF8, "application/json"))).Content.ReadAsStringAsync(); + + using var doc = JsonDocument.Parse(body); + var data = doc.RootElement; + + // Win-specific: win_count is the STRING "1". + Assert.That(data.GetProperty("win_count").ValueKind, Is.EqualTo(JsonValueKind.String)); + Assert.That(data.GetProperty("win_count").GetString(), Is.EqualTo("1")); + + // 1/3 in group 301 → no mission completion yet. + Assert.That(data.GetProperty("achieved_info").GetProperty("achieved_mission_list").GetArrayLength(), Is.EqualTo(0)); + Assert.That(data.GetProperty("reward_list").GetArrayLength(), Is.EqualTo(0)); + + // mission_start_data still contains the un-achieved Round-1 mission. + var starts = data.GetProperty("achieved_info").GetProperty("mission_start_data"); + Assert.That(starts.EnumerateArray().Any(e => e.GetProperty("mission_name").GetString() == "Clear all Round 1 puzzles"), Is.True); + + // Clear was persisted. + using var scope = factory.Services.CreateScope(); + var ctx = scope.ServiceProvider.GetRequiredService(); + Assert.That(await ctx.ViewerPuzzleClears.AnyAsync(c => c.ViewerId == viewerId && c.PuzzleId == 37), Is.True); + } + + [Test] + public async Task Finish_completes_mission_grants_reward_and_toggles_mission_target() + { + using var factory = new SVSimTestFactory(); + await factory.SeedGlobalsAsync(); + long viewerId = await factory.SeedViewerAsync(); + + // The Round-1 mission rewards LeaderSkin 3704. SeedGlobalsAsync's leaderskins.csv may + // already include this id; insert defensively (skip if exists) so the test is + // independent of seed data shape. + using (var setup = factory.Services.CreateScope()) + { + var ctx = setup.ServiceProvider.GetRequiredService(); + if (!await ctx.LeaderSkins.AnyAsync(s => s.Id == 3704)) + { + ctx.LeaderSkins.Add(new LeaderSkinEntry { Id = 3704, Name = "Round1Reward" }); + await ctx.SaveChangesAsync(); + } + + var clearRepo = setup.ServiceProvider.GetRequiredService(); + await clearRepo.UpsertClearAsync(viewerId, 37, 0); + await clearRepo.UpsertClearAsync(viewerId, 38, 0); + } + + using var client = factory.CreateAuthenticatedClient(viewerId); + var req = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","puzzle_id":39,"retry_count":0,"is_win":true}"""; + var body = await (await client.PostAsync("/basic_puzzle/finish", + new StringContent(req, Encoding.UTF8, "application/json"))).Content.ReadAsStringAsync(); + + using var doc = JsonDocument.Parse(body); + var data = doc.RootElement; + var ai = data.GetProperty("achieved_info"); + + // Achievement banner emitted. + Assert.That(ai.GetProperty("achieved_mission_list").GetArrayLength(), Is.EqualTo(1)); + Assert.That(ai.GetProperty("achieved_mission_list")[0].GetProperty("achieved_message").GetString(), + Is.EqualTo("Cleared all Round 1 puzzles")); + + // mission_reward_* prefixed shape (NOT reward_detail_id/number). + var mrl = ai.GetProperty("achieved_mission_reward_list"); + Assert.That(mrl.GetArrayLength(), Is.EqualTo(1)); + Assert.That(mrl[0].GetProperty("mission_reward_type").GetString(), Is.EqualTo("10")); + Assert.That(mrl[0].GetProperty("mission_reward_detail_id").GetString(), Is.EqualTo("3704")); + + // Top-level reward_list mirrors as TreasureReward shape (reward_id / reward_num). + var rl = data.GetProperty("reward_list"); + Assert.That(rl.GetArrayLength(), Is.EqualTo(1)); + Assert.That(rl[0].GetProperty("reward_id").GetString(), Is.EqualTo("3704")); + Assert.That(rl[0].GetProperty("reward_num").GetString(), Is.EqualTo("1")); + + // Viewer collection updated — owns the leader skin now. + using var verify = factory.Services.CreateScope(); + var verifyCtx = verify.ServiceProvider.GetRequiredService(); + var viewer = await verifyCtx.Viewers.Include(v => v.LeaderSkins).FirstAsync(v => v.Id == viewerId); + Assert.That(viewer.LeaderSkins.Any(s => s.Id == 3704), Is.True); + + // mission_start_data no longer contains the achieved Round-1 mission. + var starts = ai.GetProperty("mission_start_data"); + Assert.That(starts.EnumerateArray().Any(e => e.GetProperty("mission_name").GetString() == "Clear all Round 1 puzzles"), + Is.False); + + // puzzle_list entry for group 301 has is_mission_target=false now. + var g301 = data.GetProperty("puzzle_list").EnumerateArray() + .Single(g => g.GetProperty("puzzle_master_id").GetString() == "301"); + Assert.That(g301.GetProperty("is_all_cleared").GetBoolean(), Is.True); + Assert.That(g301.GetProperty("is_mission_target").GetBoolean(), Is.False); + } +} diff --git a/SVSim.UnitTests/Importers/GlobalsImporterPuzzleTests.cs b/SVSim.UnitTests/Importers/GlobalsImporterPuzzleTests.cs new file mode 100644 index 0000000..0eca5e5 --- /dev/null +++ b/SVSim.UnitTests/Importers/GlobalsImporterPuzzleTests.cs @@ -0,0 +1,64 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using SVSim.Database; +using SVSim.UnitTests.Infrastructure; + +namespace SVSim.UnitTests.Importers; + +public class GlobalsImporterPuzzleTests +{ + [Test] + public async Task ImportsAllPuzzleGroupsAndPuzzles() + { + using var factory = new SVSimTestFactory(); + await factory.SeedGlobalsAsync(); + + var ctx = factory.Services.GetRequiredService(); + Assert.That(await ctx.PuzzleGroups.CountAsync(), Is.EqualTo(25), + "25 groups in the captured /basic_puzzle/info (puzzle_master_ids 1..9 plus 301..316)"); + Assert.That(await ctx.Puzzles.CountAsync(), Is.GreaterThan(100), + "~110 puzzles total across all groups"); + + // Spot-check group 301 (the Round-1 character group, contains puzzles 37/38/39). + var g301 = await ctx.PuzzleGroups.Include(g => g.Puzzles).FirstAsync(g => g.Id == 301); + Assert.That(g301.BasicTitleTextId, Is.EqualTo("Puzzle_QuestSelect_0301")); + Assert.That(g301.PuzzleCharaId, Is.EqualTo(3704)); + Assert.That(g301.Puzzles.Select(p => p.Id).OrderBy(x => x), Is.EqualTo(new[] { 37, 38, 39 })); + Assert.That(g301.DifficultyNameListJson, Does.Contain("\"Beginner\":\"0\"")); + } + + [Test] + public async Task IsIdempotent() + { + using var factory = new SVSimTestFactory(); + await factory.SeedGlobalsAsync(); + await factory.SeedGlobalsAsync(); // second run — must not duplicate + + var ctx = factory.Services.GetRequiredService(); + Assert.That(await ctx.PuzzleGroups.CountAsync(), Is.EqualTo(25)); + } + + [Test] + public async Task ImportsAllPuzzleMissionsWithRoundMapping() + { + using var factory = new SVSimTestFactory(); + await factory.SeedGlobalsAsync(); + + var ctx = factory.Services.GetRequiredService(); + Assert.That(await ctx.PuzzleMissions.CountAsync(), Is.EqualTo(19), + "19 entries in the captured /basic_puzzle/mission"); + + // "Clear all Round 1 puzzles" -> target group 301 + AchievedMessage derived. + var round1 = await ctx.PuzzleMissions.FirstAsync(m => m.MissionName == "Clear all Round 1 puzzles"); + Assert.That(round1.TargetPuzzleGroupId, Is.EqualTo(301)); + Assert.That(round1.AchievedMessage, Is.EqualTo("Cleared all Round 1 puzzles")); + Assert.That(round1.RequireNumber, Is.EqualTo(3)); + Assert.That(round1.RewardType, Is.EqualTo(10)); // LeaderSkin + Assert.That(round1.RewardDetailId, Is.EqualTo(3704L)); // chara_id matching group 301 + Assert.That(round1.RewardNumber, Is.EqualTo(1)); + + // Special-Round mission -> TargetPuzzleGroupId is null (deferred per Phase 1). + var special = await ctx.PuzzleMissions.FirstAsync(m => m.MissionName == "Clear all Special Round puzzles"); + Assert.That(special.TargetPuzzleGroupId, Is.Null); + } +} diff --git a/SVSim.UnitTests/Repositories/PuzzleCatalogRepositoryTests.cs b/SVSim.UnitTests/Repositories/PuzzleCatalogRepositoryTests.cs new file mode 100644 index 0000000..8e2f53f --- /dev/null +++ b/SVSim.UnitTests/Repositories/PuzzleCatalogRepositoryTests.cs @@ -0,0 +1,62 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using SVSim.Database; +using SVSim.Database.Repositories.Globals; +using SVSim.UnitTests.Infrastructure; + +namespace SVSim.UnitTests.Repositories; + +public class PuzzleCatalogRepositoryTests +{ + [Test] + public async Task GetAllGroupsWithPuzzles_returns_25_groups_each_with_puzzles() + { + using var factory = new SVSimTestFactory(); + await factory.SeedGlobalsAsync(); + var repo = factory.Services.GetRequiredService(); + + var groups = await repo.GetAllGroupsWithPuzzles(); + + Assert.That(groups, Has.Count.EqualTo(25)); + Assert.That(groups.All(g => g.Puzzles.Count > 0), Is.True, + "every group must have its Puzzles navigation populated"); + var g301 = groups.Single(g => g.Id == 301); + Assert.That(g301.Puzzles.Select(p => p.Id).OrderBy(x => x), Is.EqualTo(new[] { 37, 38, 39 })); + } + + [Test] + public async Task GetGroupWithPuzzles_returns_one_group_or_null() + { + using var factory = new SVSimTestFactory(); + await factory.SeedGlobalsAsync(); + var repo = factory.Services.GetRequiredService(); + + var g = await repo.GetGroupWithPuzzles(301); + Assert.That(g, Is.Not.Null); + Assert.That(g!.Puzzles, Has.Count.EqualTo(3)); + + var missing = await repo.GetGroupWithPuzzles(99999); + Assert.That(missing, Is.Null); + } + + [Test] + public async Task GetAllMissionsOrdered_returns_19_missions_in_correct_order() + { + using var factory = new SVSimTestFactory(); + await factory.SeedGlobalsAsync(); + var repo = factory.Services.GetRequiredService(); + + var missions = await repo.GetAllMissionsOrdered(); + Assert.That(missions, Has.Count.EqualTo(19)); + + // Captured order: by OrderId asc, then CampaignCommenceTime desc. + var pairs = missions.Select(m => (m.OrderId, m.CampaignCommenceTime)).ToList(); + for (int i = 1; i < pairs.Count; i++) + { + var prev = pairs[i - 1]; var cur = pairs[i]; + Assert.That(prev.OrderId, Is.LessThanOrEqualTo(cur.OrderId)); + if (prev.OrderId == cur.OrderId) + Assert.That(prev.CampaignCommenceTime, Is.GreaterThanOrEqualTo(cur.CampaignCommenceTime)); + } + } +} diff --git a/SVSim.UnitTests/Repositories/PuzzleClearRepositoryTests.cs b/SVSim.UnitTests/Repositories/PuzzleClearRepositoryTests.cs new file mode 100644 index 0000000..fc9951e --- /dev/null +++ b/SVSim.UnitTests/Repositories/PuzzleClearRepositoryTests.cs @@ -0,0 +1,50 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using SVSim.Database; +using SVSim.Database.Repositories.Viewer; +using SVSim.UnitTests.Infrastructure; + +namespace SVSim.UnitTests.Repositories; + +public class PuzzleClearRepositoryTests +{ + [Test] + public async Task UpsertClear_inserts_then_updates_idempotently() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(); + var repo = factory.Services.GetRequiredService(); + + var clearsBefore = await repo.GetClearedPuzzleIds(viewerId); + Assert.That(clearsBefore, Is.Empty); + + await repo.UpsertClearAsync(viewerId, puzzleId: 37, retryCount: 2); + await repo.UpsertClearAsync(viewerId, puzzleId: 37, retryCount: 0); // better clear; BestRetryCount should drop to 0 + await repo.UpsertClearAsync(viewerId, puzzleId: 38, retryCount: 1); + + var ids = await repo.GetClearedPuzzleIds(viewerId); + Assert.That(ids, Is.EquivalentTo(new[] { 37, 38 })); + + var ctx = factory.Services.GetRequiredService(); + var row37 = await ctx.ViewerPuzzleClears.FirstAsync(c => c.ViewerId == viewerId && c.PuzzleId == 37); + Assert.That(row37.BestRetryCount, Is.EqualTo(0), "BestRetryCount is min across all wins"); + } + + [Test] + public async Task GetClearedPuzzleIdsByGroup_groups_by_FK() + { + using var factory = new SVSimTestFactory(); + await factory.SeedGlobalsAsync(); // need Puzzles table populated for GroupId FKs + long viewerId = await factory.SeedViewerAsync(); + var repo = factory.Services.GetRequiredService(); + + await repo.UpsertClearAsync(viewerId, 37, 0); // group 301 + await repo.UpsertClearAsync(viewerId, 38, 0); // group 301 + await repo.UpsertClearAsync(viewerId, 64, 0); // group 306 + + var byGroup = await repo.GetClearedPuzzleIdsByGroup(viewerId); + Assert.That(byGroup[301], Is.EquivalentTo(new[] { 37, 38 })); + Assert.That(byGroup[306], Is.EquivalentTo(new[] { 64 })); + Assert.That(byGroup.Keys, Does.Not.Contain(999)); + } +} diff --git a/SVSim.UnitTests/Services/PuzzleMissionEvaluatorTests.cs b/SVSim.UnitTests/Services/PuzzleMissionEvaluatorTests.cs new file mode 100644 index 0000000..86d7e1d --- /dev/null +++ b/SVSim.UnitTests/Services/PuzzleMissionEvaluatorTests.cs @@ -0,0 +1,53 @@ +using SVSim.Database.Models; +using SVSim.EmulatedEntrypoint.Services; + +namespace SVSim.UnitTests.Services; + +public class PuzzleMissionEvaluatorTests +{ + private static readonly PuzzleMissionEntry Round1 = new() + { Id = 1, MissionName = "Clear all Round 1 puzzles", RequireNumber = 3, TargetPuzzleGroupId = 301 }; + private static readonly PuzzleMissionEntry SpecialAll = new() + { Id = 2, MissionName = "Clear all Special Round puzzles", RequireNumber = 8, TargetPuzzleGroupId = null }; + + private readonly PuzzleMissionEvaluator _e = new(); + + [Test] + public void Evaluate_unmapped_mission_always_zero() + { + var cleared = new Dictionary> { [316] = new() { 106, 107, 108 } }; + var result = _e.Evaluate(new[] { SpecialAll }, cleared); + + Assert.That(result.Single().TotalCount, Is.EqualTo(0)); + Assert.That(result.Single().IsAchieved, Is.False); + } + + [Test] + public void Evaluate_mapped_mission_counts_clears_in_target_group_capped() + { + var partial = new Dictionary> { [301] = new() { 37, 38 } }; + Assert.That(_e.Evaluate(new[] { Round1 }, partial).Single().TotalCount, Is.EqualTo(2)); + Assert.That(_e.Evaluate(new[] { Round1 }, partial).Single().IsAchieved, Is.False); + + var complete = new Dictionary> { [301] = new() { 37, 38, 39 } }; + Assert.That(_e.Evaluate(new[] { Round1 }, complete).Single().IsAchieved, Is.True); + + // Imagine a future where the group has more puzzles than RequireNumber — cap at RequireNumber. + var over = new Dictionary> { [301] = new() { 37, 38, 39, 999 } }; + Assert.That(_e.Evaluate(new[] { Round1 }, over).Single().TotalCount, Is.EqualTo(3)); + } + + [Test] + public void FreshlyCompleted_returns_only_missions_flipping_true() + { + var before = new Dictionary> { [301] = new() { 37, 38 } }; + var after = new Dictionary> { [301] = new() { 37, 38, 39 } }; + + var fresh = _e.FreshlyCompleted(new[] { Round1, SpecialAll }, before, after); + Assert.That(fresh, Has.Count.EqualTo(1)); + Assert.That(fresh[0].Mission.Id, Is.EqualTo(Round1.Id)); + + // Re-evaluating with same before==after returns no fresh completions. + Assert.That(_e.FreshlyCompleted(new[] { Round1, SpecialAll }, after, after), Is.Empty); + } +} diff --git a/SVSim.UnitTests/Services/RewardGrantServiceTests.cs b/SVSim.UnitTests/Services/RewardGrantServiceTests.cs new file mode 100644 index 0000000..601136f --- /dev/null +++ b/SVSim.UnitTests/Services/RewardGrantServiceTests.cs @@ -0,0 +1,99 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using SVSim.Database; +using SVSim.Database.Enums; +using SVSim.Database.Models; +using SVSim.Database.Services; +using SVSim.UnitTests.Infrastructure; + +namespace SVSim.UnitTests.Services; + +public class RewardGrantServiceTests +{ + [Test] + public async Task Sleeve_added_to_viewer_collection() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(); + using var scope = factory.Services.CreateScope(); + var ctx = scope.ServiceProvider.GetRequiredService(); + + // Pick an Id above the seeded sleeves.csv range so this test doesn't collide with the + // reference-CSV importer SVSimTestFactory runs at host construction. + const int testSleeveId = 2_000_000_000; + var sleeve = new SleeveEntry { Id = testSleeveId }; // SleeveEntry has no Name field; Id only + ctx.Sleeves.Add(sleeve); + await ctx.SaveChangesAsync(); + + var viewer = await ctx.Viewers.Include(v => v.Sleeves).FirstAsync(v => v.Id == viewerId); + var svc = scope.ServiceProvider.GetRequiredService(); + + var entry = svc.Apply(viewer, UserGoodsType.Sleeve, detailId: testSleeveId, num: 1); + await ctx.SaveChangesAsync(); + + Assert.That(viewer.Sleeves.Any(s => s.Id == testSleeveId), Is.True); + Assert.That(entry.RewardType, Is.EqualTo((int)UserGoodsType.Sleeve)); + Assert.That(entry.RewardId, Is.EqualTo((long)testSleeveId)); + Assert.That(entry.RewardNum, Is.EqualTo(1)); + } + + [Test] + public async Task Rupy_sets_currency_post_state_total() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(); + using var scope = factory.Services.CreateScope(); + var ctx = scope.ServiceProvider.GetRequiredService(); + var viewer = await ctx.Viewers.FirstAsync(v => v.Id == viewerId); + viewer.Currency.Rupees = 100UL; + await ctx.SaveChangesAsync(); + + var svc = scope.ServiceProvider.GetRequiredService(); + + // Reward grants 50; final balance becomes 150 and reward_num on the wire is the new total. + var entry = svc.Apply(viewer, UserGoodsType.Rupy, detailId: 0, num: 50); + await ctx.SaveChangesAsync(); + + Assert.That(viewer.Currency.Rupees, Is.EqualTo(150UL)); + Assert.That(entry.RewardNum, Is.EqualTo(150)); + } + + [Test] + public async Task LeaderSkin_added_idempotently() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(); + using var scope = factory.Services.CreateScope(); + var ctx = scope.ServiceProvider.GetRequiredService(); + + // Pick an Id above the seeded leaderskins.csv range so this test doesn't collide with + // the reference-CSV importer SVSimTestFactory runs at host construction. + const int testSkinId = 9_999_999; + ctx.LeaderSkins.Add(new LeaderSkinEntry { Id = testSkinId, Name = "Round1Reward" }); + await ctx.SaveChangesAsync(); + + var viewer = await ctx.Viewers.Include(v => v.LeaderSkins).FirstAsync(v => v.Id == viewerId); + var svc = scope.ServiceProvider.GetRequiredService(); + + svc.Apply(viewer, UserGoodsType.Skin, testSkinId, 1); + svc.Apply(viewer, UserGoodsType.Skin, testSkinId, 1); // second grant is a no-op on collection size + await ctx.SaveChangesAsync(); + + Assert.That(viewer.LeaderSkins.Count(s => s.Id == testSkinId), Is.EqualTo(1)); + } + + [Test] + public async Task Card_reward_throws_NotSupported() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(); + using var scope = factory.Services.CreateScope(); + var ctx = scope.ServiceProvider.GetRequiredService(); + + var viewer = await ctx.Viewers.FirstAsync(v => v.Id == viewerId); + var svc = scope.ServiceProvider.GetRequiredService(); + + Assert.Throws(() => + svc.Apply(viewer, UserGoodsType.Card, 10001001L, 1)); + } +}