diff --git a/.gitignore b/.gitignore index 73ce58dae..c4abb6fb7 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,17 @@ config/reloadalas test.py test/ + +# Cover some AI IDEs +.claude +.codex +.cursor +.trae + +# Cover some mcp tools +.codegraph + + # Created by .ignore support plugin (hsz.mobi) ### JetBrains template diff --git a/alas.py b/alas.py index 06b635c53..bd6da18f4 100644 --- a/alas.py +++ b/alas.py @@ -460,6 +460,10 @@ class AzurLaneAutoScript: from module.daemon.benchmark import run_benchmark run_benchmark(config=self.config) + def downtime_fetch(self): + from module.smart_mgmt.downtime_fetch import run_downtime_fetch + run_downtime_fetch(config=self.config) + def game_manager(self): from module.daemon.game_manager import GameManager GameManager(config=self.config, device=self.device, task="GameManager").run() diff --git a/assets/cn/ui/SETTINGS_CHECK.png b/assets/cn/ui/SETTINGS_CHECK.png new file mode 100644 index 000000000..fb8f489d5 Binary files /dev/null and b/assets/cn/ui/SETTINGS_CHECK.png differ diff --git a/campaign/campaign_main/campaign_2_base.py b/campaign/campaign_main/campaign_2_base.py index 5f1565154..ac7382866 100644 --- a/campaign/campaign_main/campaign_2_base.py +++ b/campaign/campaign_main/campaign_2_base.py @@ -3,8 +3,8 @@ from module.ui.page import page_campaign class CampaignBase(CampaignBase_): - def handle_exp_info(self): + def handle_exp_info(self, handle_fail=False): # Random background of Main Chapter 2 hits EXP_INFO_B if self.ui_page_appear(page_campaign): return False - return super().handle_exp_info() + return super().handle_exp_info(handle_fail=handle_fail) diff --git a/campaign/campaign_main/campaign_3_base.py b/campaign/campaign_main/campaign_3_base.py index 1567e3c6b..823f05f43 100644 --- a/campaign/campaign_main/campaign_3_base.py +++ b/campaign/campaign_main/campaign_3_base.py @@ -4,8 +4,8 @@ from module.ui.page import page_campaign class CampaignBase(CampaignBase_): - def handle_exp_info(self): + def handle_exp_info(self, handle_fail=False): # Random background of Main Chapter 3 hits EXP_INFO_B if self.ui_page_appear(page_campaign): return False - return super().handle_exp_info() + return super().handle_exp_info(handle_fail=handle_fail) diff --git a/campaign/event_20220224_cn/campaign_base.py b/campaign/event_20220224_cn/campaign_base.py index c874590eb..47bda4bcd 100644 --- a/campaign/event_20220224_cn/campaign_base.py +++ b/campaign/event_20220224_cn/campaign_base.py @@ -8,8 +8,8 @@ class CampaignBase(CampaignBase_): self.config.MAP_SIREN_TEMPLATE = ['SS'] self.config.MAP_HAS_SIREN = True - def handle_exp_info(self): + def handle_exp_info(self, handle_fail=False): # Random background hits EXP_INFO_B if self.ui_page_appear(page_event): return False - return super().handle_exp_info() + return super().handle_exp_info(handle_fail=handle_fail) diff --git a/campaign/event_20220428_cn/campaign_base.py b/campaign/event_20220428_cn/campaign_base.py index cf328723b..c3efeab9a 100644 --- a/campaign/event_20220428_cn/campaign_base.py +++ b/campaign/event_20220428_cn/campaign_base.py @@ -3,8 +3,8 @@ from module.ui.page import page_event class CampaignBase(CampaignBase_): - def handle_exp_info(self): + def handle_exp_info(self, handle_fail=False): # Random background of chapter A hits EXP_INFO_B if self.ui_page_appear(page_event): return False - return super().handle_exp_info() + return super().handle_exp_info(handle_fail=handle_fail) diff --git a/campaign/event_20220915_cn/campaign_base.py b/campaign/event_20220915_cn/campaign_base.py index fc5e5f21b..8e7efbe07 100644 --- a/campaign/event_20220915_cn/campaign_base.py +++ b/campaign/event_20220915_cn/campaign_base.py @@ -13,8 +13,8 @@ class CampaignBase(CampaignBase_): MAP_ENEMY_SEARCHING.color, get_color(self.device.image, MAP_ENEMY_SEARCHING.area) ) > self.MAP_ENEMY_SEARCHING_OVERLAY_TRANSPARENCY_THRESHOLD - def handle_exp_info(self): + def handle_exp_info(self, handle_fail=False): # Random background hits EXP_INFO_B if self.ui_page_appear(page_event): return False - return super().handle_exp_info() + return super().handle_exp_info(handle_fail=handle_fail) diff --git a/campaign/event_20221222_cn/campaign_base.py b/campaign/event_20221222_cn/campaign_base.py index 6e2175131..04a979e35 100644 --- a/campaign/event_20221222_cn/campaign_base.py +++ b/campaign/event_20221222_cn/campaign_base.py @@ -3,11 +3,11 @@ from module.ui.page import page_event class CampaignBase(CampaignBase_): - def handle_exp_info(self): + def handle_exp_info(self, handle_fail=False): # Random background of hits EXP_INFO_B if self.ui_page_appear(page_event): return False - return super().handle_exp_info() + return super().handle_exp_info(handle_fail=handle_fail) def map_data_init(self, map_): super().map_data_init(map_) diff --git a/campaign/event_20230914_cn/campaign_base.py b/campaign/event_20230914_cn/campaign_base.py index e1522a3f5..83f9dc494 100644 --- a/campaign/event_20230914_cn/campaign_base.py +++ b/campaign/event_20230914_cn/campaign_base.py @@ -4,8 +4,8 @@ from module.campaign.campaign_base import CampaignBase as CampaignBase_ class CampaignBase(CampaignBase_): - def handle_exp_info(self): + def handle_exp_info(self, handle_fail=False): # Random background hits EXP_INFO_B if self.ui_page_appear(page_event): return False - return super().handle_exp_info() + return super().handle_exp_info(handle_fail=handle_fail) diff --git a/campaign/event_20240815_cn/campaign_base.py b/campaign/event_20240815_cn/campaign_base.py index 415e17f00..7c5121b0c 100644 --- a/campaign/event_20240815_cn/campaign_base.py +++ b/campaign/event_20240815_cn/campaign_base.py @@ -84,8 +84,8 @@ class CampaignBase(CampaignBase_): return True return super().handle_campaign_ui_additional() - def handle_exp_info(self): + def handle_exp_info(self, handle_fail=False): # Random background hits EXP_INFO_B if self.ui_page_appear(page_event): return False - return super().handle_exp_info() + return super().handle_exp_info(handle_fail=handle_fail) diff --git a/campaign/event_20240912_cn/campaign_base.py b/campaign/event_20240912_cn/campaign_base.py index 3b0e28b0b..63eaa146a 100644 --- a/campaign/event_20240912_cn/campaign_base.py +++ b/campaign/event_20240912_cn/campaign_base.py @@ -40,8 +40,8 @@ class CampaignBase(CampaignBase_): ) return super().campaign_set_chapter_20241219(*args, **kwargs) - def handle_exp_info(self): + def handle_exp_info(self, handle_fail=False): # Random background of hits EXP_INFO_B if self.ui_page_appear(page_event): return False - return super().handle_exp_info() + return super().handle_exp_info(handle_fail=handle_fail) diff --git a/campaign/event_20250424_cn/campaign_base.py b/campaign/event_20250424_cn/campaign_base.py index c7a4d7a78..ebe10f736 100644 --- a/campaign/event_20250424_cn/campaign_base.py +++ b/campaign/event_20250424_cn/campaign_base.py @@ -5,11 +5,11 @@ from module.ui.page import page_campaign_menu, page_event class CampaignBase(CampaignBase_): - def handle_exp_info(self): + def handle_exp_info(self, handle_fail=False): # Random background of hits EXP_INFO_B if self.ui_page_appear(page_event): return False - return super().handle_exp_info() + return super().handle_exp_info(handle_fail=handle_fail) def ui_goto_event(self): if self.appear(EVENT_20250424_PT_ICON, offset=(20, 20)) and self.ui_page_appear(page_event): diff --git a/campaign/event_20250724_cn/campaign_base.py b/campaign/event_20250724_cn/campaign_base.py index ea0bf47cb..ce74ffc28 100644 --- a/campaign/event_20250724_cn/campaign_base.py +++ b/campaign/event_20250724_cn/campaign_base.py @@ -51,11 +51,11 @@ class CampaignBaseTS(CampaignBaseT): return True return super().campaign_set_chapter_20241219(chapter, stage, mode) - def handle_exp_info(self): + def handle_exp_info(self, handle_fail=False): # Extra confirm button in chapter TS if self.appear_then_click(ALCHEMIST_MATERIAL_CONFIRM, offset=(20, 20), interval=1): return False - return super().handle_exp_info() + return super().handle_exp_info(handle_fail=handle_fail) def get_map_clear_percentage(self): if AUTO_SEARCH.appear(main=self): diff --git a/campaign/event_20250912_cn/campaign_base.py b/campaign/event_20250912_cn/campaign_base.py index e1522a3f5..83f9dc494 100644 --- a/campaign/event_20250912_cn/campaign_base.py +++ b/campaign/event_20250912_cn/campaign_base.py @@ -4,8 +4,8 @@ from module.campaign.campaign_base import CampaignBase as CampaignBase_ class CampaignBase(CampaignBase_): - def handle_exp_info(self): + def handle_exp_info(self, handle_fail=False): # Random background hits EXP_INFO_B if self.ui_page_appear(page_event): return False - return super().handle_exp_info() + return super().handle_exp_info(handle_fail=handle_fail) diff --git a/campaign/war_archives_20220224_cn/campaign_base.py b/campaign/war_archives_20220224_cn/campaign_base.py index b8b32ebb9..d194dea25 100644 --- a/campaign/war_archives_20220224_cn/campaign_base.py +++ b/campaign/war_archives_20220224_cn/campaign_base.py @@ -8,8 +8,8 @@ class CampaignBase(CampaignBase_): self.config.MAP_SIREN_TEMPLATE = ['SS'] self.config.MAP_HAS_SIREN = True - def handle_exp_info(self): + def handle_exp_info(self, handle_fail=False): # Random background hits EXP_INFO_B if self.ui_page_appear(page_event): return False - return super().handle_exp_info() + return super().handle_exp_info(handle_fail=handle_fail) diff --git a/campaign/war_archives_20220428_cn/campaign_base.py b/campaign/war_archives_20220428_cn/campaign_base.py index ac57105c1..69f9caee5 100644 --- a/campaign/war_archives_20220428_cn/campaign_base.py +++ b/campaign/war_archives_20220428_cn/campaign_base.py @@ -3,8 +3,8 @@ from module.ui.page import page_event class CampaignBase(CampaignBase_): - def handle_exp_info(self): + def handle_exp_info(self, handle_fail=False): # Random background of chapter A hits EXP_INFO_B if self.ui_page_appear(page_event): return False - return super().handle_exp_info() + return super().handle_exp_info(handle_fail=handle_fail) diff --git a/campaign/war_archives_20220915_cn/campaign_base.py b/campaign/war_archives_20220915_cn/campaign_base.py index 2c904a5a8..93ca2e9ec 100644 --- a/campaign/war_archives_20220915_cn/campaign_base.py +++ b/campaign/war_archives_20220915_cn/campaign_base.py @@ -13,8 +13,8 @@ class CampaignBase(CampaignBase_): MAP_ENEMY_SEARCHING.color, get_color(self.device.image, MAP_ENEMY_SEARCHING.area) ) > self.MAP_ENEMY_SEARCHING_OVERLAY_TRANSPARENCY_THRESHOLD - def handle_exp_info(self): + def handle_exp_info(self, handle_fail=False): # Random background hits EXP_INFO_B if self.ui_page_appear(page_event): return False - return super().handle_exp_info() + return super().handle_exp_info(handle_fail=handle_fail) diff --git a/campaign/war_archives_20221222_cn/campaign_base.py b/campaign/war_archives_20221222_cn/campaign_base.py index 42e0ca3f9..487d13681 100644 --- a/campaign/war_archives_20221222_cn/campaign_base.py +++ b/campaign/war_archives_20221222_cn/campaign_base.py @@ -3,11 +3,11 @@ from module.ui.page import page_event class CampaignBase(CampaignBase_): - def handle_exp_info(self): + def handle_exp_info(self, handle_fail=False): # Random background of hits EXP_INFO_B if self.ui_page_appear(page_event): return False - return super().handle_exp_info() + return super().handle_exp_info(handle_fail=handle_fail) def map_data_init(self, map_): super().map_data_init(map_) diff --git a/config/template.json b/config/template.json index c0c6f42b2..9cc3be3be 100644 --- a/config/template.json +++ b/config/template.json @@ -136,6 +136,21 @@ "Storage": {} } }, + "DowntimeMgmt": { + "Downtime": { + "Window": null, + "Title": null + }, + "DowntimeStrategy": { + "AutoFetch": false, + "ManageExercise": false, + "ExerciseClearTime": "15:00:00", + "ManageDaily": false + }, + "Storage": { + "Storage": {} + } + }, "Restart": { "Scheduler": { "Enable": true, @@ -191,8 +206,10 @@ "Submarine": { "Fleet": 0, "AutoRecommend": false, - "Mode": "do_not_use", "AutoSearchMode": "sub_standby", + "HuntMode": "do_not_use", + "CallMode": "do_not_use", + "Timing": "default", "DistanceToBoss": "2_grid_to_boss" }, "Emotion": { @@ -269,8 +286,10 @@ "Submarine": { "Fleet": 0, "AutoRecommend": false, - "Mode": "do_not_use", "AutoSearchMode": "sub_standby", + "HuntMode": "do_not_use", + "CallMode": "do_not_use", + "Timing": "default", "DistanceToBoss": "2_grid_to_boss" }, "Emotion": { @@ -347,8 +366,10 @@ "Submarine": { "Fleet": 0, "AutoRecommend": false, - "Mode": "do_not_use", "AutoSearchMode": "sub_standby", + "HuntMode": "do_not_use", + "CallMode": "do_not_use", + "Timing": "default", "DistanceToBoss": "2_grid_to_boss" }, "Emotion": { @@ -438,8 +459,10 @@ "Submarine": { "Fleet": 0, "AutoRecommend": false, - "Mode": "do_not_use", "AutoSearchMode": "sub_standby", + "HuntMode": "do_not_use", + "CallMode": "do_not_use", + "Timing": "default", "DistanceToBoss": "2_grid_to_boss" }, "Emotion": { @@ -517,8 +540,10 @@ "Submarine": { "Fleet": 0, "AutoRecommend": false, - "Mode": "do_not_use", "AutoSearchMode": "sub_standby", + "HuntMode": "do_not_use", + "CallMode": "do_not_use", + "Timing": "default", "DistanceToBoss": "2_grid_to_boss" }, "Emotion": { @@ -595,8 +620,10 @@ "Submarine": { "Fleet": 0, "AutoRecommend": false, - "Mode": "do_not_use", "AutoSearchMode": "sub_standby", + "HuntMode": "do_not_use", + "CallMode": "do_not_use", + "Timing": "default", "DistanceToBoss": "2_grid_to_boss" }, "Emotion": { @@ -833,8 +860,10 @@ "Submarine": { "Fleet": 0, "AutoRecommend": false, - "Mode": "do_not_use", "AutoSearchMode": "sub_standby", + "HuntMode": "do_not_use", + "CallMode": "do_not_use", + "Timing": "default", "DistanceToBoss": "2_grid_to_boss" }, "Emotion": { @@ -915,8 +944,10 @@ "Submarine": { "Fleet": 0, "AutoRecommend": false, - "Mode": "do_not_use", "AutoSearchMode": "sub_standby", + "HuntMode": "do_not_use", + "CallMode": "do_not_use", + "Timing": "default", "DistanceToBoss": "2_grid_to_boss" }, "Emotion": { @@ -997,8 +1028,10 @@ "Submarine": { "Fleet": 0, "AutoRecommend": false, - "Mode": "do_not_use", "AutoSearchMode": "sub_standby", + "HuntMode": "do_not_use", + "CallMode": "do_not_use", + "Timing": "default", "DistanceToBoss": "2_grid_to_boss" }, "Emotion": { @@ -1079,8 +1112,10 @@ "Submarine": { "Fleet": 0, "AutoRecommend": false, - "Mode": "do_not_use", "AutoSearchMode": "sub_standby", + "HuntMode": "do_not_use", + "CallMode": "do_not_use", + "Timing": "default", "DistanceToBoss": "2_grid_to_boss" }, "Emotion": { @@ -1161,8 +1196,10 @@ "Submarine": { "Fleet": 0, "AutoRecommend": false, - "Mode": "do_not_use", "AutoSearchMode": "sub_standby", + "HuntMode": "do_not_use", + "CallMode": "do_not_use", + "Timing": "default", "DistanceToBoss": "2_grid_to_boss" }, "Emotion": { @@ -1239,8 +1276,10 @@ "Submarine": { "Fleet": 0, "AutoRecommend": false, - "Mode": "do_not_use", "AutoSearchMode": "sub_standby", + "HuntMode": "do_not_use", + "CallMode": "do_not_use", + "Timing": "default", "DistanceToBoss": "2_grid_to_boss" }, "Emotion": { @@ -1417,7 +1456,8 @@ "AddNewStudent": { "Enable": false, "Favorite": false, - "MinLevel": 50 + "MinLevel": 50, + "EnableNotify": false }, "Storage": { "Storage": {} @@ -2328,6 +2368,14 @@ "Storage": {} } }, + "DowntimeFetch": { + "DowntimeFetch": { + "FetcherTest": false + }, + "Storage": { + "Storage": {} + } + }, "AzurLaneUncensored": { "AzurLaneUncensored": { "Repository": "https://gitee.com/LmeSzinc/AzurLaneUncensored" diff --git a/module/combat/auto_search_combat.py b/module/combat/auto_search_combat.py index 6220b3142..5e6689e51 100644 --- a/module/combat/auto_search_combat.py +++ b/module/combat/auto_search_combat.py @@ -349,8 +349,8 @@ class AutoSearchCombat(MapOperation, Combat, CampaignStatus): self.submarine_call_reset() submarine_mode = 'do_not_use' if self.config.Submarine_Fleet: - submarine_mode = self.config.Submarine_Mode - force_call = battle[0] == battle[1] - 1 if battle is not None else False + submarine_mode = self.config.Submarine_CallMode + is_boss = battle[0] == battle[1] - 1 if battle is not None else False self.combat_auto_reset() self.combat_manual_reset() self.device.stuck_record_clear() @@ -364,7 +364,7 @@ class AutoSearchCombat(MapOperation, Combat, CampaignStatus): exp_confirm_timer = Timer(1, count=2) for _ in self.loop(): - if self.handle_submarine_call(submarine_mode, call=force_call): + if self.handle_submarine_call(call_mode=submarine_mode, is_boss=is_boss): continue if self.handle_combat_auto(auto): continue @@ -444,6 +444,13 @@ class AutoSearchCombat(MapOperation, Combat, CampaignStatus): if self.is_auto_search_running(): self._auto_search_status_confirm = False break + if self.is_combat_loading(): + logger.info('Next battle already started') + break + if self.handle_retirement(): + self.map_offensive_auto_search() + # Map offensive ends at is_combat_loading + break if self.is_in_auto_search_menu() or self._handle_auto_search_menu_missing(): raise CampaignEnd @@ -455,10 +462,10 @@ class AutoSearchCombat(MapOperation, Combat, CampaignStatus): if self.handle_get_ship(): continue if not self._sinking and self.handle_auto_search_map_option(): - self._auto_search_status_confirm = False continue # bunch of popup handlers if self.handle_popup_confirm('AUTO_SEARCH_COMBAT_STATUS'): + self._auto_search_status_confirm = True continue if self.handle_urgent_commission(): get_urgent_commission = True diff --git a/module/combat/combat.py b/module/combat/combat.py index bc89b622b..4056458da 100644 --- a/module/combat/combat.py +++ b/module/combat/combat.py @@ -354,11 +354,12 @@ class Combat(Level, HPBalancer, Retirement, SubmarineCall, CombatAuto, CombatMan return False - def combat_execute(self, auto='combat_auto', submarine='do_not_use', drop=None): + def combat_execute(self, auto='combat_auto', submarine='do_not_use', is_boss=False, drop=None): """ Args: auto (str): ['combat_auto', 'combat_manual', 'stand_still_in_the_middle', 'hide_in_bottom_left'] - submarine (str): ['do_not_use', 'hunt_only', 'every_combat'] + submarine (str): do_not_use, boss_only, every_combat + is_boss (bool): True on BOSS battle drop (DropImage): """ logger.info('Combat execute') @@ -385,7 +386,7 @@ class Combat(Level, HPBalancer, Retirement, SubmarineCall, CombatAuto, CombatMan if auto != 'combat_auto' and self.auto_mode_checked and self.is_combat_executing(): if self.handle_combat_weapon_release(): continue - if self.handle_submarine_call(submarine): + if self.handle_submarine_call(call_mode=submarine, is_boss=is_boss): continue # bunch of popup handlers if self.handle_popup_confirm('COMBAT_EXECUTE'): @@ -635,7 +636,7 @@ class Combat(Level, HPBalancer, Retirement, SubmarineCall, CombatAuto, CombatMan if self.handle_in_map_with_enemy_searching(drop=drop): break - def combat(self, balance_hp=None, emotion_reduce=None, auto_mode=None, submarine_mode=None, + def combat(self, balance_hp=None, emotion_reduce=None, auto_mode=None, submarine_mode=None, is_boss=False, save_get_items=None, expected_end=None, fleet_index=1): """ Execute a combat. @@ -645,7 +646,8 @@ class Combat(Level, HPBalancer, Retirement, SubmarineCall, CombatAuto, CombatMan balance_hp (bool): emotion_reduce (bool): auto_mode (str): combat_auto, combat_manual, stand_still_in_the_middle, hide_in_bottom_left - submarine_mode (str): do_not_use, hunt_only, every_combat + submarine_mode (str): do_not_use, boss_only, every_combat + is_boss (bool): True on BOSS battle save_get_items (bool, DropImage): expected_end (str, callable): fleet_index (int): 1 or 2 @@ -657,7 +659,7 @@ class Combat(Level, HPBalancer, Retirement, SubmarineCall, CombatAuto, CombatMan if submarine_mode is None: submarine_mode = 'do_not_use' if self.config.Submarine_Fleet: - submarine_mode = self.config.Submarine_Mode + submarine_mode = self.config.Submarine_CallMode self.battle_status_click_interval = 7 if save_get_items else 0 # if not hasattr(self, 'emotion'): @@ -673,7 +675,7 @@ class Combat(Level, HPBalancer, Retirement, SubmarineCall, CombatAuto, CombatMan self.combat_preparation( balance_hp=balance_hp, emotion_reduce=emotion_reduce, auto=auto_mode, fleet_index=fleet_index) self.combat_execute( - auto=auto_mode, submarine=submarine_mode, drop=drop) + auto=auto_mode, submarine=submarine_mode, drop=drop, is_boss=is_boss) self.combat_status( drop=drop, expected_end=expected_end) # self.handle_map_after_combat_story() diff --git a/module/combat/submarine.py b/module/combat/submarine.py index 7d1e2797f..1256db84b 100644 --- a/module/combat/submarine.py +++ b/module/combat/submarine.py @@ -1,12 +1,22 @@ from module.base.base import ModuleBase from module.base.timer import Timer +from module.base.utils import color_similar, crop, get_color, image_color_count from module.combat.assets import * from module.logger import logger +# BOSS detection areas +# Classic UI: BOSS avatar frame right border +BOSS_AVATAR_FRAME_RIGHT = (301, 15, 302, 80) +BOSS_AVATAR_FRAME_COLOR = (24, 28, 24) +# New UI: BOSS HP bar bottom border, middle-left portion +# Color targets deep blue themes; need adjustment for other color schemes +# BOSS_HP_BAR_BORDER = (420, 48, 620, 50) +# BOSS_HP_BAR_COLOR = (31, 39, 61) + class SubmarineCall(ModuleBase): submarine_call_flag = False - submarine_call_timer = Timer(5) + submarine_call_timer = Timer(8) submarine_call_click_timer = Timer(1) def submarine_call_reset(self): @@ -16,18 +26,38 @@ class SubmarineCall(ModuleBase): self.submarine_call_timer.reset() self.submarine_call_flag = False - def handle_submarine_call(self, submarine='do_not_use', call=False): + def boss_appeared(self): """ + Detect whether BOSS has appeared by checking stable BOSS UI elements. + """ + # New UI: BOSS HP bar bottom border + # avg = get_color(self.device.image, BOSS_HP_BAR_BORDER) + # if color_similar(avg, BOSS_HP_BAR_COLOR, threshold=40): + # return True + # Classic UI: BOSS avatar frame right border + frame = crop(self.device.image, BOSS_AVATAR_FRAME_RIGHT, copy=False) + if image_color_count(frame, color=BOSS_AVATAR_FRAME_COLOR, threshold=200, count=50): + return True + return False + + def handle_submarine_call(self, call_mode='do_not_use', is_boss=False): + """ + Args: + call_mode (str): do_not_use, boss_only, every_combat + is_boss (bool): True on BOSS battle + Returns: - str: If call. + bool: If clicked submarine call button. """ if self.submarine_call_flag: return False - if call and submarine == 'boss_only': - pass - else: - if submarine in ['do_not_use', 'hunt_only', 'boss_only', 'hunt_and_boss']: - self.submarine_call_flag = True + if call_mode == 'do_not_use' or (call_mode == 'boss_only' and not is_boss): + self.submarine_call_flag = True + return False + + if self.config.Submarine_Timing == 'wait_for_boss' and is_boss: + if not self.boss_appeared(): + self.submarine_call_timer.reset() return False if self.submarine_call_timer.reached(): logger.info('Submarine call timer reached') diff --git a/module/config/argument/args.json b/module/config/argument/args.json index 24352c58b..787885faf 100644 --- a/module/config/argument/args.json +++ b/module/config/argument/args.json @@ -623,6 +623,45 @@ } } }, + "DowntimeMgmt": { + "Downtime": { + "Window": { + "type": "input", + "value": "" + }, + "Title": { + "type": "input", + "value": "", + "display": "disabled" + } + }, + "DowntimeStrategy": { + "AutoFetch": { + "type": "checkbox", + "value": false + }, + "ManageExercise": { + "type": "checkbox", + "value": false + }, + "ExerciseClearTime": { + "type": "input", + "value": "15:00:00" + }, + "ManageDaily": { + "type": "checkbox", + "value": false + } + }, + "Storage": { + "Storage": { + "type": "storage", + "value": {}, + "valuetype": "ignore", + "display": "disabled" + } + } + }, "Restart": { "Scheduler": { "Enable": { @@ -897,17 +936,6 @@ "type": "checkbox", "value": false }, - "Mode": { - "type": "select", - "value": "do_not_use", - "option": [ - "do_not_use", - "hunt_only", - "boss_only", - "hunt_and_boss", - "every_combat" - ] - }, "AutoSearchMode": { "type": "select", "value": "sub_standby", @@ -916,6 +944,31 @@ "sub_auto_call" ] }, + "HuntMode": { + "type": "select", + "value": "do_not_use", + "option": [ + "do_not_use", + "hunt" + ] + }, + "CallMode": { + "type": "select", + "value": "do_not_use", + "option": [ + "do_not_use", + "boss_only", + "every_combat" + ] + }, + "Timing": { + "type": "select", + "value": "default", + "option": [ + "default", + "wait_for_boss" + ] + }, "DistanceToBoss": { "type": "select", "value": "2_grid_to_boss", @@ -1293,17 +1346,6 @@ "type": "checkbox", "value": false }, - "Mode": { - "type": "select", - "value": "do_not_use", - "option": [ - "do_not_use", - "hunt_only", - "boss_only", - "hunt_and_boss", - "every_combat" - ] - }, "AutoSearchMode": { "type": "select", "value": "sub_standby", @@ -1312,6 +1354,31 @@ "sub_auto_call" ] }, + "HuntMode": { + "type": "select", + "value": "do_not_use", + "option": [ + "do_not_use", + "hunt" + ] + }, + "CallMode": { + "type": "select", + "value": "do_not_use", + "option": [ + "do_not_use", + "boss_only", + "every_combat" + ] + }, + "Timing": { + "type": "select", + "value": "default", + "option": [ + "default", + "wait_for_boss" + ] + }, "DistanceToBoss": { "type": "select", "value": "2_grid_to_boss", @@ -1689,17 +1756,6 @@ "type": "checkbox", "value": false }, - "Mode": { - "type": "select", - "value": "do_not_use", - "option": [ - "do_not_use", - "hunt_only", - "boss_only", - "hunt_and_boss", - "every_combat" - ] - }, "AutoSearchMode": { "type": "select", "value": "sub_standby", @@ -1708,6 +1764,31 @@ "sub_auto_call" ] }, + "HuntMode": { + "type": "select", + "value": "do_not_use", + "option": [ + "do_not_use", + "hunt" + ] + }, + "CallMode": { + "type": "select", + "value": "do_not_use", + "option": [ + "do_not_use", + "boss_only", + "every_combat" + ] + }, + "Timing": { + "type": "select", + "value": "default", + "option": [ + "default", + "wait_for_boss" + ] + }, "DistanceToBoss": { "type": "select", "value": "2_grid_to_boss", @@ -2183,17 +2264,6 @@ "type": "checkbox", "value": false }, - "Mode": { - "type": "select", - "value": "do_not_use", - "option": [ - "do_not_use", - "hunt_only", - "boss_only", - "hunt_and_boss", - "every_combat" - ] - }, "AutoSearchMode": { "type": "select", "value": "sub_standby", @@ -2202,6 +2272,31 @@ "sub_auto_call" ] }, + "HuntMode": { + "type": "select", + "value": "do_not_use", + "option": [ + "do_not_use", + "hunt" + ] + }, + "CallMode": { + "type": "select", + "value": "do_not_use", + "option": [ + "do_not_use", + "boss_only", + "every_combat" + ] + }, + "Timing": { + "type": "select", + "value": "default", + "option": [ + "default", + "wait_for_boss" + ] + }, "DistanceToBoss": { "type": "select", "value": "2_grid_to_boss", @@ -2593,17 +2688,6 @@ "type": "checkbox", "value": false }, - "Mode": { - "type": "select", - "value": "do_not_use", - "option": [ - "do_not_use", - "hunt_only", - "boss_only", - "hunt_and_boss", - "every_combat" - ] - }, "AutoSearchMode": { "type": "select", "value": "sub_standby", @@ -2612,6 +2696,31 @@ "sub_auto_call" ] }, + "HuntMode": { + "type": "select", + "value": "do_not_use", + "option": [ + "do_not_use", + "hunt" + ] + }, + "CallMode": { + "type": "select", + "value": "do_not_use", + "option": [ + "do_not_use", + "boss_only", + "every_combat" + ] + }, + "Timing": { + "type": "select", + "value": "default", + "option": [ + "default", + "wait_for_boss" + ] + }, "DistanceToBoss": { "type": "select", "value": "2_grid_to_boss", @@ -3005,17 +3114,6 @@ "type": "checkbox", "value": false }, - "Mode": { - "type": "select", - "value": "do_not_use", - "option": [ - "do_not_use", - "hunt_only", - "boss_only", - "hunt_and_boss", - "every_combat" - ] - }, "AutoSearchMode": { "type": "select", "value": "sub_standby", @@ -3024,6 +3122,31 @@ "sub_auto_call" ] }, + "HuntMode": { + "type": "select", + "value": "do_not_use", + "option": [ + "do_not_use", + "hunt" + ] + }, + "CallMode": { + "type": "select", + "value": "do_not_use", + "option": [ + "do_not_use", + "boss_only", + "every_combat" + ] + }, + "Timing": { + "type": "select", + "value": "default", + "option": [ + "default", + "wait_for_boss" + ] + }, "DistanceToBoss": { "type": "select", "value": "2_grid_to_boss", @@ -4410,17 +4533,6 @@ "type": "checkbox", "value": false }, - "Mode": { - "type": "select", - "value": "do_not_use", - "option": [ - "do_not_use", - "hunt_only", - "boss_only", - "hunt_and_boss", - "every_combat" - ] - }, "AutoSearchMode": { "type": "select", "value": "sub_standby", @@ -4429,6 +4541,31 @@ "sub_auto_call" ] }, + "HuntMode": { + "type": "select", + "value": "do_not_use", + "option": [ + "do_not_use", + "hunt" + ] + }, + "CallMode": { + "type": "select", + "value": "do_not_use", + "option": [ + "do_not_use", + "boss_only", + "every_combat" + ] + }, + "Timing": { + "type": "select", + "value": "default", + "option": [ + "default", + "wait_for_boss" + ] + }, "DistanceToBoss": { "type": "select", "value": "2_grid_to_boss", @@ -4840,17 +4977,6 @@ "type": "checkbox", "value": false }, - "Mode": { - "type": "select", - "value": "do_not_use", - "option": [ - "do_not_use", - "hunt_only", - "boss_only", - "hunt_and_boss", - "every_combat" - ] - }, "AutoSearchMode": { "type": "select", "value": "sub_standby", @@ -4859,6 +4985,31 @@ "sub_auto_call" ] }, + "HuntMode": { + "type": "select", + "value": "do_not_use", + "option": [ + "do_not_use", + "hunt" + ] + }, + "CallMode": { + "type": "select", + "value": "do_not_use", + "option": [ + "do_not_use", + "boss_only", + "every_combat" + ] + }, + "Timing": { + "type": "select", + "value": "default", + "option": [ + "default", + "wait_for_boss" + ] + }, "DistanceToBoss": { "type": "select", "value": "2_grid_to_boss", @@ -5270,17 +5421,6 @@ "type": "checkbox", "value": false }, - "Mode": { - "type": "select", - "value": "do_not_use", - "option": [ - "do_not_use", - "hunt_only", - "boss_only", - "hunt_and_boss", - "every_combat" - ] - }, "AutoSearchMode": { "type": "select", "value": "sub_standby", @@ -5289,6 +5429,31 @@ "sub_auto_call" ] }, + "HuntMode": { + "type": "select", + "value": "do_not_use", + "option": [ + "do_not_use", + "hunt" + ] + }, + "CallMode": { + "type": "select", + "value": "do_not_use", + "option": [ + "do_not_use", + "boss_only", + "every_combat" + ] + }, + "Timing": { + "type": "select", + "value": "default", + "option": [ + "default", + "wait_for_boss" + ] + }, "DistanceToBoss": { "type": "select", "value": "2_grid_to_boss", @@ -5700,17 +5865,6 @@ "type": "checkbox", "value": false }, - "Mode": { - "type": "select", - "value": "do_not_use", - "option": [ - "do_not_use", - "hunt_only", - "boss_only", - "hunt_and_boss", - "every_combat" - ] - }, "AutoSearchMode": { "type": "select", "value": "sub_standby", @@ -5719,6 +5873,31 @@ "sub_auto_call" ] }, + "HuntMode": { + "type": "select", + "value": "do_not_use", + "option": [ + "do_not_use", + "hunt" + ] + }, + "CallMode": { + "type": "select", + "value": "do_not_use", + "option": [ + "do_not_use", + "boss_only", + "every_combat" + ] + }, + "Timing": { + "type": "select", + "value": "default", + "option": [ + "default", + "wait_for_boss" + ] + }, "DistanceToBoss": { "type": "select", "value": "2_grid_to_boss", @@ -6130,17 +6309,6 @@ "type": "checkbox", "value": false }, - "Mode": { - "type": "select", - "value": "do_not_use", - "option": [ - "do_not_use", - "hunt_only", - "boss_only", - "hunt_and_boss", - "every_combat" - ] - }, "AutoSearchMode": { "type": "select", "value": "sub_standby", @@ -6149,6 +6317,31 @@ "sub_auto_call" ] }, + "HuntMode": { + "type": "select", + "value": "do_not_use", + "option": [ + "do_not_use", + "hunt" + ] + }, + "CallMode": { + "type": "select", + "value": "do_not_use", + "option": [ + "do_not_use", + "boss_only", + "every_combat" + ] + }, + "Timing": { + "type": "select", + "value": "default", + "option": [ + "default", + "wait_for_boss" + ] + }, "DistanceToBoss": { "type": "select", "value": "2_grid_to_boss", @@ -6550,17 +6743,6 @@ "type": "checkbox", "value": false }, - "Mode": { - "type": "select", - "value": "do_not_use", - "option": [ - "do_not_use", - "hunt_only", - "boss_only", - "hunt_and_boss", - "every_combat" - ] - }, "AutoSearchMode": { "type": "select", "value": "sub_standby", @@ -6570,6 +6752,31 @@ ], "display": "hide" }, + "HuntMode": { + "type": "select", + "value": "do_not_use", + "option": [ + "do_not_use", + "hunt" + ] + }, + "CallMode": { + "type": "select", + "value": "do_not_use", + "option": [ + "do_not_use", + "boss_only", + "every_combat" + ] + }, + "Timing": { + "type": "select", + "value": "default", + "option": [ + "default", + "wait_for_boss" + ] + }, "DistanceToBoss": { "type": "select", "value": "2_grid_to_boss", @@ -7387,6 +7594,10 @@ "MinLevel": { "type": "input", "value": 50 + }, + "EnableNotify": { + "type": "checkbox", + "value": false } }, "Storage": { @@ -13813,6 +14024,22 @@ } } }, + "DowntimeFetch": { + "DowntimeFetch": { + "FetcherTest": { + "type": "checkbox", + "value": false + } + }, + "Storage": { + "Storage": { + "type": "storage", + "value": {}, + "valuetype": "ignore", + "display": "disabled" + } + } + }, "AzurLaneUncensored": { "AzurLaneUncensored": { "Repository": { diff --git a/module/config/argument/argument.yaml b/module/config/argument/argument.yaml index 025086a46..7cc13060c 100644 --- a/module/config/argument/argument.yaml +++ b/module/config/argument/argument.yaml @@ -152,6 +152,16 @@ OldRetire: RetireAmount: value: retire_all option: [ retire_all, retire_10 ] +Downtime: + Window: '' + Title: + value: '' + display: disabled +DowntimeStrategy: + AutoFetch: false + ManageExercise: false + ExerciseClearTime: '15:00:00' + ManageDaily: false # ==================== Farm ==================== @@ -213,12 +223,18 @@ Submarine: value: 0 option: [ 0, 1, 2 ] AutoRecommend: false - Mode: - value: do_not_use - option: [ do_not_use, hunt_only, boss_only, hunt_and_boss, every_combat ] AutoSearchMode: value: sub_standby option: [ sub_standby, sub_auto_call ] + HuntMode: + value: do_not_use + option: [ do_not_use, hunt ] + CallMode: + value: do_not_use + option: [ do_not_use, boss_only, every_combat ] + Timing: + value: default + option: [ default, wait_for_boss ] DistanceToBoss: value: '2_grid_to_boss' option: [ to_boss_position, 1_grid_to_boss, 2_grid_to_boss, use_open_ocean_support ] @@ -406,6 +422,7 @@ AddNewStudent: Enable: false Favorite: false MinLevel: 50 + EnableNotify: false Research: UseCube: value: only_05_hour @@ -875,6 +892,7 @@ PrivateQuarters: TargetShip: value: anchorage option: [ anchorage, noshiro, sirius, new_jersey, taihou, aegir, nakhimov ] + # ==================== Daily ==================== Daily: @@ -1077,6 +1095,8 @@ AzurLaneUncensored: display: disabled GameManager: AutoRestart: true +DowntimeFetch: + FetcherTest: false # ==================== Dashboard ==================== Oil: diff --git a/module/config/argument/menu.json b/module/config/argument/menu.json index b6f7a9c4b..30d9f8919 100644 --- a/module/config/argument/menu.json +++ b/module/config/argument/menu.json @@ -5,6 +5,7 @@ "tasks": [ "Alas", "General", + "DowntimeMgmt", "Restart" ] }, @@ -107,6 +108,7 @@ "BoxDisassemble", "IslandPearl", "Benchmark", + "DowntimeFetch", "AzurLaneUncensored", "GameManager" ] diff --git a/module/config/argument/task.yaml b/module/config/argument/task.yaml index 92c76dd76..43324d95c 100644 --- a/module/config/argument/task.yaml +++ b/module/config/argument/task.yaml @@ -19,6 +19,9 @@ Alas: - OneClickRetire - Enhance - OldRetire + DowntimeMgmt: + - Downtime + - DowntimeStrategy Restart: - Scheduler @@ -364,6 +367,8 @@ Tool: - IslandPearl Benchmark: - Benchmark + DowntimeFetch: + - DowntimeFetch AzurLaneUncensored: - AzurLaneUncensored GameManager: diff --git a/module/config/config_generated.py b/module/config/config_generated.py index aa6541781..89f9cd361 100644 --- a/module/config/config_generated.py +++ b/module/config/config_generated.py @@ -76,6 +76,16 @@ class GeneratedConfig: OldRetire_SSR = False OldRetire_RetireAmount = 'retire_all' # retire_all, retire_10 + # Group `Downtime` + Downtime_Window = None + Downtime_Title = None + + # Group `DowntimeStrategy` + DowntimeStrategy_AutoFetch = False + DowntimeStrategy_ManageExercise = False + DowntimeStrategy_ExerciseClearTime = '15:00:00' + DowntimeStrategy_ManageDaily = False + # Group `Campaign` Campaign_Name = '12-4' Campaign_Event = 'campaign_main' # campaign_main @@ -112,8 +122,10 @@ class GeneratedConfig: # Group `Submarine` Submarine_Fleet = 0 # 0, 1, 2 Submarine_AutoRecommend = False - Submarine_Mode = 'do_not_use' # do_not_use, hunt_only, boss_only, hunt_and_boss, every_combat Submarine_AutoSearchMode = 'sub_standby' # sub_standby, sub_auto_call + Submarine_HuntMode = 'do_not_use' # do_not_use, hunt + Submarine_CallMode = 'do_not_use' # do_not_use, boss_only, every_combat + Submarine_Timing = 'default' # default, wait_for_boss Submarine_DistanceToBoss = '2_grid_to_boss' # to_boss_position, 1_grid_to_boss, 2_grid_to_boss, use_open_ocean_support # Group `Emotion` @@ -225,6 +237,7 @@ class GeneratedConfig: AddNewStudent_Enable = False AddNewStudent_Favorite = False AddNewStudent_MinLevel = 50 + AddNewStudent_EnableNotify = False # Group `Research` Research_UseCube = 'only_05_hour' # always_use, only_05_hour, only_no_project, do_not_use @@ -679,6 +692,9 @@ class GeneratedConfig: # Group `GameManager` GameManager_AutoRestart = True + # Group `DowntimeFetch` + DowntimeFetch_FetcherTest = False + # Group `Oil` Oil_Value = 0 Oil_Limit = 0 diff --git a/module/config/config_updater.py b/module/config/config_updater.py index c4c89767f..82ff87e64 100644 --- a/module/config/config_updater.py +++ b/module/config/config_updater.py @@ -622,6 +622,18 @@ class ConfigUpdater: # ] # ] + redirection += [ + ( + f'{task}.Submarine.Mode', + (f'{task}.Submarine.HuntMode', f'{task}.Submarine.CallMode'), + submarine_mode_redirect + ) for task in [ + 'Main', 'Main2', 'Main3', 'GemsFarming', + 'Event', 'Event2', 'EventA', 'EventB', 'EventC', 'EventD', 'EventSp', 'Raid', 'RaidDaily', + 'Sos', 'WarArchives', + ] + ] + @cached_property def args(self): return read_file(filepath_args()) diff --git a/module/config/i18n/en-US.json b/module/config/i18n/en-US.json index c4303c94c..2042ba33b 100644 --- a/module/config/i18n/en-US.json +++ b/module/config/i18n/en-US.json @@ -42,6 +42,10 @@ "name": "General", "help": "" }, + "DowntimeMgmt": { + "name": "Downtime Management", + "help": "" + }, "Restart": { "name": "Restart", "help": "" @@ -282,6 +286,10 @@ "name": "Performance Test", "help": "" }, + "DowntimeFetch": { + "name": "Downtime Fetch", + "help": "" + }, "AzurLaneUncensored": { "name": "[CN] Uncensored Patch", "help": "" @@ -707,6 +715,42 @@ "retire_10": "Retire 10" } }, + "Downtime": { + "_info": { + "name": "Downtime Info", + "help": "Auto-filled when 'Auto Update Info' is enabled, updated via 'Downtime Fetch' tool, or fill manually" + }, + "Window": { + "name": "Time Window", + "help": "Format: YYYY-MM-DD HH:MM~HH:MM; non-CN server users can fill manually" + }, + "Title": { + "name": "Notice Title", + "help": "Display only" + } + }, + "DowntimeStrategy": { + "_info": { + "name": "Strategy Settings", + "help": "Auto-adjust task scheduling and strategy based on downtime info" + }, + "AutoFetch": { + "name": "Auto Update Info", + "help": "CN server only. Fetches downtime maintenance notices from Bilibili Azur Lane official columns\nWhen enabled, the above enabled tasks will automatically fetch CN notices and update 'Downtime Info' on execution" + }, + "ManageExercise": { + "name": "Exercise", + "help": "When downtime end time is later than 'Exercise clear threshold', exercise strategy before downtime maintenance on that day will be replaced with 'clear exercise attempts'" + }, + "ExerciseClearTime": { + "name": "Exercise clear threshold", + "help": "Format: HH:MM:SS" + }, + "ManageDaily": { + "name": "Daily", + "help": "On downtime maintenance day, next run will be delayed to downtime end time to handle possible Emergency Module Development" + } + }, "Campaign": { "_info": { "name": "Level Settings", @@ -1046,24 +1090,34 @@ "name": "Hard Mode Auto-Recommend", "help": "Auto-fills empty submarine fleet if using via recommend button in Hard Mode" }, - "Mode": { - "name": "Submarine Mode", - "help": "Effective only when auto search disabled. Reminder: 'Hunt and Boss' is actually a mix of 'Hunt Only' and 'Boss Only', it does hunt and summon submarines at boss if available.", - "do_not_use": "Don't Use", - "hunt_only": "Hunt Only", - "boss_only": "BOSS Only", - "hunt_and_boss": "Hunt and BOSS", - "every_combat": "Every Combat" - }, "AutoSearchMode": { "name": "Submarine Roles in Auto Search", "help": "Effective only for auto search", "sub_standby": "Standby", "sub_auto_call": "Auto Call" }, + "HuntMode": { + "name": "Hunt Mode", + "help": "Effective only when auto search is disabled, adjusts submarine hunting settings in-stage", + "do_not_use": "Standby", + "hunt": "Hunt" + }, + "CallMode": { + "name": "Call Submarine", + "help": "Only supports Classic UI. Attempts to call submarines by clicking the call button during combat", + "do_not_use": "Don't Use", + "boss_only": "BOSS Only", + "every_combat": "Every Combat" + }, + "Timing": { + "name": "BOSS Battle Call Timing", + "help": "", + "default": "Default (call immediately after entering combat)", + "wait_for_boss": "Wait For BOSS" + }, "DistanceToBoss": { "name": "Before BOSS battle move the submarine near BOSS", - "help": "Effective only if \"Submarine Mode\" is \"BOSS Only\" or \"Hunt and BOSS\"\nSelecting \"X Grids To BOSS\" needs to ensure that the submarine hunting range can cover the BOSS. Distance is calculated using the Manhattan Distance. Selecting \"Use Open Ocean Support\" requires submarine with aforementioned skill", + "help": "Effective only if \"Call Submarine\" is \"BOSS Only\"\nSelecting \"X Grids To BOSS\" needs to ensure that the submarine hunting range can cover the BOSS. Distance is calculated using the Manhattan Distance. Selecting \"Use Open Ocean Support\" requires submarine with aforementioned skill", "to_boss_position": "To BOSS Location", "1_grid_to_boss": "1 Grid To BOSS", "2_grid_to_boss": "2 Grids To BOSS", @@ -1552,6 +1606,10 @@ "MinLevel": { "name": "Add Students with Level >= X Only", "help": "" + }, + "EnableNotify": { + "name": "Notify on Empty Slot", + "help": "Send a notification when there is an empty slot in the Tactical Academy: a ship girl has finished studying and 'Level Skills' is disabled, or enabled but failed to assign a new student" } }, "Research": { @@ -6315,6 +6373,16 @@ "help": "Log back into the game when the game has been ended." } }, + "DowntimeFetch": { + "_info": { + "name": "Downtime Fetch", + "help": "CN server only. Fetches downtime maintenance notices from Bilibili Azur Lane official columns\nManually trigger to fetch the latest downtime notice and fill 'Downtime Info'" + }, + "FetcherTest": { + "name": "Fetcher Test", + "help": "Runs diagnostic tests for network, API, and content parsing; does not update config" + } + }, "Oil": { "_info": { "name": "Oil._info.name", diff --git a/module/config/i18n/ja-JP.json b/module/config/i18n/ja-JP.json index e4c0df86f..05dccca1f 100644 --- a/module/config/i18n/ja-JP.json +++ b/module/config/i18n/ja-JP.json @@ -42,6 +42,10 @@ "name": "通用設定", "help": "" }, + "DowntimeMgmt": { + "name": "メンテナンス管理", + "help": "" + }, "Restart": { "name": "再起動設定", "help": "" @@ -282,6 +286,10 @@ "name": "機能テスト", "help": "" }, + "DowntimeFetch": { + "name": "メンテナンス情報取得", + "help": "" + }, "AzurLaneUncensored": { "name": "中国サーバー規制解除", "help": "" @@ -707,6 +715,42 @@ "retire_10": "retire_10" } }, + "Downtime": { + "_info": { + "name": "メンテナンス情報", + "help": "'自動更新情報'を有効にするとタスクで自動更新、ツールの'メンテナンス情報取得'で更新、または手動入力" + }, + "Window": { + "name": "時間枠", + "help": "形式:YYYY-MM-DD HH:MM~HH:MM;国服以外のユーザーは手動入力可能" + }, + "Title": { + "name": "告知タイトル", + "help": "表示のみ" + } + }, + "DowntimeStrategy": { + "_info": { + "name": "機能設定", + "help": "メンテナンス情報に基づきタスクスケジュールと戦略を自動調整" + }, + "AutoFetch": { + "name": "自動更新情報", + "help": "国服のみ対応。bilibiliアズールレーン公式コラムからメンテナンス告知を取得\n有効にすると、上記の有効なタスク実行時に国服の告知を自動取得し'メンテナンス情報'を更新します" + }, + "ManageExercise": { + "name": "演習", + "help": "メンテナンス終了時刻が'演習クリア閾値'より遅い場合、当日のメンテナンス開始前の演習戦略が'演習回数をクリア'に置き換えられます" + }, + "ExerciseClearTime": { + "name": "演習クリア閾値", + "help": "形式:HH:MM:SS" + }, + "ManageDaily": { + "name": "デイリー", + "help": "メンテナンス当日の次回実行時間をメンテナンス終了時刻に設定し、限時兵装訓練に対応します" + } + }, "Campaign": { "_info": { "name": "Campaign._info.name", @@ -1032,42 +1076,52 @@ }, "Submarine": { "_info": { - "name": "Submarine._info.name", - "help": "Submarine._info.help" + "name": "潜水艦設定", + "help": "" }, "Fleet": { - "name": "Submarine.Fleet.name", - "help": "Submarine.Fleet.help", - "0": "0", + "name": "潜水艦艦隊番号", + "help": "", + "0": "使用しない", "1": "1", "2": "2" }, "AutoRecommend": { - "name": "Submarine.AutoRecommend.name", - "help": "Submarine.AutoRecommend.help" - }, - "Mode": { - "name": "Submarine.Mode.name", - "help": "Submarine.Mode.help", - "do_not_use": "do_not_use", - "hunt_only": "hunt_only", - "boss_only": "boss_only", - "hunt_and_boss": "hunt_and_boss", - "every_combat": "every_combat" + "name": "推薦編成", + "help": "難易度ハードモードで潜水艦艦隊を使用するが未設定の場合、推薦ボタンで自動編成します" }, "AutoSearchMode": { - "name": "Submarine.AutoSearchMode.name", - "help": "Submarine.AutoSearchMode.help", - "sub_standby": "sub_standby", - "sub_auto_call": "sub_auto_call" + "name": "潜水艦自律索敵設定", + "help": "自律索敵でのみ有効", + "sub_standby": "待機", + "sub_auto_call": "自動召喚" + }, + "HuntMode": { + "name": "狩猟モード設定", + "help": "自律索敵を使用しない場合のみ有効、ステージ内で潜水艦の狩猟設定を調整します", + "do_not_use": "待機", + "hunt": "狩猟" + }, + "CallMode": { + "name": "潜水艦出撃設定", + "help": "クラシックUIのみ対応。戦闘中にボタンをクリックして潜水艦の召喚を試みます", + "do_not_use": "使用しない", + "boss_only": "BOSS戦のみ", + "every_combat": "毎戦" + }, + "Timing": { + "name": "BOSS戦召喚タイミング", + "help": "", + "default": "デフォルト(戦闘開始直後に召喚を試みる)", + "wait_for_boss": "BOSS出現待機" }, "DistanceToBoss": { - "name": "Submarine.DistanceToBoss.name", - "help": "Submarine.DistanceToBoss.help", - "to_boss_position": "to_boss_position", - "1_grid_to_boss": "1_grid_to_boss", - "2_grid_to_boss": "2_grid_to_boss", - "use_open_ocean_support": "use_open_ocean_support" + "name": "BOSS戦前に潜水艦をBOSS付近に移動", + "help": "\"潜水艦出撃設定\"が\"BOSS戦のみ\"の場合のみ有効\n\"BOSSまでXマス\"を選択する場合、潜水艦の狩猟範囲がBOSSまで届くことを確認してください。距離はマンハッタン距離で計算されます。\"遠洋支援を使用\"を選択する場合はU522/ダ・ヴィンチなどの遠洋支援スキルを持つ艦娘が必要です", + "to_boss_position": "BOSSの位置へ", + "1_grid_to_boss": "BOSSまで1マス", + "2_grid_to_boss": "BOSSまで2マス", + "use_open_ocean_support": "遠洋支援を使用" } }, "Emotion": { @@ -1552,6 +1606,10 @@ "MinLevel": { "name": "レベルがX以上のキャラクターのみを追加してください。", "help": "" + }, + "EnableNotify": { + "name": "空席通知を送信", + "help": "戦術学院に空席がある時に通知を送信:艦娘が学習を完了し、かつ'新しいスキルを学ぶ'が無効、または有効だが新規生徒の割り当てに失敗した時" } }, "Research": { @@ -6315,6 +6373,16 @@ "help": "GameManager.AutoRestart.help" } }, + "DowntimeFetch": { + "_info": { + "name": "メンテナンス公告取得", + "help": "国服のみ対応。bilibiliアズールレーン公式コラムからメンテナンス告知を取得\n手動トリガーで最新のメンテナンス告知を取得し'メンテナンス情報'に書き込み" + }, + "FetcherTest": { + "name": "機能テスト", + "help": "ネットワーク、API、内容解析の診断テストを実行、設定は更新しない" + } + }, "Oil": { "_info": { "name": "Oil._info.name", diff --git a/module/config/i18n/zh-CN.json b/module/config/i18n/zh-CN.json index f64af61b2..c068a9837 100644 --- a/module/config/i18n/zh-CN.json +++ b/module/config/i18n/zh-CN.json @@ -42,6 +42,10 @@ "name": "通用设置", "help": "" }, + "DowntimeMgmt": { + "name": "停服维护设置", + "help": "" + }, "Restart": { "name": "重启设置", "help": "" @@ -282,6 +286,10 @@ "name": "性能测试", "help": "" }, + "DowntimeFetch": { + "name": "公告拉取", + "help": "" + }, "AzurLaneUncensored": { "name": "反和谐", "help": "" @@ -707,6 +715,42 @@ "retire_10": "退役10个" } }, + "Downtime": { + "_info": { + "name": "停服维护信息", + "help": "可在开启'自动拉取信息'后由任务自动更新,也可通过工具的'公告拉取'更新,或手动填写" + }, + "Window": { + "name": "时间窗口", + "help": "格式:YYYY-MM-DD HH:MM~HH:MM" + }, + "Title": { + "name": "公告标题", + "help": "仅作展示" + } + }, + "DowntimeStrategy": { + "_info": { + "name": "功能设置", + "help": "基于停服维护信息自动调整任务策略与调度" + }, + "AutoFetch": { + "name": "自动拉取信息", + "help": "仅支持国服,从B站碧蓝航线官方专栏拉取停服维护公告\n开启后,以下启用的任务运行时将自动拉取国服公告并更新'停服维护信息'" + }, + "ManageExercise": { + "name": "演习", + "help": "当停服结束时间晚于'演习时间阈值'时,当天停服维护前演习策略将替换为'清空演习次数'" + }, + "ExerciseClearTime": { + "name": "演习时间阈值", + "help": "格式:HH:MM:SS" + }, + "ManageDaily": { + "name": "每日任务", + "help": "停服维护当天的下次运行时间将设为维护结束时,以处理可能的限时兵装训练" + } + }, "Campaign": { "_info": { "name": "关卡设置", @@ -1046,24 +1090,34 @@ "name": "推荐编队", "help": "困难模式下,如果使用潜艇编队但未配置,会点击推荐自动编队" }, - "Mode": { - "name": "潜艇出击方案", - "help": "仅在自律寻敌关闭的情况下生效,提醒: '狩猎及BOSS战'为'仅狩猎'与'仅BOSS战'的混合,它会在道中进行狩猎打击,并在BOSS战尝试召唤潜艇。", - "do_not_use": "不使用", - "hunt_only": "仅狩猎", - "boss_only": "仅BOSS战", - "hunt_and_boss": "狩猎及BOSS战", - "every_combat": "每战出击" - }, "AutoSearchMode": { "name": "潜艇自律方案", - "help": "仅在自律寻敌下生效", + "help": "周回模式潜艇职能设置", "sub_standby": "待机", "sub_auto_call": "自动召唤潜艇" }, + "HuntMode": { + "name": "设置狩猎模式", + "help": "仅在关卡不使用自律时生效,在关卡内调整潜艇狩猎设置", + "do_not_use": "待机", + "hunt": "狩猎" + }, + "CallMode": { + "name": "潜艇出击方案", + "help": "仅支持经典UI,在战斗内通过点击潜艇按钮尝试召唤", + "do_not_use": "不使用", + "boss_only": "仅BOSS战", + "every_combat": "每场战斗" + }, + "Timing": { + "name": "BOSS战召唤时机", + "help": "仅支持经典UI", + "default": "默认(进战立即尝试召唤)", + "wait_for_boss": "等待boss出现" + }, "DistanceToBoss": { "name": "BOSS战前将潜艇移动到BOSS附近", - "help": "仅在\"潜艇出击方案\"为\"仅BOSS战\"及\"狩猎及BOSS战\",时生效\n选择\"距离BOSS X格\"需要保证潜艇狩猎范围能覆盖到BOSS,距离使用曼哈顿距离计算,选择\"使用远洋支援\"需要潜艇队伍里有U522/达芬奇", + "help": "仅在\"潜艇出击方案\"为\"仅BOSS战\"时生效\n选择\"距离BOSS X格\"需要保证潜艇狩猎范围能覆盖到BOSS,距离使用曼哈顿距离计算;选择\"使用远洋支援\"需要潜艇队伍里有具备远洋支援技能的舰娘,如U522、达芬奇等", "to_boss_position": "至 BOSS 所在位置", "1_grid_to_boss": "距离 BOSS 1 格", "2_grid_to_boss": "距离 BOSS 2 格", @@ -1552,6 +1606,10 @@ "MinLevel": { "name": "仅添加等级 >= X 的角色", "help": "" + }, + "EnableNotify": { + "name": "推送空位提醒", + "help": "在战术学院有空位时推送通知:舰娘完成学习且未启用'学习新技能',或启用了但指派失败时" } }, "Research": { @@ -6315,6 +6373,16 @@ "help": "游戏被强制结束后自动登录游戏" } }, + "DowntimeFetch": { + "_info": { + "name": "停服维护公告拉取", + "help": "仅支持国服,从B站碧蓝航线官方专栏拉取停服维护公告\n手动触发拉取最新停服维护公告并写入'停服维护信息'" + }, + "FetcherTest": { + "name": "功能测试", + "help": "运行网络、API、内容解析的诊断测试,不更新配置" + } + }, "Oil": { "_info": { "name": "Oil._info.name", diff --git a/module/config/i18n/zh-TW.json b/module/config/i18n/zh-TW.json index 24fccffbb..fc0421a73 100644 --- a/module/config/i18n/zh-TW.json +++ b/module/config/i18n/zh-TW.json @@ -42,6 +42,10 @@ "name": "通用設定", "help": "" }, + "DowntimeMgmt": { + "name": "停服維護智能管理", + "help": "" + }, "Restart": { "name": "重啟設定", "help": "" @@ -282,6 +286,10 @@ "name": "性能測試", "help": "" }, + "DowntimeFetch": { + "name": "公告取得", + "help": "" + }, "AzurLaneUncensored": { "name": "反和諧", "help": "" @@ -707,6 +715,42 @@ "retire_10": "退役10個" } }, + "Downtime": { + "_info": { + "name": "停服維護資訊", + "help": "可在開啟'自動更新資訊'後由任務自動更新,也可透過工具的'公告取得'更新,或手動填寫" + }, + "Window": { + "name": "時間窗口", + "help": "格式:YYYY-MM-DD HH:MM~HH:MM;非國服用戶可手動填寫" + }, + "Title": { + "name": "公告標題", + "help": "僅作展示" + } + }, + "DowntimeStrategy": { + "_info": { + "name": "功能設定", + "help": "基於停服維護資訊自動調整任務排程與策略" + }, + "AutoFetch": { + "name": "自動更新資訊", + "help": "僅支援國服,從B站碧藍航線官方專欄拉取停服維護公告\n開啟後,以下啟用的任務執行時將自動拉取國服公告並更新'停服維護資訊'" + }, + "ManageExercise": { + "name": "演習", + "help": "當停服結束時間晚於'演習清空閾值'時,當天停服維護前演習策略將替換為'清空演習次數'" + }, + "ExerciseClearTime": { + "name": "演習清空閾值", + "help": "格式:HH:MM:SS" + }, + "ManageDaily": { + "name": "每日任務", + "help": "停服維護當天的下次運行時間將設為維護結束時間,以處理可能的限時兵裝訓練" + } + }, "Campaign": { "_info": { "name": "地圖設定", @@ -1046,24 +1090,34 @@ "name": "推薦編隊", "help": "困難模式下,如果使用潛艦編隊但未配置,會點擊推薦自動編隊" }, - "Mode": { - "name": "潛艇出擊方案", - "help": "僅在自律尋敵關閉的情況下生效,提醒: '狩獵及BOSS戰'為'僅狩獵'與'僅BOSS戰'的混合,它會在道中進行狩獵打擊,並在BOSS戰嘗試召喚潛艇。", - "do_not_use": "不使用", - "hunt_only": "僅狩獵", - "boss_only": "僅BOSS", - "hunt_and_boss": "狩獵及BOSS戰", - "every_combat": "每戰出擊" - }, "AutoSearchMode": { "name": "潛艇自律尋敵方案", "help": "僅在自律尋敵下生效", "sub_standby": "待機", "sub_auto_call": "自動出擊" }, + "HuntMode": { + "name": "設定狩獵模式", + "help": "僅在關卡不使用自律時生效,在關卡內調整潛艇狩獵設定", + "do_not_use": "待機", + "hunt": "狩獵" + }, + "CallMode": { + "name": "潛艇出擊方案", + "help": "僅支援經典UI,在戰鬥內透過點擊按鈕以嘗試召喚潛艇", + "do_not_use": "不使用", + "boss_only": "僅BOSS戰", + "every_combat": "每場戰鬥" + }, + "Timing": { + "name": "BOSS戰召喚時機", + "help": "", + "default": "預設(進戰立即嘗試召喚)", + "wait_for_boss": "等待BOSS出現" + }, "DistanceToBoss": { "name": "BOSS戰前將潛艇移動到BOSS附近", - "help": "僅在\"潛艇出擊方案\"為\"僅BOSS戰\"及\"狩獵及BOSS戰\"時生效\n選擇\"距離BOSS X格\"需要保證潛艇狩獵範圍能覆蓋到BOSS,距離使用曼哈頓距離計算,選擇\"使用遠洋支援\"需要潛艇隊伍裡有U522或是達芬奇", + "help": "僅在\"潛艇出擊方案\"為\"僅BOSS戰\"時生效\n選擇\"距離BOSS X格\"需要保證潛艇狩獵範圍能覆蓋到BOSS,距離使用曼哈頓距離計算,選擇\"使用遠洋支援\"需要潛艇隊伍裡有U522或是達芬奇", "to_boss_position": "至 BOSS 所在位置", "1_grid_to_boss": "距離 BOSS 1 格", "2_grid_to_boss": "距離 BOSS 2 格", @@ -1552,6 +1606,10 @@ "MinLevel": { "name": "僅新增等級 >= X 的角色", "help": "" + }, + "EnableNotify": { + "name": "推送空位提醒", + "help": "在戰術學院有空位時推送通知:艦娘完成學習且未啟用'學習新技能',或啟用了但指派失敗時" } }, "Research": { @@ -6315,6 +6373,16 @@ "help": "遊戲被強制結束後自動登錄遊戲" } }, + "DowntimeFetch": { + "_info": { + "name": "停服維護公告拉取", + "help": "僅支援國服,從B站碧藍航線官方專欄拉取停服維護公告\n手動觸發拉取最新停服維護公告並寫入'停服維護資訊'" + }, + "FetcherTest": { + "name": "功能測試", + "help": "運行網路、API、內容解析的診斷測試,不更新配置" + } + }, "Oil": { "_info": { "name": "Oil._info.name", diff --git a/module/config/redirect_utils/utils.py b/module/config/redirect_utils/utils.py index 33a4f6f6e..a7eb53074 100644 --- a/module/config/redirect_utils/utils.py +++ b/module/config/redirect_utils/utils.py @@ -130,3 +130,17 @@ def coalition_to_little_academy(value): return 'hard' else: return value + + +def submarine_mode_redirect(value): + """ + Submarine.Mode -> (Submarine.HuntMode, Submarine.CallMode) + """ + mapping = { + 'do_not_use': ('do_not_use', 'do_not_use'), + 'hunt_only': ('hunt', 'do_not_use'), + 'boss_only': ('do_not_use', 'boss_only'), + 'hunt_and_boss': ('hunt', 'boss_only'), + 'every_combat': ('do_not_use', 'every_combat'), + } + return mapping.get(value, ('do_not_use', 'do_not_use')) diff --git a/module/daily/daily.py b/module/daily/daily.py index 66b2d2c2d..7e1ad0290 100644 --- a/module/daily/daily.py +++ b/module/daily/daily.py @@ -1,3 +1,5 @@ +from datetime import datetime + import numpy as np import module.config.server as server @@ -7,6 +9,7 @@ from module.combat.combat import Combat from module.daily.assets import * from module.logger import logger from module.ocr.ocr import Digit +from module.smart_mgmt.downtime_fetch import get_downtime_end from module.ui.assets import BACK_ARROW, DAILY_CHECK from module.ui.page import page_campaign_menu, page_daily @@ -335,13 +338,37 @@ class Daily(Combat): logger.info('Daily clear complete.') break + def _get_downtime_end(self): + """ + Get downtime end datetime on maintenance day. + + Returns: + datetime.datetime: End datetime if today is maintenance day and + maintenance has not ended yet, or None if smart_mgmt unavailable, + today is not maintenance day, or maintenance already ended. + """ + end_dt = get_downtime_end(self.config) + if end_dt is None: + return None + now = datetime.now() + if end_dt.date() != now.date(): + return None + if end_dt <= now: + return None + return end_dt + def run(self): """ Pages: in: Any page out: page_daily """ + # Cannot stay in page_daily, because order is disordered. self.daily_run() - # Cannot stay in page_daily, because order is disordered. - self.config.task_delay(server_update=True) + end_dt = self._get_downtime_end() + if end_dt is not None: + logger.info('Downtime today, delay to end of downtime') + self.config.task_delay(target=end_dt) + else: + self.config.task_delay(server_update=True) diff --git a/module/exercise/exercise.py b/module/exercise/exercise.py index bac6ec0e8..5ff94aa5b 100644 --- a/module/exercise/exercise.py +++ b/module/exercise/exercise.py @@ -1,11 +1,11 @@ import datetime -from module.config.utils import get_server_last_update +from module.config.utils import get_server_next_update, get_server_last_update from module.exercise.assets import * from module.exercise.combat import ExerciseCombat from module.logger import logger from module.ocr.ocr import Digit, Ocr, OcrYuv from module.ui.page import page_exercise -from module.config.utils import get_server_next_update +from module.smart_mgmt.downtime_fetch import get_downtime_end class DatedDuration(Ocr): def __init__(self, buttons, lang='cnocr', letter=(255, 255, 255), threshold=128, alphabet='0123456789:IDS天日d', @@ -171,18 +171,6 @@ class Exercise(ExerciseCombat): self.config.set_record(Exercise_OpponentRefreshValue=0) return 0 - def _get_thursday_strategy(self, remain_time): - """ - Args: - remain_time(datetime.timedelta): - - Returns: - bool: if use Thursday strategy - """ - if 96 > remain_time >= 84 or 264 > remain_time >= 252: - return True - return False - def _get_exercise_reset_remain(self): """ Returns: @@ -206,52 +194,76 @@ class Exercise(ExerciseCombat): return preserve, admiral_interval - def run(self): - self.ui_ensure(page_exercise) - server_update = self.config.Scheduler_ServerUpdate + def _get_thursday_strategy(self, remain_hours): + """ + Args: + remain_hours(int): - self.opponent_change_count = self._get_opponent_change_count() - logger.attr("Change_opponent_count", self.opponent_change_count) - logger.attr('Exercise_ExerciseStrategy', self.config.Exercise_ExerciseStrategy) - self.preserve, admiral_interval = self._get_exercise_strategy() + Returns: + bool: if use Thursday strategy + """ + if 96 > remain_hours >= 84 or 264 > remain_hours >= 252: + return True + return False - remain_time = OCR_PERIOD_REMAIN.ocr(self.device.image) - logger.info(f'Exercise period remain: {remain_time}') + def _get_downtime_strategy(self, now): + """ + Check smart_mgmt downtime window to decide whether to clear attempts. - if admiral_interval is not None and remain_time: - admiral_start, admiral_end = admiral_interval + Returns: + bool: + True if today is maintenance day with end time at or after ExerciseClearTime. + False if available but today is not maintenance day. + None if unavailable (disabled, window missing, or parse failed). + """ + end_dt = get_downtime_end(self.config) + if end_dt is None: + return None + if end_dt <= now: + return False + threshold_str = self.config.cross_get('DowntimeMgmt.DowntimeStrategy.ExerciseClearTime', '15:00:00') + threshold_time = datetime.datetime.strptime(threshold_str, '%H:%M:%S').time() + return end_dt.date() == now.date() and end_dt.time() >= threshold_time - if self._get_thursday_strategy(int(remain_time.total_seconds() // 3600)): - logger.info('Reach Thursday for admiral trial, using all attempts.') - self.preserve = 0 - forced_run = True - elif admiral_start > int(remain_time.total_seconds() // 3600) >= admiral_end: # set time for getting admiral - logger.info('Reach set time for admiral trial, using all attempts.') - self.preserve = 0 - forced_run = True - elif int(remain_time.total_seconds() // 3600) < 6: # if not set to "sun18", still depleting at sunday 18pm. - logger.info('Exercise period remain less than 6 hours, using all attempts.') - self.preserve = 0 - forced_run = True - else: - logger.info(f'Preserve {self.preserve} exercise') - forced_run = False - else: - forced_run = False + def _should_force_clear(self, now, remain_time, admiral_interval): + """Decide whether to clear all exercise attempts this run.""" + if admiral_interval is None or not remain_time: + return False - # Delay task to the configured time - if ((get_server_next_update(server_update) - datetime.datetime.now()).seconds > - 3600 * self.config.Exercise_DelayUntilHoursBeforeNextUpdate)\ - and not forced_run: - logger.warning(f'Exercise should run at {self.config.Exercise_DelayUntilHoursBeforeNextUpdate} ' - f'hours before next update. Delay task to it.') - run = False - else: - run = True + remain_hours = int(remain_time.total_seconds() // 3600) + admiral_start, admiral_end = admiral_interval - while run: + downtime_strategy = self._get_downtime_strategy(now) + if downtime_strategy is True: + logger.info('Downtime today, using all attempts before downtime') + return True + elif downtime_strategy is None: + # Fallback to thursday strategy when smart_mgmt is unavailable + if self._get_thursday_strategy(remain_hours): + logger.info('Reach Thursday, using all attempts before downtime') + return True + + if admiral_start > remain_hours >= admiral_end: + logger.info('Reach set time for admiral trial, using all attempts') + return True + if remain_hours < 6: + logger.info('Exercise period remain less than 6 hours, using all attempts') + return True + + return False + + def _should_delay(self, now, next_update, forced_clear): + """Check whether to delay exercise to the configured time before next update.""" + if forced_clear: + return False + seconds_until_update = (next_update - now).seconds + return seconds_until_update > 3600 * self.config.Exercise_DelayUntilHoursBeforeNextUpdate + + def _exercise(self, preserve): + """Consume exercise attempts until remain <= preserve or refresh exhausted.""" + while 1: self.remain = OCR_EXERCISE_REMAIN.ocr(self.device.image) - if self.remain <= self.preserve: + if self.remain <= preserve: break logger.hr(f'Exercise remain {self.remain}', level=1) @@ -263,19 +275,43 @@ class Exercise(ExerciseCombat): logger.info('New opponent exhausted') break + def run(self): + self.ui_ensure(page_exercise) + server_update = self.config.Scheduler_ServerUpdate + + self.opponent_change_count = self._get_opponent_change_count() + logger.attr("Change_opponent_count", self.opponent_change_count) + logger.attr('Exercise_ExerciseStrategy', self.config.Exercise_ExerciseStrategy) + self.preserve, admiral_interval = self._get_exercise_strategy() + + now = datetime.datetime.now() + remain_time = self._get_exercise_reset_remain() + logger.info(f'Exercise period remain: {remain_time}') + + forced_clear = self._should_force_clear(now, remain_time, admiral_interval) + if forced_clear: + self.preserve = 0 + logger.attr("exercise preserve", self.preserve) + + next_update = get_server_next_update(server_update) + should_delay = self._should_delay(now, next_update, forced_clear) + if should_delay: + logger.warning(f'Exercise should run at {self.config.Exercise_DelayUntilHoursBeforeNextUpdate} ' + f'hours before next update. Delay task to it.') + else: + self._exercise(self.preserve) + # self.equipment_take_off_when_finished() # Scheduler with self.config.multi_set(): self.config.set_record(Exercise_OpponentRefreshValue=self.opponent_change_count) if self.remain <= self.preserve or self.opponent_change_count >= 5: - next_run = get_server_next_update(server_update) \ - - datetime.timedelta(hours=self.config.Exercise_DelayUntilHoursBeforeNextUpdate) - now = datetime.datetime.now() - if next_run < now or run: + next_run = next_update - datetime.timedelta(hours=self.config.Exercise_DelayUntilHoursBeforeNextUpdate) + if next_run >= now or should_delay: + minutes_to_delay = int((next_run - now).total_seconds() / 60 + 1) + self.config.task_delay(minute=minutes_to_delay) + else: self.config.task_delay(server_update=True) - return - minutes_to_delay = int((next_run - now).total_seconds() / 60 + 1) - self.config.task_delay(minute=minutes_to_delay) else: self.config.task_delay(success=False) diff --git a/module/handler/fast_forward.py b/module/handler/fast_forward.py index 3598f7fbb..3c5777eae 100644 --- a/module/handler/fast_forward.py +++ b/module/handler/fast_forward.py @@ -306,7 +306,7 @@ class FastForwardHandler(AutoSearchHandler): @property def is_call_submarine_at_boss(self): - return self.config.SUBMARINE and self.config.Submarine_Mode in ['boss_only', 'hunt_and_boss'] + return self.config.SUBMARINE and self.config.Submarine_CallMode == 'boss_only' def handle_auto_submarine_call_disable(self): """ diff --git a/module/handler/strategy.py b/module/handler/strategy.py index e4f1c37ee..7b406f0bd 100644 --- a/module/handler/strategy.py +++ b/module/handler/strategy.py @@ -113,7 +113,7 @@ class StrategyHandler(InfoHandler): self.strategy_set_execute( formation=expected_formation, sub_view=False, - sub_hunt=bool(self.config.Submarine_Fleet) and self.config.Submarine_Mode in ['hunt_only', 'hunt_and_boss'] + sub_hunt=bool(self.config.Submarine_Fleet) and self.config.Submarine_HuntMode == 'hunt' ) self.strategy_close() self.__setattr__(f'fleet_{index}_formation_fixed', True) diff --git a/module/map/fleet.py b/module/map/fleet.py index 249f61e20..d83d8ce81 100644 --- a/module/map/fleet.py +++ b/module/map/fleet.py @@ -287,7 +287,7 @@ class Fleet(Camera, AmbushHandler): arrived = False # Wait to confirm fleet arrived. It does't appear immediately if fleet in combat. extra = 0 - if self.config.Submarine_Mode in ['hunt_only', 'hunt_and_boss']: + if self.config.Submarine_HuntMode == 'hunt': extra += 4.5 if self.config.MAP_HAS_LAND_BASED and grid.is_mechanism_trigger: extra += grid.mechanism_wait @@ -317,7 +317,7 @@ class Fleet(Camera, AmbushHandler): self.combat( expected_end=self._expected_end(expected), fleet_index=self.fleet_show_index, - submarine_mode=self._submarine_mode(expected) + is_boss='boss' in expected, ) self.hp_get() self.lv_get(after_battle=True) @@ -941,15 +941,6 @@ class Fleet(Camera, AmbushHandler): # Out of the spawn_data, nothing will spawn return 'no_searching' - def _submarine_mode(self, expected): - if self.is_call_submarine_at_boss: - if 'boss' in expected: - return 'every_combat' - else: - return 'do_not_use' - else: - return None - def fleet_at(self, grid, fleet=None): """ Args: diff --git a/module/os/map.py b/module/os/map.py index 013beaf91..18b4fe1a5 100644 --- a/module/os/map.py +++ b/module/os/map.py @@ -2,8 +2,10 @@ import time from sys import maxsize import inflection +import numpy as np from module.base.timer import Timer +from module.base.utils import area_cross_area from module.config.utils import get_os_reset_remain from module.exception import CampaignEnd, GameTooManyClickError, MapWalkError, RequestHumanTakeover, ScriptError from module.handler.login import LoginHandler, MAINTENANCE_ANNOUNCE @@ -20,6 +22,10 @@ from module.os_handler.strategic import StrategicSearchHandler from module.ui.assets import GOTO_MAIN from module.ui.page import page_os +# UI button area where clicking may trigger UI buttons instead of fleet movement +# To be replaced with an asset in the future +OS_UI_BUTTON_AREA = (1170, 455, 1280, 720) + class OSMap(OSFleet, Map, GlobeCamera, StorageHandler, StrategicSearchHandler): def os_init(self): @@ -54,7 +60,8 @@ class OSMap(OSFleet, Map, GlobeCamera, StorageHandler, StrategicSearchHandler): kwargs[key] = 0 self.config.override( Submarine_Fleet=1, - Submarine_Mode='every_combat', + Submarine_CallMode='every_combat', + Submarine_HuntMode='do_not_use', STORY_ALLOW_SKIP=False, **kwargs ) @@ -1009,11 +1016,12 @@ class OSMap(OSFleet, Map, GlobeCamera, StorageHandler, StrategicSearchHandler): self.clear_question() self.map_rescan() - def map_rescan_current(self, drop=None): + def map_rescan_current(self, drop=None, _ui_avoid_count=0): """ Args: drop: + _ui_avoid_count (int): Internal counter for UI button area avoidance attempts Returns: bool: If solved a map random event @@ -1035,6 +1043,13 @@ class OSMap(OSFleet, Map, GlobeCamera, StorageHandler, StrategicSearchHandler): logger.info(f'Found Akashi on {grid}') fleet = self.convert_radar_to_local((0, 0)) if fleet.distance_to(grid) > 1: + # Avoid clicking grid that overlaps with UI button area + if _ui_avoid_count < 3 and area_cross_area(grid.button, OS_UI_BUTTON_AREA, threshold=0): + logger.info(f'Akashi grid {grid} overlaps with UI button area, swipe camera to avoid') + vector = np.array(grid.location) - np.array(fleet.location) + vector = tuple((vector // 2).astype(int)) + self.map_swipe(vector) + return self.map_rescan_current(drop=drop, _ui_avoid_count=_ui_avoid_count + 1) self.device.click(grid) with self.config.temporary(STORY_ALLOW_SKIP=False): result = self.wait_until_walk_stable(drop=drop, walk_out_of_step=False) diff --git a/module/os_combat/combat.py b/module/os_combat/combat.py index 349518e15..4dad57634 100644 --- a/module/os_combat/combat.py +++ b/module/os_combat/combat.py @@ -255,7 +255,7 @@ class Combat(Combat_, MapEventHandler): self.device.click_record_clear() submarine_mode = 'do_not_use' if self.config.Submarine_Fleet: - submarine_mode = self.config.Submarine_Mode + submarine_mode = self.config.Submarine_CallMode success = True while 1: diff --git a/module/private_quarters/interact.py b/module/private_quarters/interact.py index ceda5851e..7e10511ff 100644 --- a/module/private_quarters/interact.py +++ b/module/private_quarters/interact.py @@ -87,7 +87,6 @@ class PQInteract(UI): self.device.drag(p1, p2, segments=2, shake=(0, 25), point_random=(0, 0, 0, 0), shake_random=(0, -5, 0, 5)) - settle_timer.reset() else: # Absence of check likely means dialogue is ongoing self._pq_handle_dialogue() diff --git a/module/raid/raid.py b/module/raid/raid.py index 401c3ccac..9fa840355 100644 --- a/module/raid/raid.py +++ b/module/raid/raid.py @@ -380,7 +380,7 @@ class Raid(MapOperation, RaidCombat, CampaignEvent): if mode == 'ex': backup = self.config.temporary( Submarine_Fleet=1, - Submarine_Mode='every_combat' + Submarine_CallMode='every_combat' ) self.emotion.check_reduce(1) diff --git a/module/shop/shop_medal.py b/module/shop/shop_medal.py index efed69541..71bba4964 100644 --- a/module/shop/shop_medal.py +++ b/module/shop/shop_medal.py @@ -46,7 +46,9 @@ MEDAL_SHOP_SCROLL_250814 = ShopAdaptiveScroll( background=3, name="MEDAL_SHOP_SCROLL_250814" ) -MEDAL_SHOP_SCROLL_250814.drag_threshold = 0.1 +# A little bit larger than edge_threshold +# to prevent continuous scrolling attempts while already at the bottom +MEDAL_SHOP_SCROLL_250814.drag_threshold = 0.13 # A little bit larger than 0.1 to handle bottom MEDAL_SHOP_SCROLL_250814.edge_threshold = 0.12 diff --git a/module/smart_mgmt/downtime_fetch.py b/module/smart_mgmt/downtime_fetch.py new file mode 100644 index 000000000..516d6525e --- /dev/null +++ b/module/smart_mgmt/downtime_fetch.py @@ -0,0 +1,83 @@ +from __future__ import annotations + +import re +from datetime import datetime +from typing import Optional + +from module.config.config import AzurLaneConfig +from module.logger import logger +from module.smart_mgmt.downtime_fetcher import DowntimeFetcher, FetchError +from module.smart_mgmt.fetcher_test import DowntimeFetcherTest + + +DOWNTIME_FORMAT_RE = re.compile( + r'(\d{4})-(\d{1,2})-(\d{1,2}) ' + r'(\d{1,2}):(\d{2})~(\d{1,2}):(\d{2})', +) + + +def parse_downtime(window: str) -> Optional[datetime]: + """ + Args: + window (str): Window string in format 'YYYY-MM-DD HH:MM~HH:MM'. + + Returns: + Optional[datetime]: + End datetime parsed from the window, or None if no pattern matched. + """ + match = DOWNTIME_FORMAT_RE.search(window) + if not match: + logger.warning(f'Failed to parse downtime window: {window}') + return None + year, month, day = int(match.group(1)), int(match.group(2)), int(match.group(3)) + end_hour, end_minute = int(match.group(6)), int(match.group(7)) + end_dt = datetime(year, month, day, end_hour, end_minute) + logger.info(f'Parsed downtime window: end={end_dt:%Y-%m-%d %H:%M}') + return end_dt + + +def update_downtime(config: AzurLaneConfig, force_refresh=False) -> bool: + """Fetch downtime and write config.""" + try: + payload = DowntimeFetcher().run(force_refresh=force_refresh) + except FetchError as e: + # Expected operational failure degrade + logger.warning(f'Failed to fetch downtime: {e}') + return False + except Exception as e: + # Unexpected programming error; full traceback for diagnosis + logger.exception(f'Unexpected error: {e}') + return False + with config.multi_set(): + config.cross_set('DowntimeMgmt.Downtime.Window', payload.get('window', '')) + config.cross_set('DowntimeMgmt.Downtime.Title', payload.get('title', '')) + return True + + +def get_downtime_end(config: AzurLaneConfig) -> Optional[datetime]: + task = config.task.command + if not config.cross_get(f'DowntimeMgmt.DowntimeStrategy.Manage{task}', False): + return None + if config.cross_get('DowntimeMgmt.DowntimeStrategy.AutoFetch', False): + if config.SERVER == 'cn': + update_downtime(config) + else: + logger.warning('Auto-fetch skipped as fetcher is for CN only, use window from config') + else: + logger.info('Auto-fetch disabled, use window from config') + window = config.cross_get('DowntimeMgmt.Downtime.Window', None) + if not window: + return None + return parse_downtime(window) + + +def run_downtime_fetch(config: AzurLaneConfig): + # Tool task entry is invoked with config bound to Alas by default, + # rebind to DowntimeFetch. + config.init_task('DowntimeFetch') + if config.DowntimeFetch_FetcherTest: + DowntimeFetcherTest().run() + else: + if config.SERVER != 'cn': + logger.warning('Downtime fetch targets CN Bilibili notices, data may be irrelevant on other servers') + update_downtime(config, force_refresh=True) diff --git a/module/smart_mgmt/downtime_fetcher.py b/module/smart_mgmt/downtime_fetcher.py new file mode 100644 index 000000000..6f8c3b99c --- /dev/null +++ b/module/smart_mgmt/downtime_fetcher.py @@ -0,0 +1,351 @@ +from __future__ import annotations + +import json +import os +import re +import time +from dataclasses import dataclass +from datetime import datetime, timezone, timedelta +from pathlib import Path +from typing import Optional + +import requests + +from deploy.atomic import atomic_read_text, atomic_write +from module.logger import logger + + +class FetchError(RuntimeError): + pass + + +@dataclass +class CollectionMeta: + collection_id: int + collection_name: str + update_time: int + +@dataclass +class Article: + pub_ts: int + title: str + summary: str + +@dataclass +class DowntimeWindow: + window: str + + +class DowntimeFetcher: + DEFAULT_MID = 233114659 + DEFAULT_COLLECTION_NAME = '《碧蓝航线》维护公告' + ARTICLE_LISTS_API = 'https://api.bilibili.com/x/article/up/lists' + ARTICLE_COLLECTION_API = 'https://api.bilibili.com/x/article/list/web/articles' + CN_TZ = timezone(timedelta(hours=8)) + REQUEST_TIMEOUT = 30.0 + + WS = r'[\s ]*' + WINDOW_RE = re.compile( + r'司令部将于' + WS + + r'(\d{1,2})月(\d{1,2})日' + WS + + r'(\d{1,2})[::](\d{2})' + WS + + r'[~~至到—\-]+' + WS + + r'(\d{1,2})[::](\d{2})', + ) + DURATION_RE = re.compile(r'为期' + WS + r'(\d+)' + WS + r'个?小时') + TITLE_TIME_RE = re.compile(r'(\d{1,2})月(\d{1,2})日' + WS + r'(\d{1,2})[::](\d{2})') + + CACHE_FILE = './log/downtime_cache.json' + FETCH_LOCK_FILE = './log/downtime_fetch.lock' + # Multi-instance duplicate fetch avoidance window (seconds) + CACHE_FRESH_SECONDS = 600 + # A bit larger than REQUEST_TIMEOUT + LOCK_WAIT_SECONDS = 35 + + def __init__(self): + self.session = self._session_for() + + def _session_for(self): + s = requests.Session() + s.headers.update({ + 'User-Agent': ( + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ' + '(KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36' + ), + 'Referer': f'https://space.bilibili.com/{self.DEFAULT_MID}/article', + }) + return s + + def _request_api(self, url, params) -> dict: + """Request Bilibili API and return inner data field (code == 0).""" + http_resp = self.session.get(url, params=params, timeout=self.REQUEST_TIMEOUT) + http_resp.raise_for_status() + body = http_resp.json() + if body['code'] != 0: + raise FetchError(body.get('message') or body.get('msg') or 'unknown error') + return body['data'] + + def find_collection(self) -> CollectionMeta: + """ + Find target collection by name from UP's article lists. + + Returns: + CollectionMeta: Metadata of the matched collection. + """ + lists = self._request_api(self.ARTICLE_LISTS_API, {'mid': str(self.DEFAULT_MID), 'sort': 0})['lists'] + for item in lists: + if item['name'] == self.DEFAULT_COLLECTION_NAME: + collection_meta = CollectionMeta( + collection_id=int(item['id']), + collection_name=item['name'], + update_time=int(item['update_time']), + ) + logger.info(f'Found collection: id={collection_meta.collection_id} ' + f'update_time={collection_meta.update_time}') + return collection_meta + raise FetchError(f'collection not found: mid={self.DEFAULT_MID} name={self.DEFAULT_COLLECTION_NAME!r}') + + @staticmethod + def _normalize_article(article) -> Article: + return Article( + pub_ts=int(article['publish_time']), + title=article['title'], + summary=article['summary'], + ) + + @staticmethod + def _clean_text(text: str): + return text.replace('[图片]', '').replace('\u3000', ' ').strip() + + def _infer_year(self, published, month, day): + year = published.year + delta = (datetime(year, month, day, tzinfo=self.CN_TZ).date() - published.date()).days + if delta < -30: + year += 1 + return year + + def _build_window_dt(self, published, month, day, sh, smin, eh, emin): + year = self._infer_year(published, month, day) + start = datetime(year, month, day, sh, smin, tzinfo=self.CN_TZ) + end = datetime(year, month, day, eh, emin, tzinfo=self.CN_TZ) + return start, end + + def parse_window_from_article(self, article: Article) -> Optional[DowntimeWindow]: + """ + Returns: + Optional[DowntimeWindow]: Parsed downtime window, or None if no pattern matched. + The window string uses '~' as time range separator to prevent config loader + from mis-parsing 'HH:MM-HH:MM' as an ISO 8601 datetime with offset. + """ + published = datetime.fromtimestamp(article.pub_ts, tz=self.CN_TZ) + summary = self._clean_text(article.summary) + window_match = self.WINDOW_RE.search(summary) + duration_match = self.DURATION_RE.search(summary) + + if window_match: + month = int(window_match.group(1)) + day = int(window_match.group(2)) + sh = int(window_match.group(3)) + smin = int(window_match.group(4)) + eh = int(window_match.group(5)) + emin = int(window_match.group(6)) + start, end = self._build_window_dt(published, month, day, sh, smin, eh, emin) + return DowntimeWindow(window=f'{start:%Y-%m-%d} {start:%H:%M}~{end:%H:%M}') + + if duration_match: + title_match = self.TITLE_TIME_RE.search(article.title) + if title_match: + month = int(title_match.group(1)) + day = int(title_match.group(2)) + sh = int(title_match.group(3)) + smin = int(title_match.group(4)) + hours = int(duration_match.group(1)) + start, _ = self._build_window_dt(published, month, day, sh, smin, sh, smin) + end = start + timedelta(hours=hours) + return DowntimeWindow(window=f'{start:%Y-%m-%d} {start:%H:%M}~{end:%H:%M}') + + return None + + @staticmethod + def _latest_article(articles: list[Article]) -> Article: + return max(articles, key=lambda a: a.pub_ts) + + def fetch_articles(self, local_update_time=None) -> tuple[Optional[list[Article]], CollectionMeta]: + """ + find_collection -> incremental check -> fetch articles + + Returns: + tuple[Optional[list[Article]], CollectionMeta]: + articles is None when collection not updated (incremental check hit). + """ + collection_meta = self.find_collection() + + if local_update_time is not None and local_update_time == collection_meta.update_time: + logger.info('Collection not updated, reuse local cache') + return None, collection_meta + + logger.info(f'Collection updated, fetching articles (id={collection_meta.collection_id})') + articles_data = self._request_api(self.ARTICLE_COLLECTION_API, {'id': str(collection_meta.collection_id)}) + articles = [self._normalize_article(article) for article in articles_data['articles']] + logger.info(f'Fetched {len(articles)} articles') + return articles, collection_meta + + def collect_info(self, local_update_time=None) -> tuple[Optional[DowntimeWindow], CollectionMeta, Optional[Article]]: + """ + Full fetch and parse pipeline. + + Returns: + tuple[Optional[DowntimeWindow], CollectionMeta, Optional[Article]]: + (downtime_window, collection_meta, latest_article). + downtime_window is None if parse failed. latest_article is None if collection not updated. + """ + articles, collection_meta = self.fetch_articles(local_update_time) + if articles is None: + return None, collection_meta, None + latest_article = self._latest_article(articles) + downtime_window = self.parse_window_from_article(latest_article) + logger.info(f'Fetched downtime: window={downtime_window.window if downtime_window else ""} ' + f'title={latest_article.title!r}') + return downtime_window, collection_meta, latest_article + + @classmethod + def load_cache(cls) -> Optional[dict]: + if not Path(cls.CACHE_FILE).exists(): + return None + text = atomic_read_text(cls.CACHE_FILE) + if not text: + return None + try: + return json.loads(text) + except json.JSONDecodeError: + logger.warning('Failed to parse downtime_cache.json, will re-fetch') + return None + + @classmethod + def save_cache(cls, payload): + Path(cls.CACHE_FILE).parent.mkdir(parents=True, exist_ok=True) + atomic_write(cls.CACHE_FILE, json.dumps(payload, ensure_ascii=False, indent=2)) + logger.info(f'Saved downtime cache to {cls.CACHE_FILE}') + + @classmethod + def is_cache_fresh(cls): + """Check if cache file was modified within CACHE_FRESH_SECONDS.""" + p = Path(cls.CACHE_FILE) + if not p.exists(): + return False + age = datetime.now().timestamp() - p.stat().st_mtime + return age < cls.CACHE_FRESH_SECONDS + + def get_downtime_info(self, force_refresh=False) -> dict: + """ + Fetch from API, parse, and save cache. + + Args: + force_refresh (bool): + Skip incremental check, always fetch from API and overwrite cache. + + Returns: + dict: payload with window, title, collection_id, update_time. + """ + cache = self.load_cache() + local_update_time = None if force_refresh else (cache.get('update_time') if cache else None) + + downtime_window, collection_meta, latest_article = self.collect_info(local_update_time) + + if latest_article is None: + if cache is None: + raise FetchError('No available cache and collection not updated') + return cache + + # Degrade on parse failure: reuse cached window, or raise if no cache + if downtime_window is None: + if cache and cache.get('window'): + logger.warning('Failed to parse, reuse cached window') + window = cache['window'] + else: + raise FetchError('Failed to parse and no cache available') + else: + window = downtime_window.window + + cache_payload = { + 'collection_id': collection_meta.collection_id, + 'update_time': collection_meta.update_time, + 'window': window, + 'title': latest_article.title, + } + self.save_cache(cache_payload) + return cache_payload + + @classmethod + def acquire_lock(cls) -> bool: + """ + Acquire cross-process fetch lock. + + Returns: + bool: True if acquired, False on timeout or OSError. + """ + p = Path(cls.FETCH_LOCK_FILE) + p.parent.mkdir(parents=True, exist_ok=True) + deadline = time.monotonic() + cls.LOCK_WAIT_SECONDS + while time.monotonic() < deadline: + if p.exists(): + try: + age = datetime.now().timestamp() - p.stat().st_mtime + if age < cls.LOCK_WAIT_SECONDS: + time.sleep(1) + continue + logger.warning(f'Stale lock assumed (age={age:.0f}s), overriding') + p.unlink() + except (OSError, FileNotFoundError): + pass + # Atomic create via O_CREAT|O_EXCL to avoid race between instances + try: + fd = os.open(str(p), os.O_CREAT | os.O_EXCL | os.O_WRONLY) + os.write(fd, str(datetime.now().timestamp()).encode()) + os.close(fd) + return True + except FileExistsError: + time.sleep(1) + except OSError as e: + logger.warning(f'Failed to acquire fetch lock: {e}') + return False + return False + + @classmethod + def release_lock(cls): + try: + Path(cls.FETCH_LOCK_FILE).unlink() + except FileNotFoundError: + pass + except OSError as e: + logger.warning(f'Failed to release fetch lock: {e}') + + def run(self, force_refresh=False) -> dict: + """ + Lock-guarded entry for the downtime pipeline. + + Fast path: fresh cache, return directly without lock (read-only). + Common path: acquire cross-process lock, then fetch. + Fallback: lock acquisition failed, reuse cache only if another process + has freshly updated it; otherwise raise FetchError. + + Returns: + dict: payload with window, title, collection_id, update_time. + """ + # Fast path: fresh cache, read-only, no lock needed + if not force_refresh: + cache = self.load_cache() + if cache and self.is_cache_fresh(): + logger.info('Cache is fresh, reuse without fetch') + return cache + if not self.acquire_lock(): + # Lock failed: reuse fresh cache if another process updated it + cache = self.load_cache() + if cache and self.is_cache_fresh(): + logger.info('Cache updated by another process, reuse') + return cache + raise FetchError('Failed to acquire fetch lock') + try: + return self.get_downtime_info(force_refresh=force_refresh) + finally: + self.release_lock() diff --git a/module/smart_mgmt/fetcher_test.py b/module/smart_mgmt/fetcher_test.py new file mode 100644 index 000000000..4a0cda5af --- /dev/null +++ b/module/smart_mgmt/fetcher_test.py @@ -0,0 +1,370 @@ +from __future__ import annotations + +import json +import socket +import ssl +import time +from dataclasses import dataclass +from datetime import datetime +from enum import Enum +from pathlib import Path +from urllib.parse import urlparse + +import requests +from rich.table import Table +from rich.text import Text + +from module.logger import logger +from module.smart_mgmt.downtime_fetcher import DowntimeFetcher + + +class _TestStatus(Enum): + PASS = 'PASS' + FAIL = 'FAIL' + SKIP = 'SKIP' + + +@dataclass +class _TestResult: + name: str + status: _TestStatus + note: str + + +class DowntimeFetcherTest: + # TCP/TLS probe timeout, shorter than HTTP request timeout for faster fail + CONNECT_TIMEOUT = 10.0 + + # Number of articles (excluding the latest) shown as title-only preview + ARTICLE_PREVIEW_COUNT = 3 + # Max summary length shown for the latest article + SUMMARY_EXCERPT_LEN = 200 + ARTICLES_JSONL_FILE = './log/downtime_articles_cache.jsonl' + + # Test names + TEST_NETWORK = 'Network' + TEST_LISTS_API = 'Lists API' + TEST_COLLECTION = 'Collection lookup' + TEST_ARTICLES_API = 'Articles API' + TEST_PARSE = 'Parse window' + + _STATUS_STYLE = { + _TestStatus.PASS: 'green', + _TestStatus.FAIL: 'bright_red', + _TestStatus.SKIP: 'yellow', + } + + def __init__(self): + self.fetcher = DowntimeFetcher() + # Abstract main-flow params into local test params: single point of + # change when fetcher internals evolve, avoids scattered coupling. + self.session = self.fetcher.session + self.mid = self.fetcher.DEFAULT_MID + self.collection_name = self.fetcher.DEFAULT_COLLECTION_NAME + self.lists_api = self.fetcher.ARTICLE_LISTS_API + self.articles_api = self.fetcher.ARTICLE_COLLECTION_API + self.request_timeout = self.fetcher.REQUEST_TIMEOUT + self.cn_tz = self.fetcher.CN_TZ + self.window_re = self.fetcher.WINDOW_RE + self.duration_re = self.fetcher.DURATION_RE + self.title_time_re = self.fetcher.TITLE_TIME_RE + # State carried across tests along the dependency chain + self._lists = [] + self._up_name = '' + self._collection_id = None + self._latest_article = None + self._results = [] + self._skip_remaining = False + + def run(self): + logger.hr('Downtime fetcher test', level=1) + self._results.clear() + self._skip_remaining = False + + tests = [ + (self.TEST_NETWORK, self.test_network), + (self.TEST_LISTS_API, self.test_lists_api), + (self.TEST_COLLECTION, self.test_find_collection), + (self.TEST_ARTICLES_API, self.test_articles_api), + (self.TEST_PARSE, self.test_parse_window), + ] + for name, func in tests: + if self._skip_remaining: + self._results.append(_TestResult(name, _TestStatus.SKIP, 'Upstream failed')) + continue + try: + func() + except Exception as e: + # Unexpected programming error: record and stop the chain so + # the summary still prints + logger.exception(f'Unexpected error in {name}') + self._results.append(_TestResult(name, _TestStatus.FAIL, f'unexpected: {type(e).__name__}: {e}')) + self._skip_remaining = True + continue + if self._results[-1].status == _TestStatus.FAIL: + self._skip_remaining = True + + self._show_summary() + + # ------------------------------------------------------------------ + # Shared helpers + # ------------------------------------------------------------------ + + def _fetch_json(self, url, params): + """GET url with fetcher session and return (body, error). + + Returns: + tuple: (parsed_body, None) on success; (None, diagnostic_str) on failure. + """ + logger.info(f'Request: {url} params={params}') + try: + resp = self.session.get(url, params=params, timeout=self.request_timeout) + logger.info(f'HTTP status: {resp.status_code}') + return resp.json(), None + except requests.RequestException as e: + logger.warning(f'Request failed: {e}') + return None, f'{type(e).__name__}: {e}' + except ValueError as e: + logger.warning(f'JSON parse failed: {e}') + return None, f'JSON: {e}' + + def _log_body_structure(self, body): + """Log top-level keys and data keys of API response body.""" + if not isinstance(body, dict): + logger.info(f'Body is {type(body).__name__}, not dict') + return + logger.info(f'Body top-level keys: {list(body.keys())}') + data = body.get('data') + if isinstance(data, dict): + logger.info(f'data keys: {list(data.keys())}') + elif data is not None: + logger.info(f'data is {type(data).__name__}, not dict') + + def _format_pub_time(self, pub_ts): + """Format publish timestamp to readable CN time string.""" + return datetime.fromtimestamp(pub_ts, tz=self.cn_tz).strftime('%Y-%m-%d %H:%M') + + def _normalize_article_for_cache(self, article): + """Convert raw API article dict to cache record format. + + Mirrors dev_tools/downtime_notice_crawler.py normalize_article output + so test caches stay consistent with crawler-produced caches. + """ + cvid = str(article['id']) + pub_ts = int(article['publish_time']) + return { + 'pub_ts': pub_ts, + 'pub_time': datetime.fromtimestamp(pub_ts, tz=self.cn_tz).isoformat(), + 'title': article['title'], + 'text': article['summary'], + 'url': f'https://www.bilibili.com/read/cv{cvid}', + 'collection_id': self._collection_id, + 'collection_name': self.collection_name, + } + + def _save_caches(self, articles): + """Persist normalized article records for diagnostic inspection. + + Args: + articles (list[dict]): Raw article list from body['data']['articles']. + """ + Path(self.ARTICLES_JSONL_FILE).parent.mkdir(parents=True, exist_ok=True) + records = [self._normalize_article_for_cache(a) for a in articles] + jsonl_path = Path(self.ARTICLES_JSONL_FILE) + jsonl_path.write_text( + ''.join(json.dumps(r, ensure_ascii=False) + '\n' for r in records), + encoding='utf-8', + ) + logger.info(f'Saved {len(records)} article records to {self.ARTICLES_JSONL_FILE}') + + def _record(self, name, status, note=''): + self._results.append(_TestResult(name, status, note)) + + # ------------------------------------------------------------------ + # Test cases + # ------------------------------------------------------------------ + + def test_network(self): + """Probe DNS/TCP/TLS to the API host to locate where connectivity breaks.""" + logger.hr('Network connectivity', level=2) + host = urlparse(self.lists_api).hostname + port = 443 + logger.info(f'Target: {host}:{port}') + + try: + t0 = time.monotonic() + ip = socket.gethostbyname(host) + dns_cost = time.monotonic() - t0 + logger.info(f'DNS resolved: {host} -> {ip} ({dns_cost:.2f}s)') + except socket.gaierror as e: + logger.warning(f'DNS resolution failed: {e}') + self._record(self.TEST_NETWORK, _TestStatus.FAIL, f'DNS: {e}') + return + + ctx = ssl.create_default_context() + sock = None + try: + t0 = time.monotonic() + sock = socket.create_connection((host, port), timeout=self.CONNECT_TIMEOUT) + tcp_cost = time.monotonic() - t0 + logger.info(f'TCP connected: {host}:{port} ({tcp_cost:.2f}s)') + + t1 = time.monotonic() + ssock = ctx.wrap_socket(sock, server_hostname=host) + tls_cost = time.monotonic() - t1 + ssock.close() + sock = None + logger.info(f'TLS handshake ok ({tls_cost:.2f}s)') + self._record( + self.TEST_NETWORK, _TestStatus.PASS, + f'DNS:{dns_cost:.2f}s TCP:{tcp_cost:.2f}s TLS:{tls_cost:.2f}s', + ) + except OSError as e: + logger.warning(f'Connection failed: {type(e).__name__}: {e}') + self._record(self.TEST_NETWORK, _TestStatus.FAIL, f'{type(e).__name__}: {e}') + finally: + if sock is not None: + try: + sock.close() + except OSError: + pass + + def test_lists_api(self): + """Verify ARTICLE_LISTS_API returns valid response with data.lists.""" + logger.hr('Lists API', level=2) + body, err = self._fetch_json(self.lists_api, {'mid': str(self.mid), 'sort': 0}) + if body is None: + self._record(self.TEST_LISTS_API, _TestStatus.FAIL, err) + return + + self._log_body_structure(body) + code = body.get('code') + message = body.get('message') or body.get('msg') or '' + logger.info(f'API response: code={code} message={message!r}') + if code != 0: + logger.warning(f'Non-zero code, full body: {body}') + self._record(self.TEST_LISTS_API, _TestStatus.FAIL, f'code={code} msg={message}') + return + + lists = (body.get('data') or {}).get('lists') + if not isinstance(lists, list): + logger.warning(f'Invalid data.lists: type={type(lists).__name__}') + self._record(self.TEST_LISTS_API, _TestStatus.FAIL, f'lists invalid: {type(lists).__name__}') + return + + self._lists = lists + logger.info(f'Got {len(lists)} collections') + self._record(self.TEST_LISTS_API, _TestStatus.PASS, f'{len(lists)} collections') + + def test_find_collection(self): + """Check whether target collection exists in UP's lists.""" + logger.hr('Collection lookup', level=2) + logger.info(f'Target collection: {self.collection_name!r}') + names = [item.get('name') for item in self._lists] + logger.info(f'Available collections: {names}') + + target = next( + (item for item in self._lists if item.get('name') == self.collection_name), + None, + ) + if target is None: + logger.warning(f'Target not found: {self.collection_name!r}') + self._record(self.TEST_COLLECTION, _TestStatus.FAIL, f'not found, available={names}') + return + + self._collection_id = int(target['id']) + logger.info(f'Found: id={self._collection_id} name={target["name"]!r}') + self._record(self.TEST_COLLECTION, _TestStatus.PASS, f'id={self._collection_id}') + + def test_articles_api(self): + """Verify ARTICLE_COLLECTION_API returns articles for the collection.""" + logger.hr('Articles API', level=2) + body, err = self._fetch_json(self.articles_api, {'id': str(self._collection_id)}) + if body is None: + self._record(self.TEST_ARTICLES_API, _TestStatus.FAIL, err) + return + + self._log_body_structure(body) + code = body.get('code') + message = body.get('message') or body.get('msg') or '' + logger.info(f'API response: code={code} message={message!r}') + if code != 0: + logger.warning(f'Non-zero code, full body: {body}') + self._record(self.TEST_ARTICLES_API, _TestStatus.FAIL, f'code={code} msg={message}') + return + + articles = (body.get('data') or {}).get('articles') + if not isinstance(articles, list) or not articles: + logger.warning(f'Empty or invalid data.articles: type={type(articles).__name__}') + self._record(self.TEST_ARTICLES_API, _TestStatus.FAIL, f'articles empty: {type(articles).__name__}') + return + + # Extract UP name from response body for display + author = (body.get('data') or {}).get('author') + if isinstance(author, dict): + up_name = author.get('name') + if up_name: + self._up_name = up_name + logger.info(f'UP: mid={self.mid} name={up_name!r}') + + logger.info(f'The latest article and recent {self.ARTICLE_PREVIEW_COUNT} articles:') + + # Latest article: title + summary excerpt + sorted_articles = sorted(articles, key=lambda a: a.get('publish_time', 0), reverse=True) + latest = sorted_articles[0] + pub_time = self._format_pub_time(latest.get('publish_time', 0)) + summary = (latest.get('summary') or '')[:self.SUMMARY_EXCERPT_LEN] + logger.info(f'Latest: [{pub_time}] {latest.get("title")} | {summary!r}') + + # Next N articles: title only + for a in sorted_articles[1:1 + self.ARTICLE_PREVIEW_COUNT]: + pub_time = self._format_pub_time(a.get('publish_time', 0)) + logger.info(f'[{pub_time}] {a.get("title")}') + + # Persist article records for diagnostic inspection + self._save_caches(articles) + + articles_normalized = [self.fetcher._normalize_article(a) for a in articles] + self._latest_article = self.fetcher._latest_article(articles_normalized) + self._record(self.TEST_ARTICLES_API, _TestStatus.PASS, f'{len(articles)} articles') + + def test_parse_window(self): + """Run regex parse against the latest article to detect format drift.""" + logger.hr('Parse window', level=2) + article = self._latest_article + body = self.fetcher._clean_text(article.summary) + + # Show individual regex match results for diagnosis + window_match = self.window_re.search(body) + duration_match = self.duration_re.search(body) + title_match = self.title_time_re.search(article.title) + logger.info(f'WINDOW_RE on summary: {"matched" if window_match else "unmatched"} ' + f'groups={window_match.groups() if window_match else None}') + logger.info(f'DURATION_RE on summary: {"matched" if duration_match else "unmatched"} ' + f'groups={duration_match.groups() if duration_match else None}') + logger.info(f'TITLE_TIME_RE on title: {"matched" if title_match else "unmatched"} ' + f'groups={title_match.groups() if title_match else None}') + + window = self.fetcher.parse_window_from_article(article) + if window is None: + logger.warning('parse_window_from_article returned None') + self._record(self.TEST_PARSE, _TestStatus.FAIL, 'no regex matched') + return + + logger.info(f'Parsed window: {window.window!r}') + self._record(self.TEST_PARSE, _TestStatus.PASS, f'window={window.window}') + + # ------------------------------------------------------------------ + # Summary + # ------------------------------------------------------------------ + + def _show_summary(self): + logger.hr('Test summary', level=1) + table = Table(show_lines=True) + table.add_column('Test', style='cyan', no_wrap=True) + table.add_column('Result') + table.add_column('Note') + for r in self._results: + style = self._STATUS_STYLE.get(r.status, 'white') + table.add_row(r.name, Text(r.status.value, style=style), r.note) + logger.print(table, justify='center') diff --git a/module/submodule/utils.py b/module/submodule/utils.py index d7a87d916..86330ba2b 100644 --- a/module/submodule/utils.py +++ b/module/submodule/utils.py @@ -23,6 +23,7 @@ def get_available_func(): 'AzurLaneUncensored', 'Benchmark', 'GameManager', + 'DowntimeFetch', ) def get_available_mod(): diff --git a/module/tactical/tactical_class.py b/module/tactical/tactical_class.py index 0e518a49a..e430e0d44 100644 --- a/module/tactical/tactical_class.py +++ b/module/tactical/tactical_class.py @@ -11,6 +11,7 @@ from module.exception import ScriptError from module.handler.assets import GET_MISSION, MISSION_POPUP_ACK, MISSION_POPUP_GO, POPUP_CANCEL, POPUP_CONFIRM from module.logger import logger from module.map.map_grids import SelectedGrids +from module.notify import handle_notify from module.ocr.ocr import DigitCounter, Duration, Ocr from module.retire.assets import DOCK_CHECK, DOCK_EMPTY, SHIP_CONFIRM from module.retire.dock import CARD_GRIDS, CARD_LEVEL_GRIDS, Dock @@ -378,6 +379,23 @@ class RewardTacticalClass(Dock): return False + def _notify_tactical_empty_slot(self, reason: str): + """ + Push notification when tactical class has empty slots. + Only the first call in a single run will actually send, later calls are skipped. + + Args: + reason (str): + """ + if getattr(self, '_tactical_notified', False): + return + handle_notify( + self.config.Error_OnePushConfig, + title=f"Alas <{self.config.config_name}> tactical slot empty", + content=f"{reason}", + ) + self._tactical_notified = True + def _tactical_get_finish(self): """ Get the future finish time. @@ -389,6 +407,10 @@ class RewardTacticalClass(Dock): is_running = [self.image_color_count(button, color=(148, 255, 99), count=50) for button in grids.buttons] logger.info(f'Tactical status: {["running" if s else "empty" for s in is_running]}') + if (self.config.AddNewStudent_EnableNotify + and not self.config.AddNewStudent_Enable and self.appear(ADD_NEW_STUDENT, offset=(800, 20))): + self._notify_tactical_empty_slot("Slot unfilled or lesson just completed, with 'Add New Student' disabled") + buttons = [b for b, s in zip(grids.buttons, is_running) if s] ocr = Duration(buttons, letter=(148, 255, 99), name='TACTICAL_REMAIN') remains = ocr.ocr(self.device.image) @@ -415,6 +437,7 @@ class RewardTacticalClass(Dock): """ logger.hr('Tactical class receive', level=1) received = False + self._tactical_notified = False study_finished = not self.config.AddNewStudent_Enable book_empty = False # tactical cards can't be loaded that fast, confirm if it's empty. @@ -520,6 +543,8 @@ class RewardTacticalClass(Dock): if self.select_suitable_ship(): pass else: + if self.config.AddNewStudent_EnableNotify: + self._notify_tactical_empty_slot('No available student to add') study_finished = True self.device.click(BACK_ARROW) else: @@ -536,6 +561,8 @@ class RewardTacticalClass(Dock): if self._tactical_skill_choose(): pass else: + if self.config.AddNewStudent_EnableNotify: + self._notify_tactical_empty_slot('No available skill to learn') study_finished = True self.device.click(BACK_ARROW) else: diff --git a/module/ui/assets.py b/module/ui/assets.py index 3e1d9a6d7..95ec08557 100644 --- a/module/ui/assets.py +++ b/module/ui/assets.py @@ -88,6 +88,7 @@ REWARD_GOTO_BATTLE_PASS = Button(area={'cn': (580, 117, 620, 155), 'en': (580, 1 REWARD_GOTO_COMMISSION = Button(area={'cn': (432, 279, 500, 296), 'en': (407, 274, 499, 291), 'jp': (431, 274, 476, 295), 'tw': (418, 271, 468, 293)}, color={'cn': (102, 205, 254), 'en': (158, 183, 220), 'jp': (153, 180, 219), 'tw': (142, 172, 216)}, button={'cn': (432, 279, 500, 296), 'en': (393, 262, 514, 303), 'jp': (393, 262, 514, 303), 'tw': (383, 262, 503, 302)}, file={'cn': './assets/cn/ui/REWARD_GOTO_COMMISSION.png', 'en': './assets/en/ui/REWARD_GOTO_COMMISSION.png', 'jp': './assets/jp/ui/REWARD_GOTO_COMMISSION.png', 'tw': './assets/tw/ui/REWARD_GOTO_COMMISSION.png'}) REWARD_GOTO_MAIN = Button(area={'cn': (219, 107, 267, 158), 'en': (219, 107, 267, 158), 'jp': (219, 107, 267, 158), 'tw': (219, 107, 267, 158)}, color={'cn': (143, 116, 123), 'en': (143, 116, 123), 'jp': (143, 116, 123), 'tw': (143, 116, 123)}, button={'cn': (787, 602, 867, 642), 'en': (787, 602, 867, 642), 'jp': (787, 602, 867, 642), 'tw': (787, 602, 867, 642)}, file={'cn': './assets/cn/ui/REWARD_GOTO_MAIN.png', 'en': './assets/en/ui/REWARD_GOTO_MAIN.png', 'jp': './assets/jp/ui/REWARD_GOTO_MAIN.png', 'tw': './assets/tw/ui/REWARD_GOTO_MAIN.png'}) REWARD_GOTO_TACTICAL = Button(area={'cn': (435, 420, 498, 448), 'en': (407, 416, 499, 433), 'jp': (431, 416, 476, 437), 'tw': (418, 413, 468, 435)}, color={'cn': (101, 204, 254), 'en': (157, 185, 219), 'jp': (151, 182, 217), 'tw': (141, 175, 215)}, button={'cn': (435, 420, 498, 448), 'en': (393, 404, 514, 445), 'jp': (393, 404, 514, 445), 'tw': (383, 404, 503, 444)}, file={'cn': './assets/cn/ui/REWARD_GOTO_TACTICAL.png', 'en': './assets/en/ui/REWARD_GOTO_TACTICAL.png', 'jp': './assets/jp/ui/REWARD_GOTO_TACTICAL.png', 'tw': './assets/tw/ui/REWARD_GOTO_TACTICAL.png'}) +SETTINGS_CHECK = Button(area={'cn': (31, 132, 70, 172), 'en': (31, 132, 70, 172), 'jp': (31, 132, 70, 172), 'tw': (31, 132, 70, 172)}, color={'cn': (95, 106, 119), 'en': (95, 106, 119), 'jp': (95, 106, 119), 'tw': (95, 106, 119)}, button={'cn': (31, 132, 70, 172), 'en': (31, 132, 70, 172), 'jp': (31, 132, 70, 172), 'tw': (31, 132, 70, 172)}, file={'cn': './assets/cn/ui/SETTINGS_CHECK.png', 'en': './assets/cn/ui/SETTINGS_CHECK.png', 'jp': './assets/cn/ui/SETTINGS_CHECK.png', 'tw': './assets/cn/ui/SETTINGS_CHECK.png'}) SHIPYARD_CHECK = Button(area={'cn': (9, 131, 82, 148), 'en': (4, 126, 52, 141), 'jp': (8, 130, 82, 148), 'tw': (7, 130, 83, 150)}, color={'cn': (159, 180, 229), 'en': (133, 148, 171), 'jp': (152, 169, 202), 'tw': (142, 164, 219)}, button={'cn': (9, 131, 82, 148), 'en': (4, 126, 52, 141), 'jp': (8, 130, 82, 148), 'tw': (7, 130, 83, 150)}, file={'cn': './assets/cn/ui/SHIPYARD_CHECK.png', 'en': './assets/en/ui/SHIPYARD_CHECK.png', 'jp': './assets/jp/ui/SHIPYARD_CHECK.png', 'tw': './assets/tw/ui/SHIPYARD_CHECK.png'}) SHOP_BACK_ARROW = Button(area={'cn': (43, 35, 57, 56), 'en': (43, 35, 57, 56), 'jp': (43, 35, 57, 56), 'tw': (43, 35, 57, 56)}, color={'cn': (191, 191, 191), 'en': (191, 191, 191), 'jp': (191, 191, 191), 'tw': (191, 191, 191)}, button={'cn': (43, 35, 57, 56), 'en': (43, 35, 57, 56), 'jp': (43, 35, 57, 56), 'tw': (43, 35, 57, 56)}, file={'cn': './assets/cn/ui/SHOP_BACK_ARROW.png', 'en': './assets/cn/ui/SHOP_BACK_ARROW.png', 'jp': './assets/jp/ui/SHOP_BACK_ARROW.png', 'tw': './assets/tw/ui/SHOP_BACK_ARROW.png'}) SHOP_CHECK = Button(area={'cn': (149, 23, 192, 44), 'en': (143, 25, 199, 41), 'jp': (127, 22, 217, 42), 'tw': (149, 23, 192, 44)}, color={'cn': (107, 114, 125), 'en': (150, 155, 163), 'jp': (64, 72, 87), 'tw': (107, 114, 125)}, button={'cn': (149, 23, 192, 44), 'en': (143, 25, 199, 41), 'jp': (127, 22, 217, 42), 'tw': (149, 23, 192, 44)}, file={'cn': './assets/cn/ui/SHOP_CHECK.png', 'en': './assets/en/ui/SHOP_CHECK.png', 'jp': './assets/jp/ui/SHOP_CHECK.png', 'tw': './assets/tw/ui/SHOP_CHECK.png'}) diff --git a/module/ui/page.py b/module/ui/page.py index a22e334df..14d03bc12 100644 --- a/module/ui/page.py +++ b/module/ui/page.py @@ -102,6 +102,10 @@ page_main_white.link(button=MAIN_GOTO_FLEET_WHITE, destination=page_fleet) page_unknown = Page(None) page_unknown.link(button=GOTO_MAIN, destination=page_main) +# Settings +page_settings = Page(SETTINGS_CHECK) +page_settings.link(button=BACK_ARROW, destination=page_main) + # Exercise # Don't enter page_exercise from page_campaign page_exercise = Page(EXERCISE_CHECK) diff --git a/module/webui/updater.py b/module/webui/updater.py index 91a330f68..ddc88832b 100644 --- a/module/webui/updater.py +++ b/module/webui/updater.py @@ -56,7 +56,9 @@ class Updater(DeployConfig, GitManager, PipManager): ) if not log: - return None, None, None, None + if n == 1: + return None, None, None, None + return [] logs = log.split("\n") logs = list(map(lambda log: tuple(log.split("---")), logs))