diff --git a/.github/workflows/git-over-cdn-123pan.yml b/.github/workflows/git-over-cdn-123pan.yml index 1a32202a3..557ee4c90 100644 --- a/.github/workflows/git-over-cdn-123pan.yml +++ b/.github/workflows/git-over-cdn-123pan.yml @@ -15,9 +15,7 @@ on: description: "Remote folder name under parent folder" required: false default: "AzurPilot_master" - push: - branches: - - master + # 123Pan 需要单独的 ClientID/ClientSecret,未配置时不参与日常自动发布。 jobs: build-and-upload: diff --git a/.github/workflows/git-over-cdn-pages.yml b/.github/workflows/git-over-cdn-pages.yml index d8b7488c9..a003fd0fa 100644 --- a/.github/workflows/git-over-cdn-pages.yml +++ b/.github/workflows/git-over-cdn-pages.yml @@ -45,7 +45,35 @@ concurrency: cancel-in-progress: true jobs: + check-cloudflare-config: + runs-on: ubuntu-latest + permissions: {} + outputs: + configured: ${{ steps.detect.outputs.configured }} + + steps: + - name: Detect publish credentials + id: detect + env: + ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + WORKER_NAME: ${{ secrets.CF_WORKER_NAME }} + run: | + set -euo pipefail + if [ "$GITHUB_EVENT_NAME" = "workflow_dispatch" ] || { + [ -n "$ACCOUNT_ID" ] && + [ -n "$API_TOKEN" ] && + [ -n "$WORKER_NAME" ]; + }; then + echo "configured=true" >> "$GITHUB_OUTPUT" + else + echo "configured=false" >> "$GITHUB_OUTPUT" + echo "Cloudflare Secrets 未配置,跳过本次自动发布。" + fi + build-upload-release: + needs: check-cloudflare-config + if: needs.check-cloudflare-config.outputs.configured == 'true' runs-on: ubuntu-latest permissions: contents: read diff --git a/.github/workflows/git-over-cdn-ssh.yml b/.github/workflows/git-over-cdn-ssh.yml index 3bb47d77f..b348f39bf 100644 --- a/.github/workflows/git-over-cdn-ssh.yml +++ b/.github/workflows/git-over-cdn-ssh.yml @@ -7,9 +7,7 @@ on: description: "How many old commits to build packs for" required: false default: "15" - push: - branches: - - master + # SSH 发布需要独立服务器和私钥,未配置时不参与日常自动发布。 jobs: build-and-upload: diff --git a/.gitignore b/.gitignore index a8016c6f2..8b6ebc626 100644 --- a/.gitignore +++ b/.gitignore @@ -314,3 +314,4 @@ cursor-helper/**/review/reports/files/ AGENTS.md RTK.md +/低心情撤退修复补丁/ diff --git a/assets/gui/css/bj.jpg b/assets/gui/css/bj.jpg deleted file mode 100644 index bf5a9853d..000000000 Binary files a/assets/gui/css/bj.jpg and /dev/null differ diff --git a/assets/map_detection/os_globe_map.png b/assets/map_detection/os_globe_map.png deleted file mode 100644 index a2d93f987..000000000 Binary files a/assets/map_detection/os_globe_map.png and /dev/null differ diff --git a/doc/macGUI.png b/doc/macGUI.png deleted file mode 100644 index e4b059f67..000000000 Binary files a/doc/macGUI.png and /dev/null differ diff --git a/module/campaign/campaign_base.py b/module/campaign/campaign_base.py index be3dfaae0..b54c0b012 100644 --- a/module/campaign/campaign_base.py +++ b/module/campaign/campaign_base.py @@ -37,6 +37,24 @@ class CampaignBase(CampaignUI, Map, AutoSearchCombat): """ FUNCTION_NAME_BASE = 'battle_' MAP: CampaignMap + low_emotion_withdrawn = False + + def handle_combat_low_emotion(self): + """处理战役中的低心情强制出击提示。 + + 仅在“计算心情消耗”模式下取消提示并撤退;其他心情设置维持原行为。 + """ + if self.config.Emotion_Mode != 'calculate': + return super().handle_combat_low_emotion() + + if self.handle_popup_cancel('IGNORE_LOW_EMOTION'): + logger.hr('低心情撤退') + logger.info('[低心情] 已取消强制出击,退出战斗准备后撤退当前地图') + self.low_emotion_withdrawn = True + # 不能在战斗准备页内嵌撤退循环;由 CampaignRun 退出此页面后统一撤退。 + raise CampaignEnd('LowEmotionWithdraw') + + return False def battle_default(self): """默认战斗策略:清除所有敌人。 diff --git a/module/campaign/gems_farming.py b/module/campaign/gems_farming.py index 139f326df..954969ea8 100644 --- a/module/campaign/gems_farming.py +++ b/module/campaign/gems_farming.py @@ -139,14 +139,18 @@ def handle_exp_info(self): return False if super().handle_exp_info(): return True + wait_for_transition = not getattr(self, '_withdraw_result_processing', False) if self.appear_then_click(EXP_INFO_C, threshold=10): - self.device.sleep((0.25, 0.5)) + if wait_for_transition: + self.device.sleep((0.25, 0.5)) return True if self.appear_then_click(EXP_INFO_D): - self.device.sleep((0.25, 0.5)) + if wait_for_transition: + self.device.sleep((0.25, 0.5)) return True if self.appear_then_click(OPTS_INFO_D, offset=True, similarity=0.9): - self.device.sleep((0.25, 0.5)) + if wait_for_transition: + self.device.sleep((0.25, 0.5)) return True return False diff --git a/module/campaign/run.py b/module/campaign/run.py index 3aaaa87b0..a67ff44d6 100644 --- a/module/campaign/run.py +++ b/module/campaign/run.py @@ -21,6 +21,7 @@ from module.shop.shop_status import ShopStatus from module.campaign.campaign_ui import MODE_SWITCH_1 from module.config.config import AzurLaneConfig +from module.config.task_priority import parse_task_priority from module.exception import CampaignEnd, RequestHumanTakeover, ScriptEnd from module.handler.fast_forward import map_files, to_map_file_name from module.logger import logger @@ -418,6 +419,83 @@ def after_campaign_run(self): """单次战役完成后的扩展钩子。""" pass + def get_low_emotion_next_campaign_tasks(self, task): + """从任务优先级配置中获取当前战役系列的后续任务。""" + task_prefix = next( + ( + prefix + for prefix in ('Event', 'Main') + if task == prefix or (task.startswith(prefix) and task[len(prefix):].isdigit()) + ), + None, + ) + if task_prefix is None: + return [] + + campaign_tasks = [ + candidate + for candidate in parse_task_priority(self.config.SCHEDULER_PRIORITY) + if candidate == task_prefix + or (candidate.startswith(task_prefix) and candidate[len(task_prefix):].isdigit()) + ] + try: + return campaign_tasks[campaign_tasks.index(task) + 1:] + except ValueError: + return [] + + def handle_low_emotion_withdrawal(self): + """延后因低心情撤退的当前任务,并切换到后续同系列战役图。""" + if not getattr(self.campaign, 'low_emotion_withdrawn', False): + return False + + task = self.config.task.command + try: + # 低心情弹窗可能出现在战斗准备页。先退出该页面,回到地图后再撤退。 + self.campaign.withdraw(skip_first_screenshot=False) + except CampaignEnd: + pass + + emotion = self.campaign.emotion + emotion.update() + if emotion.using_public: + public_fleet = getattr(emotion, 'public_fleet', None) + fleets = [public_fleet] if public_fleet is not None else [] + else: + # 初次出击时地图还未初始化,无法可靠判断是哪一队触发弹窗。 + # 双舰队配置下保守地延后两队,避免按错误舰队提前重试。 + fleets = list(getattr(emotion, 'fleets', [])) + if not self.config.FLEET_2: + fleets = fleets[:1] + + if not fleets: + logger.critical('[低心情] 未找到舰队心情记录,无法安全延后当前任务') + raise RequestHumanTakeover + + # 游戏已显示低心情强制出击弹窗,说明本地记录的心情值已经失真。 + # 不能继续用过高的旧值(例如 75)计算,否则会把当前任务排回现在。 + for fleet in fleets: + fleet.current = 0 + emotion.record() + emotion.show() + recovered = emotion.get_recovered_for_battle(self.campaign._map_battle) + + fleet_names = ', '.join(str(fleet.fleet) for fleet in fleets) + logger.info( + f'[低心情] 游戏端低心情,{task} 的 {fleet_names} 队按 0 心情恢复至 {recovered}' + ) + self.config.task_delay(target=recovered) + + # 后继同系列战役图由用户的任务优先级配置决定,跳过用户禁用的任务。 + # 所有后继图不可调用时,最后一张图交由调度器处理。 + for next_task in self.get_low_emotion_next_campaign_tasks(task): + if self.config.task_call(next_task, force_call=False): + logger.info(f'[低心情] {task} 已撤退,立即切换到 {next_task}') + self.config.update() + break + + self.campaign.low_emotion_withdrawn = False + return True + def handle_commission_notice(self): """ 检查委托通知。如果发现委托完成,停止当前任务并调用委托处理。 @@ -511,6 +589,9 @@ def run(self, name, folder='campaign_main', mode='normal', total=0): self.device.click_record_clear() try: self.campaign.run() + except CampaignEnd: + if not getattr(self.campaign, 'low_emotion_withdrawn', False): + raise except ScriptEnd as e: logger.hr('脚本结束') logger.info(str(e)) @@ -519,6 +600,9 @@ def run(self, name, folder='campaign_main', mode='normal', total=0): self.config.Scheduler_Enable = False break + if self.handle_low_emotion_withdrawal(): + self.config.task_stop('[低心情] 已撤退并延后当前任务') + # 更新配置 if len(self.campaign.config.modified): logger.info('[战役-运行] 更新仪表盘配置') diff --git a/module/combat/combat.py b/module/combat/combat.py index c11d0e2e1..865a0c4db 100644 --- a/module/combat/combat.py +++ b/module/combat/combat.py @@ -497,10 +497,11 @@ def handle_battle_status(self, drop=None): """ if self.is_combat_executing(): return False + wait_for_transition = not getattr(self, '_withdraw_result_processing', False) if self.appear(BATTLE_STATUS_S, interval=self.battle_status_click_interval): if drop: drop.handle_add(self) - else: + elif wait_for_transition: self.device.sleep((0.25, 0.5)) self.device.click(BATTLE_STATUS_S) return True @@ -508,7 +509,7 @@ def handle_battle_status(self, drop=None): logger.warning('[战斗-结算] 战斗评价 A') if drop: drop.handle_add(self) - else: + elif wait_for_transition: self.device.sleep((0.25, 0.5)) self.device.click(BATTLE_STATUS_A) return True @@ -516,7 +517,7 @@ def handle_battle_status(self, drop=None): logger.warning('[战斗-结算] 战斗评价 B') if drop: drop.handle_add(self) - else: + elif wait_for_transition: self.device.sleep((0.25, 0.5)) self.device.click(BATTLE_STATUS_B) return True @@ -524,7 +525,7 @@ def handle_battle_status(self, drop=None): logger.warning('[战斗-结算] 战斗评价 C') if drop: drop.handle_add(self) - else: + elif wait_for_transition: self.device.sleep((0.25, 0.5)) self.device.click(BATTLE_STATUS_C) return True @@ -532,7 +533,7 @@ def handle_battle_status(self, drop=None): logger.warning('[战斗-结算] 战斗评价 D') if drop: drop.handle_add(self) - else: + elif wait_for_transition: self.device.sleep((0.25, 0.5)) self.device.click(BATTLE_STATUS_D) return True @@ -587,20 +588,26 @@ def handle_exp_info(self): """ if self.is_combat_executing(): return False + wait_for_transition = not getattr(self, '_withdraw_result_processing', False) if self.appear_then_click(EXP_INFO_S): - self.device.sleep((0.25, 0.5)) + if wait_for_transition: + self.device.sleep((0.25, 0.5)) return True if self.appear_then_click(EXP_INFO_A): - self.device.sleep((0.25, 0.5)) + if wait_for_transition: + self.device.sleep((0.25, 0.5)) return True if self.appear_then_click(EXP_INFO_B): - self.device.sleep((0.25, 0.5)) + if wait_for_transition: + self.device.sleep((0.25, 0.5)) return True if self.appear_then_click(EXP_INFO_C): - self.device.sleep((0.25, 0.5)) + if wait_for_transition: + self.device.sleep((0.25, 0.5)) return True if self.appear_then_click(EXP_INFO_D): - self.device.sleep((0.25, 0.5)) + if wait_for_transition: + self.device.sleep((0.25, 0.5)) return True return False diff --git a/module/combat/emotion.py b/module/combat/emotion.py index bc67ec658..bbddabf7e 100644 --- a/module/combat/emotion.py +++ b/module/combat/emotion.py @@ -336,23 +336,16 @@ def reduce_per_battle_before_entering(self): def reduce_shipwreck(self): return 10 - def _check_reduce(self, battle): - """检查战斗带来的情绪减少。 + def get_recovered_for_battle(self, battle): + """计算完成下一次战役所需的情绪恢复时间。 Returns: - recovered (datetime): 预期恢复时间。 - delay (bool): 是否需要延迟。 + datetime: 扣除下一次战役的预计情绪后达到控制阈值的时间。 """ if self.using_public: reduce = battle * self.reduce_per_battle_before_entering logger.info(f'[情绪-检查] 预期情绪扣减: {reduce}') - - self.update() - self.record() - self.show() - recovered = self.public_fleet.get_recovered(reduce) - delay = recovered > current_time() - return recovered, delay + return self.public_fleet.get_recovered(reduce) method = self.config.Fleet_FleetOrder @@ -369,13 +362,15 @@ def _check_reduce(self, battle): battle = tuple(np.array(battle) * self.reduce_per_battle_before_entering) logger.info(f'[情绪-检查] 预期情绪扣减: {battle}') + return max([f.get_recovered(b) for f, b in zip(self.fleets, battle)]) + def _check_reduce(self, battle): + """检查战斗带来的情绪减少。""" self.update() self.record() self.show() - recovered = max([f.get_recovered(b) for f, b in zip(self.fleets, battle)]) - delay = recovered > current_time() - return recovered, delay + recovered = self.get_recovered_for_battle(battle) + return recovered, recovered > current_time() def check_reduce(self, battle): """进入战役前检查情绪。 diff --git a/module/map/map_operation.py b/module/map/map_operation.py index a18c27daa..368dccab6 100644 --- a/module/map/map_operation.py +++ b/module/map/map_operation.py @@ -17,6 +17,7 @@ import cv2 from module.base.timer import Timer +from module.combat.assets import BATTLE_PREPARATION from module.exception import CampaignEnd, RequestHumanTakeover, ScriptEnd from module.handler.fast_forward import FastForwardHandler from module.handler.mystery import MysteryHandler @@ -44,6 +45,12 @@ class MapOperation(MysteryHandler, FleetPreparation, Retirement, FastForwardHand map_cat_attack_timer = Timer(2) map_clear_percentage_prev = -1 map_clear_percentage_timer = Timer(0.3, count=1) + WITHDRAW_RESULT_HANDLERS = ( + 'handle_battle_status', + 'handle_exp_info', + 'handle_get_ship', + 'handle_get_items', + ) # 屏幕上显示的舰队编号。 fleet_show_index = 1 @@ -301,11 +308,7 @@ def enter_map_cancel(self, skip_first_screenshot=True): if self.is_in_stage(): break - if self.appear(MAP_PREPARATION, offset=(20, 20), interval=2): - self.device.click(MAP_PREPARATION_CANCEL) - continue - if self.appear(FLEET_PREPARATION, offset=(20, 50), interval=2): - self.device.click(MAP_PREPARATION_CANCEL) + if self.handle_enter_map_preparation_cancel(): continue return True @@ -451,6 +454,12 @@ def withdraw(self, skip_first_screenshot=True): else: self.device.screenshot() + if self.handle_withdraw_battle_preparation(): + continue + if self.handle_enter_map_preparation_cancel(): + continue + if self.handle_withdraw_result(): + continue if self.appear_then_click(FLEET_SWITCH_CONFIRM, offset=(30, 30)): continue if self.handle_popup_confirm('WITHDRAW'): @@ -469,6 +478,39 @@ def withdraw(self, skip_first_screenshot=True): if self.handle_in_stage(): raise CampaignEnd('Withdraw') + def handle_withdraw_battle_preparation(self): + """退出撤退前仍残留的战斗准备页。""" + if self.appear(BATTLE_PREPARATION, offset=(20, 20), interval=2): + logger.info(f'{BATTLE_PREPARATION} -> {BACK_ARROW}') + self.device.click(BACK_ARROW) + return True + return False + + def handle_enter_map_preparation_cancel(self): + """从舰队准备或地图准备页退回关卡选择页。""" + if self.appear(MAP_PREPARATION, offset=(20, 20), interval=2): + logger.info(f'{MAP_PREPARATION} -> {MAP_PREPARATION_CANCEL}') + self.device.click(MAP_PREPARATION_CANCEL) + return True + if self.appear(FLEET_PREPARATION, offset=(20, 50), interval=2): + logger.info(f'{FLEET_PREPARATION} -> {MAP_PREPARATION_CANCEL}') + self.device.click(MAP_PREPARATION_CANCEL) + return True + return False + + def handle_withdraw_result(self): + """推进撤退过程中可能出现的战斗结算页面。""" + # MapOperation 也由不混入 Combat 的任务复用,结算处理器在该场景下是可选的。 + self._withdraw_result_processing = True + try: + for name in self.WITHDRAW_RESULT_HANDLERS: + handler = getattr(self, name, None) + if handler and handler(): + return True + return self.handle_popup_confirm('COMBAT_STATUS') + finally: + self._withdraw_result_processing = False + def handle_map_cat_attack(self): """ 处理猫猫攻击动画,点击跳过。 diff --git a/module/webui/app_dashboard.py b/module/webui/app_dashboard.py index 78387fc59..65c6fecf6 100644 --- a/module/webui/app_dashboard.py +++ b/module/webui/app_dashboard.py @@ -104,7 +104,7 @@ def _update_dashboard(self, num=None, groups_to_display=None): self._log.dashboard_arg_group if groups_to_display is None else groups_to_display - ) + ) or [] time_now = current_time().replace(microsecond=0) for group_name in _arg_group: group = LogRes(self.alas_config).group(group_name) diff --git a/tests/test_webui_dashboard.py b/tests/test_webui_dashboard.py new file mode 100644 index 000000000..ac11b26d3 --- /dev/null +++ b/tests/test_webui_dashboard.py @@ -0,0 +1,21 @@ +import unittest +from types import SimpleNamespace + +from module.webui.app_dashboard import DashboardMixin + + +class TestDashboardMixin(unittest.TestCase): + def test_empty_dashboard_groups_do_not_break_overview(self): + dashboard = DashboardMixin.__new__(DashboardMixin) + dashboard.__dict__['_log'] = SimpleNamespace( + dashboard_arg_group=None, + first_display=True, + ) + + dashboard._update_dashboard() + + self.assertFalse(dashboard._log.first_display) + + +if __name__ == '__main__': + unittest.main()