From 1f7cff493801f1d63aab6337a119dc0b23c32cf8 Mon Sep 17 00:00:00 2001 From: a2893005741 Date: Fri, 14 Aug 2026 18:32:05 +0800 Subject: [PATCH 01/12] =?UTF-8?q?fix(campaign):=20=E4=BD=8E=E5=BF=83?= =?UTF-8?q?=E6=83=85=E6=92=A4=E9=80=80=E5=90=8E=E5=88=87=E6=8D=A2=E5=90=8E?= =?UTF-8?q?=E7=BB=AD=E6=B4=BB=E5=8A=A8=E4=BB=BB=E5=8A=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- module/campaign/campaign_base.py | 16 +++++++++++++++ module/campaign/run.py | 35 ++++++++++++++++++++++++++++++++ module/map/map_operation.py | 11 ++++++++++ 3 files changed, 62 insertions(+) diff --git a/module/campaign/campaign_base.py b/module/campaign/campaign_base.py index be3dfaae0..d010fc9fa 100644 --- a/module/campaign/campaign_base.py +++ b/module/campaign/campaign_base.py @@ -38,6 +38,22 @@ class CampaignBase(CampaignUI, Map, AutoSearchCombat): FUNCTION_NAME_BASE = 'battle_' MAP: CampaignMap + 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 + self.withdraw() + + return False + def battle_default(self): """默认战斗策略:清除所有敌人。 diff --git a/module/campaign/run.py b/module/campaign/run.py index 3aaaa87b0..5209bf315 100644 --- a/module/campaign/run.py +++ b/module/campaign/run.py @@ -418,6 +418,38 @@ def after_campaign_run(self): """单次战役完成后的扩展钩子。""" pass + def handle_low_emotion_withdrawal(self): + """延后因低心情撤退的当前任务,并切换到后续活动图。""" + if not getattr(self.campaign, 'low_emotion_withdrawn', False): + return False + + task = self.config.task.command + emotion = self.campaign.emotion + emotion.update() + if emotion.using_public: + fleet = emotion.public_fleet + else: + fleet = emotion.fleets[self.campaign.fleet_current_index - 1] + + # 游戏已显示低心情强制出击弹窗,说明本地记录的心情值已经失真。 + # 不能继续用过高的旧值(例如 75)计算,否则会把当前任务排回现在。 + fleet.current = 0 + emotion.record() + recovered = fleet.get_recovered() + + logger.info(f'[低心情] 游戏端低心情,{task} 的 {fleet.fleet} 队按 0 心情恢复至 {recovered}') + self.config.task_delay(target=recovered) + + # 前两张活动图低心情撤退时,明确切到下一张活动图。 + # Event3 不再指定后继任务,交由调度器按任务列表选择下一项。 + next_event = {'Event': 'Event2', 'Event2': 'Event3'}.get(task) + if next_event and self.config.task_call(next_event, force_call=False): + logger.info(f'[低心情] {task} 已撤退,立即切换到 {next_event}') + self.config.update() + + self.campaign.low_emotion_withdrawn = False + return True + def handle_commission_notice(self): """ 检查委托通知。如果发现委托完成,停止当前任务并调用委托处理。 @@ -519,6 +551,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/map/map_operation.py b/module/map/map_operation.py index a18c27daa..35bbd4a12 100644 --- a/module/map/map_operation.py +++ b/module/map/map_operation.py @@ -451,6 +451,17 @@ def withdraw(self, skip_first_screenshot=True): else: self.device.screenshot() + # 撤退过程中也可能先落在战斗结算页,必须推进结算后才能回到关卡页。 + if hasattr(self, 'handle_battle_status') and self.handle_battle_status(): + continue + if hasattr(self, 'handle_exp_info') and self.handle_exp_info(): + continue + if hasattr(self, 'handle_get_ship') and self.handle_get_ship(): + continue + if hasattr(self, 'handle_get_items') and self.handle_get_items(): + continue + if self.handle_popup_confirm('COMBAT_STATUS'): + continue if self.appear_then_click(FLEET_SWITCH_CONFIRM, offset=(30, 30)): continue if self.handle_popup_confirm('WITHDRAW'): From f9c7900bd207245b56959e52bcf7d0f29f2f5caf Mon Sep 17 00:00:00 2001 From: a2893005741 Date: Fri, 14 Aug 2026 18:52:18 +0800 Subject: [PATCH 02/12] =?UTF-8?q?fix(campaign):=20=E4=BF=AE=E6=AD=A3?= =?UTF-8?q?=E4=BD=8E=E5=BF=83=E6=83=85=E6=92=A4=E9=80=80=E6=B5=81=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- module/campaign/campaign_base.py | 5 +- module/campaign/run.py | 26 ++++- module/map/map_operation.py | 28 +++-- tests/test_low_emotion_withdraw.py | 176 +++++++++++++++++++++++++++++ 4 files changed, 218 insertions(+), 17 deletions(-) create mode 100644 tests/test_low_emotion_withdraw.py diff --git a/module/campaign/campaign_base.py b/module/campaign/campaign_base.py index d010fc9fa..832a5ce9d 100644 --- a/module/campaign/campaign_base.py +++ b/module/campaign/campaign_base.py @@ -48,9 +48,10 @@ def handle_combat_low_emotion(self): if self.handle_popup_cancel('IGNORE_LOW_EMOTION'): logger.hr('低心情撤退') - logger.info('[低心情] 已取消强制出击,撤退当前地图') + logger.info('[低心情] 已取消强制出击,退出战斗准备后撤退当前地图') self.low_emotion_withdrawn = True - self.withdraw() + # 不能在战斗准备页内嵌撤退循环;由 CampaignRun 退出此页面后统一撤退。 + raise CampaignEnd('LowEmotionWithdraw') return False diff --git a/module/campaign/run.py b/module/campaign/run.py index 5209bf315..5a71a86df 100644 --- a/module/campaign/run.py +++ b/module/campaign/run.py @@ -53,6 +53,7 @@ class CampaignRun(CampaignEvent, ShopStatus): name: str stage: str module = None + LOW_EMOTION_NEXT_EVENT = {'Event': 'Event2', 'Event2': 'Event3'} config: AzurLaneConfig campaign: CampaignBase run_count: int @@ -424,25 +425,35 @@ def handle_low_emotion_withdrawal(self): 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: - fleet = emotion.public_fleet + fleets = [emotion.public_fleet] else: - fleet = emotion.fleets[self.campaign.fleet_current_index - 1] + # 初次出击时地图还未初始化,无法可靠判断是哪一队触发弹窗。 + # 双舰队配置下保守地延后两队,避免按错误舰队提前重试。 + fleets = emotion.fleets if self.config.FLEET_2 else [emotion.fleets[0]] # 游戏已显示低心情强制出击弹窗,说明本地记录的心情值已经失真。 # 不能继续用过高的旧值(例如 75)计算,否则会把当前任务排回现在。 - fleet.current = 0 + for fleet in fleets: + fleet.current = 0 emotion.record() - recovered = fleet.get_recovered() + recovered = max(fleet.get_recovered() for fleet in fleets) - logger.info(f'[低心情] 游戏端低心情,{task} 的 {fleet.fleet} 队按 0 心情恢复至 {recovered}') + fleet_names = ', '.join(str(fleet.fleet) for fleet in fleets) + logger.info(f'[低心情] 游戏端低心情,{task} 的 {fleet_names} 队按 0 心情恢复至 {recovered}') self.config.task_delay(target=recovered) # 前两张活动图低心情撤退时,明确切到下一张活动图。 # Event3 不再指定后继任务,交由调度器按任务列表选择下一项。 - next_event = {'Event': 'Event2', 'Event2': 'Event3'}.get(task) + next_event = self.LOW_EMOTION_NEXT_EVENT.get(task) if next_event and self.config.task_call(next_event, force_call=False): logger.info(f'[低心情] {task} 已撤退,立即切换到 {next_event}') self.config.update() @@ -543,6 +554,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)) diff --git a/module/map/map_operation.py b/module/map/map_operation.py index 35bbd4a12..f184adb03 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 @@ -451,16 +452,9 @@ def withdraw(self, skip_first_screenshot=True): else: self.device.screenshot() - # 撤退过程中也可能先落在战斗结算页,必须推进结算后才能回到关卡页。 - if hasattr(self, 'handle_battle_status') and self.handle_battle_status(): + if self.handle_withdraw_battle_preparation(): continue - if hasattr(self, 'handle_exp_info') and self.handle_exp_info(): - continue - if hasattr(self, 'handle_get_ship') and self.handle_get_ship(): - continue - if hasattr(self, 'handle_get_items') and self.handle_get_items(): - continue - if self.handle_popup_confirm('COMBAT_STATUS'): + if self.handle_withdraw_result(): continue if self.appear_then_click(FLEET_SWITCH_CONFIRM, offset=(30, 30)): continue @@ -480,6 +474,22 @@ 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_withdraw_result(self): + """推进撤退过程中可能出现的战斗结算页面。""" + for name in ('handle_battle_status', 'handle_exp_info', 'handle_get_ship', 'handle_get_items'): + handler = getattr(self, name, None) + if handler and handler(): + return True + return self.handle_popup_confirm('COMBAT_STATUS') + def handle_map_cat_attack(self): """ 处理猫猫攻击动画,点击跳过。 diff --git a/tests/test_low_emotion_withdraw.py b/tests/test_low_emotion_withdraw.py new file mode 100644 index 000000000..5e7b6ffbd --- /dev/null +++ b/tests/test_low_emotion_withdraw.py @@ -0,0 +1,176 @@ +from datetime import datetime +from types import SimpleNamespace +import unittest +from unittest.mock import Mock + +# 本地旧虚拟环境的 RapidOCR 仍未提供源码要求的 PPOCRV6 枚举。 +# 仅为加载待测战役模块补齐别名,不影响生产运行时的依赖版本。 +from rapidocr import OCRVersion + +if not hasattr(OCRVersion, 'PPOCRV6'): + OCRVersion.PPOCRV6 = OCRVersion.PPOCRV5 + +from module.campaign.campaign_base import CampaignBase +from module.campaign.run import CampaignRun +from module.exception import CampaignEnd +from module.map.map_operation import MapOperation + + +class TestLowEmotionWithdraw(unittest.TestCase): + def test_calculate_mode_cancels_then_exits_combat_preparation(self): + campaign = CampaignBase.__new__(CampaignBase) + campaign.config = SimpleNamespace(Emotion_Mode='calculate') + campaign.handle_popup_cancel = Mock(return_value=True) + campaign.withdraw = Mock() + + with self.assertRaises(CampaignEnd): + campaign.handle_combat_low_emotion() + + campaign.handle_popup_cancel.assert_called_once_with('IGNORE_LOW_EMOTION') + campaign.withdraw.assert_not_called() + self.assertTrue(campaign.low_emotion_withdrawn) + + def test_non_calculate_mode_keeps_original_confirm_behavior(self): + campaign = CampaignBase.__new__(CampaignBase) + campaign.config = SimpleNamespace(Emotion_Mode='calculate_ignore') + campaign.__dict__['emotion'] = SimpleNamespace(is_ignore=True) + campaign.handle_popup_cancel = Mock() + campaign.handle_popup_confirm = Mock(return_value=True) + campaign.interval_reset = Mock() + + self.assertTrue(campaign.handle_combat_low_emotion()) + + campaign.handle_popup_cancel.assert_not_called() + campaign.handle_popup_confirm.assert_called_once_with('IGNORE_LOW_EMOTION') + + def test_withdrawal_delays_current_task_to_emotion_recovery(self): + recovered = datetime(2026, 8, 14, 12, 0, 0) + fleet = Mock() + fleet.fleet = 1 + fleet.current = 75 + fleet.get_recovered.return_value = recovered + emotion = Mock(using_public=False) + emotion.fleets = [fleet, Mock()] + campaign = SimpleNamespace( + low_emotion_withdrawn=True, + emotion=emotion, + withdraw=Mock(side_effect=CampaignEnd('Withdraw')), + ) + runner = CampaignRun.__new__(CampaignRun) + runner.__dict__['campaign'] = campaign + runner.__dict__['config'] = Mock() + runner.config.task.command = 'Event' + runner.config.FLEET_2 = 0 + + self.assertTrue(runner.handle_low_emotion_withdrawal()) + + campaign.withdraw.assert_called_once_with(skip_first_screenshot=False) + emotion.update.assert_called_once_with() + emotion.record.assert_called_once_with() + self.assertEqual(fleet.current, 0) + fleet.get_recovered.assert_called_once_with() + runner.config.task_delay.assert_called_once_with(target=recovered) + runner.config.task_call.assert_called_once_with('Event2', force_call=False) + runner.config.update.assert_called_once_with() + self.assertFalse(campaign.low_emotion_withdrawn) + + def test_withdrawal_from_second_event_runs_third_event(self): + fleet = Mock() + fleet.get_recovered.return_value = datetime(2026, 8, 14, 12, 0, 0) + emotion = Mock(using_public=False) + emotion.fleets = [fleet] + campaign = SimpleNamespace( + low_emotion_withdrawn=True, + emotion=emotion, + withdraw=Mock(side_effect=CampaignEnd('Withdraw')), + ) + runner = CampaignRun.__new__(CampaignRun) + runner.__dict__['campaign'] = campaign + runner.__dict__['config'] = Mock() + runner.config.task.command = 'Event2' + runner.config.FLEET_2 = 0 + + self.assertTrue(runner.handle_low_emotion_withdrawal()) + + runner.config.task_call.assert_called_once_with('Event3', force_call=False) + runner.config.update.assert_called_once_with() + + def test_withdrawal_from_third_event_returns_to_scheduler_queue(self): + fleet = Mock() + fleet.get_recovered.return_value = datetime(2026, 8, 14, 12, 0, 0) + emotion = Mock(using_public=False) + emotion.fleets = [fleet] + campaign = SimpleNamespace( + low_emotion_withdrawn=True, + emotion=emotion, + withdraw=Mock(side_effect=CampaignEnd('Withdraw')), + ) + runner = CampaignRun.__new__(CampaignRun) + runner.__dict__['campaign'] = campaign + runner.__dict__['config'] = Mock() + runner.config.task.command = 'Event3' + runner.config.FLEET_2 = 0 + + self.assertTrue(runner.handle_low_emotion_withdrawal()) + + runner.config.task_delay.assert_called_once_with(target=fleet.get_recovered.return_value) + runner.config.task_call.assert_not_called() + runner.config.update.assert_not_called() + + def test_withdrawal_delays_both_configured_fleets(self): + first_recovered = datetime(2026, 8, 14, 12, 0, 0) + second_recovered = datetime(2026, 8, 14, 12, 12, 0) + fleet_1 = Mock(fleet=1, current=75) + fleet_1.get_recovered.return_value = first_recovered + fleet_2 = Mock(fleet=2, current=90) + fleet_2.get_recovered.return_value = second_recovered + emotion = Mock(using_public=False) + emotion.fleets = [fleet_1, fleet_2] + campaign = SimpleNamespace( + low_emotion_withdrawn=True, + emotion=emotion, + withdraw=Mock(side_effect=CampaignEnd('Withdraw')), + ) + runner = CampaignRun.__new__(CampaignRun) + runner.__dict__['campaign'] = campaign + runner.__dict__['config'] = Mock() + runner.config.task.command = 'Event' + runner.config.FLEET_2 = 2 + + self.assertTrue(runner.handle_low_emotion_withdrawal()) + + self.assertEqual(fleet_1.current, 0) + self.assertEqual(fleet_2.current, 0) + runner.config.task_delay.assert_called_once_with(target=second_recovered) + + def test_withdraw_processes_battle_result_before_waiting_for_stage(self): + operation = MapOperation.__new__(MapOperation) + operation.device = SimpleNamespace(screenshot=Mock()) + operation.handle_battle_status = Mock(side_effect=[True, False]) + operation.handle_exp_info = Mock(return_value=False) + operation.handle_get_ship = Mock(return_value=False) + operation.handle_get_items = Mock(return_value=False) + operation.handle_popup_confirm = Mock(return_value=False) + operation.appear_then_click = Mock(return_value=False) + operation.handle_auto_search_exit = Mock(return_value=False) + operation.appear = Mock(return_value=False) + operation.handle_in_stage = Mock(return_value=True) + + with self.assertRaises(CampaignEnd): + operation.withdraw(skip_first_screenshot=False) + + operation.handle_battle_status.assert_called() + operation.handle_in_stage.assert_called_once_with() + + def test_withdraw_exits_battle_preparation_before_opening_withdraw_menu(self): + operation = MapOperation.__new__(MapOperation) + operation.device = SimpleNamespace(click=Mock()) + operation.appear = Mock(return_value=True) + + self.assertTrue(operation.handle_withdraw_battle_preparation()) + + operation.device.click.assert_called_once() + + +if __name__ == '__main__': + unittest.main() From cd23d6cc5db72072f8b4a21f776142b02b4b9299 Mon Sep 17 00:00:00 2001 From: a2893005741 Date: Fri, 14 Aug 2026 19:05:01 +0800 Subject: [PATCH 03/12] =?UTF-8?q?fix(campaign):=20=E5=AE=8C=E6=95=B4?= =?UTF-8?q?=E9=80=80=E5=87=BA=E4=BD=8E=E5=BF=83=E6=83=85=E5=87=86=E5=A4=87?= =?UTF-8?q?=E9=A1=B5=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- module/campaign/campaign_base.py | 1 + module/campaign/run.py | 11 ++++- module/map/map_operation.py | 20 ++++++--- tests/test_low_emotion_withdraw.py | 72 +++++++++++++++++++++++++++++- 4 files changed, 96 insertions(+), 8 deletions(-) diff --git a/module/campaign/campaign_base.py b/module/campaign/campaign_base.py index 832a5ce9d..b54c0b012 100644 --- a/module/campaign/campaign_base.py +++ b/module/campaign/campaign_base.py @@ -37,6 +37,7 @@ class CampaignBase(CampaignUI, Map, AutoSearchCombat): """ FUNCTION_NAME_BASE = 'battle_' MAP: CampaignMap + low_emotion_withdrawn = False def handle_combat_low_emotion(self): """处理战役中的低心情强制出击提示。 diff --git a/module/campaign/run.py b/module/campaign/run.py index 5a71a86df..32dd86bc3 100644 --- a/module/campaign/run.py +++ b/module/campaign/run.py @@ -434,11 +434,18 @@ def handle_low_emotion_withdrawal(self): emotion = self.campaign.emotion emotion.update() if emotion.using_public: - fleets = [emotion.public_fleet] + public_fleet = getattr(emotion, 'public_fleet', None) + fleets = [public_fleet] if public_fleet is not None else [] else: # 初次出击时地图还未初始化,无法可靠判断是哪一队触发弹窗。 # 双舰队配置下保守地延后两队,避免按错误舰队提前重试。 - fleets = emotion.fleets if self.config.FLEET_2 else [emotion.fleets[0]] + fleets = list(getattr(emotion, 'fleets', [])) + if not self.config.FLEET_2: + fleets = fleets[:1] + + if not fleets: + logger.critical('[低心情] 未找到舰队心情记录,无法安全延后当前任务') + raise RequestHumanTakeover # 游戏已显示低心情强制出击弹窗,说明本地记录的心情值已经失真。 # 不能继续用过高的旧值(例如 75)计算,否则会把当前任务排回现在。 diff --git a/module/map/map_operation.py b/module/map/map_operation.py index f184adb03..5828b1095 100644 --- a/module/map/map_operation.py +++ b/module/map/map_operation.py @@ -302,11 +302,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 @@ -454,6 +450,8 @@ def withdraw(self, skip_first_screenshot=True): 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)): @@ -482,6 +480,18 @@ def handle_withdraw_battle_preparation(self): 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): """推进撤退过程中可能出现的战斗结算页面。""" for name in ('handle_battle_status', 'handle_exp_info', 'handle_get_ship', 'handle_get_items'): diff --git a/tests/test_low_emotion_withdraw.py b/tests/test_low_emotion_withdraw.py index 5e7b6ffbd..8755375e5 100644 --- a/tests/test_low_emotion_withdraw.py +++ b/tests/test_low_emotion_withdraw.py @@ -12,7 +12,8 @@ from module.campaign.campaign_base import CampaignBase from module.campaign.run import CampaignRun -from module.exception import CampaignEnd +from module.exception import CampaignEnd, RequestHumanTakeover +from module.map.assets import FLEET_PREPARATION, MAP_PREPARATION, MAP_PREPARATION_CANCEL from module.map.map_operation import MapOperation @@ -95,6 +96,52 @@ def test_withdrawal_from_second_event_runs_third_event(self): runner.config.task_call.assert_called_once_with('Event3', force_call=False) runner.config.update.assert_called_once_with() + def test_withdrawal_uses_public_fleet_emotion(self): + recovered = datetime(2026, 8, 14, 12, 0, 0) + public_fleet = Mock(fleet='Public', current=75) + public_fleet.get_recovered.return_value = recovered + fleet_1 = Mock(fleet=1, current=80) + fleet_2 = Mock(fleet=2, current=90) + emotion = Mock(using_public=True, public_fleet=public_fleet) + emotion.fleets = [fleet_1, fleet_2] + campaign = SimpleNamespace( + low_emotion_withdrawn=True, + emotion=emotion, + withdraw=Mock(side_effect=CampaignEnd('Withdraw')), + ) + runner = CampaignRun.__new__(CampaignRun) + runner.__dict__['campaign'] = campaign + runner.__dict__['config'] = Mock() + runner.config.task.command = 'Event3' + runner.config.FLEET_2 = 2 + + self.assertTrue(runner.handle_low_emotion_withdrawal()) + + self.assertEqual(public_fleet.current, 0) + self.assertEqual(fleet_1.current, 80) + self.assertEqual(fleet_2.current, 90) + public_fleet.get_recovered.assert_called_once_with() + runner.config.task_delay.assert_called_once_with(target=recovered) + + def test_withdrawal_without_fleet_record_requires_human_takeover(self): + emotion = Mock(using_public=False) + emotion.fleets = [] + campaign = SimpleNamespace( + low_emotion_withdrawn=True, + emotion=emotion, + withdraw=Mock(side_effect=CampaignEnd('Withdraw')), + ) + runner = CampaignRun.__new__(CampaignRun) + runner.__dict__['campaign'] = campaign + runner.__dict__['config'] = Mock() + runner.config.task.command = 'Event' + runner.config.FLEET_2 = 0 + + with self.assertRaises(RequestHumanTakeover): + runner.handle_low_emotion_withdrawal() + + runner.config.task_delay.assert_not_called() + def test_withdrawal_from_third_event_returns_to_scheduler_queue(self): fleet = Mock() fleet.get_recovered.return_value = datetime(2026, 8, 14, 12, 0, 0) @@ -171,6 +218,29 @@ def test_withdraw_exits_battle_preparation_before_opening_withdraw_menu(self): operation.device.click.assert_called_once() + def test_withdraw_exits_initial_preparation_pages(self): + for preparation_page in (MAP_PREPARATION, FLEET_PREPARATION): + with self.subTest(preparation_page=preparation_page): + operation = MapOperation.__new__(MapOperation) + operation.device = SimpleNamespace(click=Mock()) + operation.appear = Mock(side_effect=lambda button, **_: button is preparation_page) + + self.assertTrue(operation.handle_enter_map_preparation_cancel()) + + operation.device.click.assert_called_once_with(MAP_PREPARATION_CANCEL) + + def test_handle_withdraw_result_falls_back_to_combat_status_popup(self): + operation = MapOperation.__new__(MapOperation) + operation.handle_battle_status = Mock(return_value=False) + operation.handle_exp_info = Mock(return_value=False) + operation.handle_get_ship = Mock(return_value=False) + operation.handle_get_items = Mock(return_value=False) + operation.handle_popup_confirm = Mock(return_value=True) + + self.assertTrue(operation.handle_withdraw_result()) + + operation.handle_popup_confirm.assert_called_once_with('COMBAT_STATUS') + if __name__ == '__main__': unittest.main() From 5e786aa5b6aba291eefb6ea1decd071ef245580e Mon Sep 17 00:00:00 2001 From: a2893005741 Date: Fri, 14 Aug 2026 19:24:36 +0800 Subject: [PATCH 04/12] =?UTF-8?q?refactor(campaign):=20=E9=85=8D=E7=BD=AE?= =?UTF-8?q?=E5=8C=96=E4=BD=8E=E5=BF=83=E6=83=85=E4=BB=BB=E5=8A=A1=E5=88=87?= =?UTF-8?q?=E6=8D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- module/campaign/run.py | 19 +++++++++++++++---- module/map/map_operation.py | 10 +++++++--- tests/test_low_emotion_withdraw.py | 14 ++++++++++++++ 3 files changed, 36 insertions(+), 7 deletions(-) diff --git a/module/campaign/run.py b/module/campaign/run.py index 32dd86bc3..96f18033f 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 @@ -53,7 +54,6 @@ class CampaignRun(CampaignEvent, ShopStatus): name: str stage: str module = None - LOW_EMOTION_NEXT_EVENT = {'Event': 'Event2', 'Event2': 'Event3'} config: AzurLaneConfig campaign: CampaignBase run_count: int @@ -419,6 +419,18 @@ def after_campaign_run(self): """单次战役完成后的扩展钩子。""" pass + def get_low_emotion_next_event_task(self, task): + """从任务优先级配置中获取当前活动图的后继活动图任务。""" + event_tasks = [ + candidate + for candidate in parse_task_priority(self.config.SCHEDULER_PRIORITY) + if candidate == 'Event' or (candidate.startswith('Event') and candidate[5:].isdigit()) + ] + try: + return event_tasks[event_tasks.index(task) + 1] + except (IndexError, ValueError): + return None + def handle_low_emotion_withdrawal(self): """延后因低心情撤退的当前任务,并切换到后续活动图。""" if not getattr(self.campaign, 'low_emotion_withdrawn', False): @@ -458,9 +470,8 @@ def handle_low_emotion_withdrawal(self): logger.info(f'[低心情] 游戏端低心情,{task} 的 {fleet_names} 队按 0 心情恢复至 {recovered}') self.config.task_delay(target=recovered) - # 前两张活动图低心情撤退时,明确切到下一张活动图。 - # Event3 不再指定后继任务,交由调度器按任务列表选择下一项。 - next_event = self.LOW_EMOTION_NEXT_EVENT.get(task) + # 后继活动图由用户的任务优先级配置决定;最后一张活动图交由调度器处理。 + next_event = self.get_low_emotion_next_event_task(task) if next_event and self.config.task_call(next_event, force_call=False): logger.info(f'[低心情] {task} 已撤退,立即切换到 {next_event}') self.config.update() diff --git a/module/map/map_operation.py b/module/map/map_operation.py index 5828b1095..67f1e9fcb 100644 --- a/module/map/map_operation.py +++ b/module/map/map_operation.py @@ -494,9 +494,13 @@ def handle_enter_map_preparation_cancel(self): def handle_withdraw_result(self): """推进撤退过程中可能出现的战斗结算页面。""" - for name in ('handle_battle_status', 'handle_exp_info', 'handle_get_ship', 'handle_get_items'): - handler = getattr(self, name, None) - if handler and handler(): + for handler in ( + self.handle_battle_status, + self.handle_exp_info, + self.handle_get_ship, + self.handle_get_items, + ): + if handler(): return True return self.handle_popup_confirm('COMBAT_STATUS') diff --git a/tests/test_low_emotion_withdraw.py b/tests/test_low_emotion_withdraw.py index 8755375e5..63bf5a57a 100644 --- a/tests/test_low_emotion_withdraw.py +++ b/tests/test_low_emotion_withdraw.py @@ -18,6 +18,8 @@ class TestLowEmotionWithdraw(unittest.TestCase): + EVENT_PRIORITY = 'Event > Event2 > Event3' + def test_calculate_mode_cancels_then_exits_combat_preparation(self): campaign = CampaignBase.__new__(CampaignBase) campaign.config = SimpleNamespace(Emotion_Mode='calculate') @@ -62,6 +64,7 @@ def test_withdrawal_delays_current_task_to_emotion_recovery(self): runner.__dict__['config'] = Mock() runner.config.task.command = 'Event' runner.config.FLEET_2 = 0 + runner.config.SCHEDULER_PRIORITY = self.EVENT_PRIORITY self.assertTrue(runner.handle_low_emotion_withdrawal()) @@ -90,6 +93,7 @@ def test_withdrawal_from_second_event_runs_third_event(self): runner.__dict__['config'] = Mock() runner.config.task.command = 'Event2' runner.config.FLEET_2 = 0 + runner.config.SCHEDULER_PRIORITY = self.EVENT_PRIORITY self.assertTrue(runner.handle_low_emotion_withdrawal()) @@ -183,6 +187,7 @@ def test_withdrawal_delays_both_configured_fleets(self): runner.__dict__['config'] = Mock() runner.config.task.command = 'Event' runner.config.FLEET_2 = 2 + runner.config.SCHEDULER_PRIORITY = self.EVENT_PRIORITY self.assertTrue(runner.handle_low_emotion_withdrawal()) @@ -190,6 +195,15 @@ def test_withdrawal_delays_both_configured_fleets(self): self.assertEqual(fleet_2.current, 0) runner.config.task_delay.assert_called_once_with(target=second_recovered) + def test_next_event_task_follows_scheduler_priority_configuration(self): + runner = CampaignRun.__new__(CampaignRun) + runner.__dict__['config'] = SimpleNamespace( + SCHEDULER_PRIORITY='Event3 > Main > Event > Event2 > Raid' + ) + + self.assertEqual(runner.get_low_emotion_next_event_task('Event'), 'Event2') + self.assertIsNone(runner.get_low_emotion_next_event_task('Event2')) + def test_withdraw_processes_battle_result_before_waiting_for_stage(self): operation = MapOperation.__new__(MapOperation) operation.device = SimpleNamespace(screenshot=Mock()) From 7395fc03ae5dcac6db0d7d3e8ad264b7e42d18a7 Mon Sep 17 00:00:00 2001 From: a2893005741 Date: Fri, 14 Aug 2026 19:32:49 +0800 Subject: [PATCH 05/12] =?UTF-8?q?fix(emotion):=20=E9=A2=84=E7=95=99?= =?UTF-8?q?=E4=B8=8B=E4=B8=80=E6=AC=A1=E5=87=BA=E5=87=BB=E5=BF=83=E6=83=85?= =?UTF-8?q?=E6=B6=88=E8=80=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- module/campaign/run.py | 6 +++-- module/combat/emotion.py | 20 +++++++-------- tests/test_low_emotion_withdraw.py | 41 ++++++++++++++++++++++++++---- 3 files changed, 50 insertions(+), 17 deletions(-) diff --git a/module/campaign/run.py b/module/campaign/run.py index 96f18033f..e1c41cdac 100644 --- a/module/campaign/run.py +++ b/module/campaign/run.py @@ -464,10 +464,12 @@ def handle_low_emotion_withdrawal(self): for fleet in fleets: fleet.current = 0 emotion.record() - recovered = max(fleet.get_recovered() for fleet in fleets) + 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}') + logger.info( + f'[低心情] 游戏端低心情,{task} 的 {fleet_names} 队按 0 心情恢复至 {recovered}' + ) self.config.task_delay(target=recovered) # 后继活动图由用户的任务优先级配置决定;最后一张活动图交由调度器处理。 diff --git a/module/combat/emotion.py b/module/combat/emotion.py index bc67ec658..c54ae4fff 100644 --- a/module/combat/emotion.py +++ b/module/combat/emotion.py @@ -336,12 +336,11 @@ 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 @@ -350,9 +349,7 @@ def _check_reduce(self, battle): 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 @@ -373,9 +370,12 @@ 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 + return max([f.get_recovered(b) for f, b in zip(self.fleets, battle)]) + + def _check_reduce(self, battle): + """检查战斗带来的情绪减少。""" + recovered = self.get_recovered_for_battle(battle) + return recovered, recovered > current_time() def check_reduce(self, battle): """进入战役前检查情绪。 diff --git a/tests/test_low_emotion_withdraw.py b/tests/test_low_emotion_withdraw.py index 63bf5a57a..5a9228bfa 100644 --- a/tests/test_low_emotion_withdraw.py +++ b/tests/test_low_emotion_withdraw.py @@ -12,6 +12,7 @@ from module.campaign.campaign_base import CampaignBase from module.campaign.run import CampaignRun +from module.combat.emotion import Emotion from module.exception import CampaignEnd, RequestHumanTakeover from module.map.assets import FLEET_PREPARATION, MAP_PREPARATION, MAP_PREPARATION_CANCEL from module.map.map_operation import MapOperation @@ -54,10 +55,12 @@ def test_withdrawal_delays_current_task_to_emotion_recovery(self): fleet.get_recovered.return_value = recovered emotion = Mock(using_public=False) emotion.fleets = [fleet, Mock()] + emotion.get_recovered_for_battle.return_value = recovered campaign = SimpleNamespace( low_emotion_withdrawn=True, emotion=emotion, withdraw=Mock(side_effect=CampaignEnd('Withdraw')), + _map_battle=5, ) runner = CampaignRun.__new__(CampaignRun) runner.__dict__['campaign'] = campaign @@ -72,21 +75,23 @@ def test_withdrawal_delays_current_task_to_emotion_recovery(self): emotion.update.assert_called_once_with() emotion.record.assert_called_once_with() self.assertEqual(fleet.current, 0) - fleet.get_recovered.assert_called_once_with() + emotion.get_recovered_for_battle.assert_called_once_with(5) runner.config.task_delay.assert_called_once_with(target=recovered) runner.config.task_call.assert_called_once_with('Event2', force_call=False) runner.config.update.assert_called_once_with() self.assertFalse(campaign.low_emotion_withdrawn) def test_withdrawal_from_second_event_runs_third_event(self): + recovered = datetime(2026, 8, 14, 12, 0, 0) fleet = Mock() - fleet.get_recovered.return_value = datetime(2026, 8, 14, 12, 0, 0) emotion = Mock(using_public=False) emotion.fleets = [fleet] + emotion.get_recovered_for_battle.return_value = recovered campaign = SimpleNamespace( low_emotion_withdrawn=True, emotion=emotion, withdraw=Mock(side_effect=CampaignEnd('Withdraw')), + _map_battle=4, ) runner = CampaignRun.__new__(CampaignRun) runner.__dict__['campaign'] = campaign @@ -99,6 +104,7 @@ def test_withdrawal_from_second_event_runs_third_event(self): runner.config.task_call.assert_called_once_with('Event3', force_call=False) runner.config.update.assert_called_once_with() + emotion.get_recovered_for_battle.assert_called_once_with(4) def test_withdrawal_uses_public_fleet_emotion(self): recovered = datetime(2026, 8, 14, 12, 0, 0) @@ -108,10 +114,12 @@ def test_withdrawal_uses_public_fleet_emotion(self): fleet_2 = Mock(fleet=2, current=90) emotion = Mock(using_public=True, public_fleet=public_fleet) emotion.fleets = [fleet_1, fleet_2] + emotion.get_recovered_for_battle.return_value = recovered campaign = SimpleNamespace( low_emotion_withdrawn=True, emotion=emotion, withdraw=Mock(side_effect=CampaignEnd('Withdraw')), + _map_battle=3, ) runner = CampaignRun.__new__(CampaignRun) runner.__dict__['campaign'] = campaign @@ -124,7 +132,7 @@ def test_withdrawal_uses_public_fleet_emotion(self): self.assertEqual(public_fleet.current, 0) self.assertEqual(fleet_1.current, 80) self.assertEqual(fleet_2.current, 90) - public_fleet.get_recovered.assert_called_once_with() + emotion.get_recovered_for_battle.assert_called_once_with(3) runner.config.task_delay.assert_called_once_with(target=recovered) def test_withdrawal_without_fleet_record_requires_human_takeover(self): @@ -147,14 +155,16 @@ def test_withdrawal_without_fleet_record_requires_human_takeover(self): runner.config.task_delay.assert_not_called() def test_withdrawal_from_third_event_returns_to_scheduler_queue(self): + recovered = datetime(2026, 8, 14, 12, 0, 0) fleet = Mock() - fleet.get_recovered.return_value = datetime(2026, 8, 14, 12, 0, 0) emotion = Mock(using_public=False) emotion.fleets = [fleet] + emotion.get_recovered_for_battle.return_value = recovered campaign = SimpleNamespace( low_emotion_withdrawn=True, emotion=emotion, withdraw=Mock(side_effect=CampaignEnd('Withdraw')), + _map_battle=2, ) runner = CampaignRun.__new__(CampaignRun) runner.__dict__['campaign'] = campaign @@ -164,7 +174,8 @@ def test_withdrawal_from_third_event_returns_to_scheduler_queue(self): self.assertTrue(runner.handle_low_emotion_withdrawal()) - runner.config.task_delay.assert_called_once_with(target=fleet.get_recovered.return_value) + emotion.get_recovered_for_battle.assert_called_once_with(2) + runner.config.task_delay.assert_called_once_with(target=recovered) runner.config.task_call.assert_not_called() runner.config.update.assert_not_called() @@ -177,10 +188,12 @@ def test_withdrawal_delays_both_configured_fleets(self): fleet_2.get_recovered.return_value = second_recovered emotion = Mock(using_public=False) emotion.fleets = [fleet_1, fleet_2] + emotion.get_recovered_for_battle.return_value = second_recovered campaign = SimpleNamespace( low_emotion_withdrawn=True, emotion=emotion, withdraw=Mock(side_effect=CampaignEnd('Withdraw')), + _map_battle=6, ) runner = CampaignRun.__new__(CampaignRun) runner.__dict__['campaign'] = campaign @@ -193,6 +206,7 @@ def test_withdrawal_delays_both_configured_fleets(self): self.assertEqual(fleet_1.current, 0) self.assertEqual(fleet_2.current, 0) + emotion.get_recovered_for_battle.assert_called_once_with(6) runner.config.task_delay.assert_called_once_with(target=second_recovered) def test_next_event_task_follows_scheduler_priority_configuration(self): @@ -204,6 +218,23 @@ def test_next_event_task_follows_scheduler_priority_configuration(self): self.assertEqual(runner.get_low_emotion_next_event_task('Event'), 'Event2') self.assertIsNone(runner.get_low_emotion_next_event_task('Event2')) + def test_recovery_for_battle_includes_next_sortie_emotion_cost(self): + recovered = datetime(2026, 8, 14, 12, 0, 0) + public_fleet = Mock() + public_fleet.get_recovered.return_value = recovered + emotion = Emotion.__new__(Emotion) + emotion.config = SimpleNamespace(Campaign_Use2xBook=False) + emotion.using_public = True + emotion.public_fleet = public_fleet + emotion.map_is_2x_book = False + emotion.update = Mock() + emotion.record = Mock() + emotion.show = Mock() + + self.assertEqual(emotion.get_recovered_for_battle(5), recovered) + + public_fleet.get_recovered.assert_called_once_with(10) + def test_withdraw_processes_battle_result_before_waiting_for_stage(self): operation = MapOperation.__new__(MapOperation) operation.device = SimpleNamespace(screenshot=Mock()) From 59682181b148c2e097bbd1c452b770ef31e208b0 Mon Sep 17 00:00:00 2001 From: a2893005741 Date: Fri, 14 Aug 2026 19:37:48 +0800 Subject: [PATCH 06/12] =?UTF-8?q?fix(map):=20=E5=85=BC=E5=AE=B9=E9=9D=9E?= =?UTF-8?q?=E6=88=98=E6=96=97=E4=BB=BB=E5=8A=A1=E6=92=A4=E9=80=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- module/map/map_operation.py | 17 ++++++++++------- tests/test_low_emotion_withdraw.py | 9 +++++++++ 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/module/map/map_operation.py b/module/map/map_operation.py index 67f1e9fcb..ece589a6d 100644 --- a/module/map/map_operation.py +++ b/module/map/map_operation.py @@ -45,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 @@ -494,13 +500,10 @@ def handle_enter_map_preparation_cancel(self): def handle_withdraw_result(self): """推进撤退过程中可能出现的战斗结算页面。""" - for handler in ( - self.handle_battle_status, - self.handle_exp_info, - self.handle_get_ship, - self.handle_get_items, - ): - if handler(): + # MapOperation 也由不混入 Combat 的任务复用,结算处理器在该场景下是可选的。 + 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') diff --git a/tests/test_low_emotion_withdraw.py b/tests/test_low_emotion_withdraw.py index 5a9228bfa..6c20e924d 100644 --- a/tests/test_low_emotion_withdraw.py +++ b/tests/test_low_emotion_withdraw.py @@ -13,6 +13,7 @@ from module.campaign.campaign_base import CampaignBase from module.campaign.run import CampaignRun from module.combat.emotion import Emotion +from module.event.maritime_escort import MaritimeEscort from module.exception import CampaignEnd, RequestHumanTakeover from module.map.assets import FLEET_PREPARATION, MAP_PREPARATION, MAP_PREPARATION_CANCEL from module.map.map_operation import MapOperation @@ -286,6 +287,14 @@ def test_handle_withdraw_result_falls_back_to_combat_status_popup(self): operation.handle_popup_confirm.assert_called_once_with('COMBAT_STATUS') + def test_withdraw_result_supports_non_combat_maritime_escort(self): + escort = MaritimeEscort.__new__(MaritimeEscort) + escort.handle_popup_confirm = Mock(return_value=True) + + self.assertTrue(escort.handle_withdraw_result()) + + escort.handle_popup_confirm.assert_called_once_with('COMBAT_STATUS') + if __name__ == '__main__': unittest.main() From 0c297f615f0cd5456d615b7d321352a72f574423 Mon Sep 17 00:00:00 2001 From: a2893005741 Date: Fri, 14 Aug 2026 19:52:01 +0800 Subject: [PATCH 07/12] =?UTF-8?q?fix(emotion):=20=E9=9B=86=E4=B8=AD?= =?UTF-8?q?=E6=83=85=E7=BB=AA=E7=8A=B6=E6=80=81=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- module/campaign/run.py | 1 + module/combat/emotion.py | 11 +++-------- tests/test_low_emotion_withdraw.py | 22 +++++++++++++++++++++- 3 files changed, 25 insertions(+), 9 deletions(-) diff --git a/module/campaign/run.py b/module/campaign/run.py index e1c41cdac..85177d600 100644 --- a/module/campaign/run.py +++ b/module/campaign/run.py @@ -464,6 +464,7 @@ def handle_low_emotion_withdrawal(self): 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) diff --git a/module/combat/emotion.py b/module/combat/emotion.py index c54ae4fff..bbddabf7e 100644 --- a/module/combat/emotion.py +++ b/module/combat/emotion.py @@ -345,10 +345,6 @@ def get_recovered_for_battle(self, battle): if self.using_public: reduce = battle * self.reduce_per_battle_before_entering logger.info(f'[情绪-检查] 预期情绪扣减: {reduce}') - - self.update() - self.record() - self.show() return self.public_fleet.get_recovered(reduce) method = self.config.Fleet_FleetOrder @@ -366,14 +362,13 @@ def get_recovered_for_battle(self, battle): battle = tuple(np.array(battle) * self.reduce_per_battle_before_entering) logger.info(f'[情绪-检查] 预期情绪扣减: {battle}') - - self.update() - self.record() - self.show() 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 = self.get_recovered_for_battle(battle) return recovered, recovered > current_time() diff --git a/tests/test_low_emotion_withdraw.py b/tests/test_low_emotion_withdraw.py index 6c20e924d..4925904c1 100644 --- a/tests/test_low_emotion_withdraw.py +++ b/tests/test_low_emotion_withdraw.py @@ -1,7 +1,7 @@ from datetime import datetime from types import SimpleNamespace import unittest -from unittest.mock import Mock +from unittest.mock import Mock, patch # 本地旧虚拟环境的 RapidOCR 仍未提供源码要求的 PPOCRV6 枚举。 # 仅为加载待测战役模块补齐别名,不影响生产运行时的依赖版本。 @@ -75,6 +75,7 @@ def test_withdrawal_delays_current_task_to_emotion_recovery(self): campaign.withdraw.assert_called_once_with(skip_first_screenshot=False) emotion.update.assert_called_once_with() emotion.record.assert_called_once_with() + emotion.show.assert_called_once_with() self.assertEqual(fleet.current, 0) emotion.get_recovered_for_battle.assert_called_once_with(5) runner.config.task_delay.assert_called_once_with(target=recovered) @@ -235,6 +236,25 @@ def test_recovery_for_battle_includes_next_sortie_emotion_cost(self): self.assertEqual(emotion.get_recovered_for_battle(5), recovered) public_fleet.get_recovered.assert_called_once_with(10) + emotion.update.assert_not_called() + emotion.record.assert_not_called() + emotion.show.assert_not_called() + + def test_check_reduce_updates_and_records_emotion_once(self): + recovered = datetime(2026, 8, 14, 12, 0, 0) + emotion = Emotion.__new__(Emotion) + emotion.update = Mock() + emotion.record = Mock() + emotion.show = Mock() + emotion.get_recovered_for_battle = Mock(return_value=recovered) + + with patch('module.combat.emotion.current_time', return_value=datetime(2026, 8, 14, 13, 0, 0)): + self.assertEqual(emotion._check_reduce(5), (recovered, False)) + + emotion.update.assert_called_once_with() + emotion.record.assert_called_once_with() + emotion.show.assert_called_once_with() + emotion.get_recovered_for_battle.assert_called_once_with(5) def test_withdraw_processes_battle_result_before_waiting_for_stage(self): operation = MapOperation.__new__(MapOperation) From d4128669d2d4a6d96958ea4250e2fa3d2ce05e52 Mon Sep 17 00:00:00 2001 From: a2893005741 Date: Sun, 16 Aug 2026 20:16:08 +0800 Subject: [PATCH 08/12] =?UTF-8?q?fix(campaign):=20=E4=B8=BB=E7=BA=BF?= =?UTF-8?q?=E4=BD=8E=E5=BF=83=E6=83=85=E6=92=A4=E9=80=80=E5=90=8E=E5=88=87?= =?UTF-8?q?=E6=8D=A2=E5=90=8E=E7=BB=AD=E4=BB=BB=E5=8A=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- module/campaign/run.py | 32 +++++++++++++++++--------- tests/test_low_emotion_withdraw.py | 36 ++++++++++++++++++++++++++---- 2 files changed, 54 insertions(+), 14 deletions(-) diff --git a/module/campaign/run.py b/module/campaign/run.py index 85177d600..70845173e 100644 --- a/module/campaign/run.py +++ b/module/campaign/run.py @@ -419,20 +419,32 @@ def after_campaign_run(self): """单次战役完成后的扩展钩子。""" pass - def get_low_emotion_next_event_task(self, task): - """从任务优先级配置中获取当前活动图的后继活动图任务。""" - event_tasks = [ + def get_low_emotion_next_campaign_task(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 None + + campaign_tasks = [ candidate for candidate in parse_task_priority(self.config.SCHEDULER_PRIORITY) - if candidate == 'Event' or (candidate.startswith('Event') and candidate[5:].isdigit()) + if candidate == task_prefix + or (candidate.startswith(task_prefix) and candidate[len(task_prefix):].isdigit()) ] try: - return event_tasks[event_tasks.index(task) + 1] + return campaign_tasks[campaign_tasks.index(task) + 1] except (IndexError, ValueError): return None def handle_low_emotion_withdrawal(self): - """延后因低心情撤退的当前任务,并切换到后续活动图。""" + """延后因低心情撤退的当前任务,并切换到后续同系列战役图。""" if not getattr(self.campaign, 'low_emotion_withdrawn', False): return False @@ -473,10 +485,10 @@ def handle_low_emotion_withdrawal(self): ) self.config.task_delay(target=recovered) - # 后继活动图由用户的任务优先级配置决定;最后一张活动图交由调度器处理。 - next_event = self.get_low_emotion_next_event_task(task) - if next_event and self.config.task_call(next_event, force_call=False): - logger.info(f'[低心情] {task} 已撤退,立即切换到 {next_event}') + # 后继同系列战役图由用户的任务优先级配置决定;最后一张图交由调度器处理。 + next_task = self.get_low_emotion_next_campaign_task(task) + if next_task and self.config.task_call(next_task, force_call=False): + logger.info(f'[低心情] {task} 已撤退,立即切换到 {next_task}') self.config.update() self.campaign.low_emotion_withdrawn = False diff --git a/tests/test_low_emotion_withdraw.py b/tests/test_low_emotion_withdraw.py index 4925904c1..2abbe84c5 100644 --- a/tests/test_low_emotion_withdraw.py +++ b/tests/test_low_emotion_withdraw.py @@ -21,6 +21,7 @@ class TestLowEmotionWithdraw(unittest.TestCase): EVENT_PRIORITY = 'Event > Event2 > Event3' + MAIN_PRIORITY = 'Main > Main2 > Main3' def test_calculate_mode_cancels_then_exits_combat_preparation(self): campaign = CampaignBase.__new__(CampaignBase) @@ -108,6 +109,31 @@ def test_withdrawal_from_second_event_runs_third_event(self): runner.config.update.assert_called_once_with() emotion.get_recovered_for_battle.assert_called_once_with(4) + def test_withdrawal_from_main_runs_next_main_task(self): + recovered = datetime(2026, 8, 14, 12, 0, 0) + fleet = Mock() + emotion = Mock(using_public=False) + emotion.fleets = [fleet] + emotion.get_recovered_for_battle.return_value = recovered + campaign = SimpleNamespace( + low_emotion_withdrawn=True, + emotion=emotion, + withdraw=Mock(side_effect=CampaignEnd('Withdraw')), + _map_battle=4, + ) + runner = CampaignRun.__new__(CampaignRun) + runner.__dict__['campaign'] = campaign + runner.__dict__['config'] = Mock() + runner.config.task.command = 'Main' + runner.config.FLEET_2 = 0 + runner.config.SCHEDULER_PRIORITY = self.MAIN_PRIORITY + + self.assertTrue(runner.handle_low_emotion_withdrawal()) + + runner.config.task_delay.assert_called_once_with(target=recovered) + runner.config.task_call.assert_called_once_with('Main2', force_call=False) + runner.config.update.assert_called_once_with() + def test_withdrawal_uses_public_fleet_emotion(self): recovered = datetime(2026, 8, 14, 12, 0, 0) public_fleet = Mock(fleet='Public', current=75) @@ -211,14 +237,16 @@ def test_withdrawal_delays_both_configured_fleets(self): emotion.get_recovered_for_battle.assert_called_once_with(6) runner.config.task_delay.assert_called_once_with(target=second_recovered) - def test_next_event_task_follows_scheduler_priority_configuration(self): + def test_next_campaign_task_follows_scheduler_priority_configuration(self): runner = CampaignRun.__new__(CampaignRun) runner.__dict__['config'] = SimpleNamespace( - SCHEDULER_PRIORITY='Event3 > Main > Event > Event2 > Raid' + SCHEDULER_PRIORITY='Event3 > Main > Event > Main2 > Event2 > Raid > Main3' ) - self.assertEqual(runner.get_low_emotion_next_event_task('Event'), 'Event2') - self.assertIsNone(runner.get_low_emotion_next_event_task('Event2')) + self.assertEqual(runner.get_low_emotion_next_campaign_task('Event'), 'Event2') + self.assertIsNone(runner.get_low_emotion_next_campaign_task('Event2')) + self.assertEqual(runner.get_low_emotion_next_campaign_task('Main'), 'Main2') + self.assertEqual(runner.get_low_emotion_next_campaign_task('Main2'), 'Main3') def test_recovery_for_battle_includes_next_sortie_emotion_cost(self): recovered = datetime(2026, 8, 14, 12, 0, 0) From 5a98c4fe407bfcaf63e498b8ab7015da9d9aad72 Mon Sep 17 00:00:00 2001 From: a2893005741 Date: Sun, 16 Aug 2026 20:26:19 +0800 Subject: [PATCH 09/12] =?UTF-8?q?fix(withdraw):=20=E7=A7=BB=E9=99=A4?= =?UTF-8?q?=E6=92=A4=E9=80=80=E7=BB=93=E7=AE=97=E5=9B=BA=E5=AE=9A=E7=AD=89?= =?UTF-8?q?=E5=BE=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- module/campaign/gems_farming.py | 10 +++++--- module/combat/combat.py | 27 +++++++++++++-------- module/map/map_operation.py | 14 +++++++---- tests/test_low_emotion_withdraw.py | 38 ++++++++++++++++++++++++++++++ 4 files changed, 71 insertions(+), 18 deletions(-) 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/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/map/map_operation.py b/module/map/map_operation.py index ece589a6d..368dccab6 100644 --- a/module/map/map_operation.py +++ b/module/map/map_operation.py @@ -501,11 +501,15 @@ def handle_enter_map_preparation_cancel(self): def handle_withdraw_result(self): """推进撤退过程中可能出现的战斗结算页面。""" # MapOperation 也由不混入 Combat 的任务复用,结算处理器在该场景下是可选的。 - 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') + 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/tests/test_low_emotion_withdraw.py b/tests/test_low_emotion_withdraw.py index 2abbe84c5..c3164dc36 100644 --- a/tests/test_low_emotion_withdraw.py +++ b/tests/test_low_emotion_withdraw.py @@ -11,7 +11,9 @@ OCRVersion.PPOCRV6 = OCRVersion.PPOCRV5 from module.campaign.campaign_base import CampaignBase +from module.campaign.gems_farming import GemsCampaignOverride from module.campaign.run import CampaignRun +from module.combat.combat import Combat from module.combat.emotion import Emotion from module.event.maritime_escort import MaritimeEscort from module.exception import CampaignEnd, RequestHumanTakeover @@ -335,6 +337,42 @@ def test_handle_withdraw_result_falls_back_to_combat_status_popup(self): operation.handle_popup_confirm.assert_called_once_with('COMBAT_STATUS') + def test_withdraw_result_marks_handlers_as_non_blocking(self): + operation = MapOperation.__new__(MapOperation) + operation.handle_battle_status = Mock( + side_effect=lambda: operation._withdraw_result_processing + ) + operation.handle_popup_confirm = Mock() + + self.assertTrue(operation.handle_withdraw_result()) + + self.assertFalse(operation._withdraw_result_processing) + operation.handle_popup_confirm.assert_not_called() + + def test_combat_result_handlers_skip_sleep_during_withdrawal(self): + combat = Combat.__new__(Combat) + combat.device = SimpleNamespace(click=Mock(), sleep=Mock()) + combat.is_combat_executing = Mock(return_value=False) + combat.appear = Mock(return_value=True) + combat.appear_then_click = Mock(return_value=True) + combat._withdraw_result_processing = True + + self.assertTrue(combat.handle_battle_status()) + self.assertTrue(combat.handle_exp_info()) + + combat.device.sleep.assert_not_called() + + def test_gems_exp_result_skips_sleep_during_withdrawal(self): + campaign = GemsCampaignOverride.__new__(GemsCampaignOverride) + campaign.device = SimpleNamespace(sleep=Mock()) + campaign.is_combat_executing = Mock(return_value=False) + campaign.appear_then_click = Mock(return_value=True) + campaign._withdraw_result_processing = True + + self.assertTrue(campaign.handle_exp_info()) + + campaign.device.sleep.assert_not_called() + def test_withdraw_result_supports_non_combat_maritime_escort(self): escort = MaritimeEscort.__new__(MaritimeEscort) escort.handle_popup_confirm = Mock(return_value=True) From bfb927ddb2e2d61926ca50c6f3bbf3250c4426da Mon Sep 17 00:00:00 2001 From: a2893005741 Date: Sun, 16 Aug 2026 20:34:20 +0800 Subject: [PATCH 10/12] =?UTF-8?q?test(campaign):=20=E6=92=A4=E9=94=80?= =?UTF-8?q?=E4=BD=8E=E5=BF=83=E6=83=85=E6=92=A4=E9=80=80=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=E6=96=87=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_low_emotion_withdraw.py | 386 ----------------------------- 1 file changed, 386 deletions(-) delete mode 100644 tests/test_low_emotion_withdraw.py diff --git a/tests/test_low_emotion_withdraw.py b/tests/test_low_emotion_withdraw.py deleted file mode 100644 index c3164dc36..000000000 --- a/tests/test_low_emotion_withdraw.py +++ /dev/null @@ -1,386 +0,0 @@ -from datetime import datetime -from types import SimpleNamespace -import unittest -from unittest.mock import Mock, patch - -# 本地旧虚拟环境的 RapidOCR 仍未提供源码要求的 PPOCRV6 枚举。 -# 仅为加载待测战役模块补齐别名,不影响生产运行时的依赖版本。 -from rapidocr import OCRVersion - -if not hasattr(OCRVersion, 'PPOCRV6'): - OCRVersion.PPOCRV6 = OCRVersion.PPOCRV5 - -from module.campaign.campaign_base import CampaignBase -from module.campaign.gems_farming import GemsCampaignOverride -from module.campaign.run import CampaignRun -from module.combat.combat import Combat -from module.combat.emotion import Emotion -from module.event.maritime_escort import MaritimeEscort -from module.exception import CampaignEnd, RequestHumanTakeover -from module.map.assets import FLEET_PREPARATION, MAP_PREPARATION, MAP_PREPARATION_CANCEL -from module.map.map_operation import MapOperation - - -class TestLowEmotionWithdraw(unittest.TestCase): - EVENT_PRIORITY = 'Event > Event2 > Event3' - MAIN_PRIORITY = 'Main > Main2 > Main3' - - def test_calculate_mode_cancels_then_exits_combat_preparation(self): - campaign = CampaignBase.__new__(CampaignBase) - campaign.config = SimpleNamespace(Emotion_Mode='calculate') - campaign.handle_popup_cancel = Mock(return_value=True) - campaign.withdraw = Mock() - - with self.assertRaises(CampaignEnd): - campaign.handle_combat_low_emotion() - - campaign.handle_popup_cancel.assert_called_once_with('IGNORE_LOW_EMOTION') - campaign.withdraw.assert_not_called() - self.assertTrue(campaign.low_emotion_withdrawn) - - def test_non_calculate_mode_keeps_original_confirm_behavior(self): - campaign = CampaignBase.__new__(CampaignBase) - campaign.config = SimpleNamespace(Emotion_Mode='calculate_ignore') - campaign.__dict__['emotion'] = SimpleNamespace(is_ignore=True) - campaign.handle_popup_cancel = Mock() - campaign.handle_popup_confirm = Mock(return_value=True) - campaign.interval_reset = Mock() - - self.assertTrue(campaign.handle_combat_low_emotion()) - - campaign.handle_popup_cancel.assert_not_called() - campaign.handle_popup_confirm.assert_called_once_with('IGNORE_LOW_EMOTION') - - def test_withdrawal_delays_current_task_to_emotion_recovery(self): - recovered = datetime(2026, 8, 14, 12, 0, 0) - fleet = Mock() - fleet.fleet = 1 - fleet.current = 75 - fleet.get_recovered.return_value = recovered - emotion = Mock(using_public=False) - emotion.fleets = [fleet, Mock()] - emotion.get_recovered_for_battle.return_value = recovered - campaign = SimpleNamespace( - low_emotion_withdrawn=True, - emotion=emotion, - withdraw=Mock(side_effect=CampaignEnd('Withdraw')), - _map_battle=5, - ) - runner = CampaignRun.__new__(CampaignRun) - runner.__dict__['campaign'] = campaign - runner.__dict__['config'] = Mock() - runner.config.task.command = 'Event' - runner.config.FLEET_2 = 0 - runner.config.SCHEDULER_PRIORITY = self.EVENT_PRIORITY - - self.assertTrue(runner.handle_low_emotion_withdrawal()) - - campaign.withdraw.assert_called_once_with(skip_first_screenshot=False) - emotion.update.assert_called_once_with() - emotion.record.assert_called_once_with() - emotion.show.assert_called_once_with() - self.assertEqual(fleet.current, 0) - emotion.get_recovered_for_battle.assert_called_once_with(5) - runner.config.task_delay.assert_called_once_with(target=recovered) - runner.config.task_call.assert_called_once_with('Event2', force_call=False) - runner.config.update.assert_called_once_with() - self.assertFalse(campaign.low_emotion_withdrawn) - - def test_withdrawal_from_second_event_runs_third_event(self): - recovered = datetime(2026, 8, 14, 12, 0, 0) - fleet = Mock() - emotion = Mock(using_public=False) - emotion.fleets = [fleet] - emotion.get_recovered_for_battle.return_value = recovered - campaign = SimpleNamespace( - low_emotion_withdrawn=True, - emotion=emotion, - withdraw=Mock(side_effect=CampaignEnd('Withdraw')), - _map_battle=4, - ) - runner = CampaignRun.__new__(CampaignRun) - runner.__dict__['campaign'] = campaign - runner.__dict__['config'] = Mock() - runner.config.task.command = 'Event2' - runner.config.FLEET_2 = 0 - runner.config.SCHEDULER_PRIORITY = self.EVENT_PRIORITY - - self.assertTrue(runner.handle_low_emotion_withdrawal()) - - runner.config.task_call.assert_called_once_with('Event3', force_call=False) - runner.config.update.assert_called_once_with() - emotion.get_recovered_for_battle.assert_called_once_with(4) - - def test_withdrawal_from_main_runs_next_main_task(self): - recovered = datetime(2026, 8, 14, 12, 0, 0) - fleet = Mock() - emotion = Mock(using_public=False) - emotion.fleets = [fleet] - emotion.get_recovered_for_battle.return_value = recovered - campaign = SimpleNamespace( - low_emotion_withdrawn=True, - emotion=emotion, - withdraw=Mock(side_effect=CampaignEnd('Withdraw')), - _map_battle=4, - ) - runner = CampaignRun.__new__(CampaignRun) - runner.__dict__['campaign'] = campaign - runner.__dict__['config'] = Mock() - runner.config.task.command = 'Main' - runner.config.FLEET_2 = 0 - runner.config.SCHEDULER_PRIORITY = self.MAIN_PRIORITY - - self.assertTrue(runner.handle_low_emotion_withdrawal()) - - runner.config.task_delay.assert_called_once_with(target=recovered) - runner.config.task_call.assert_called_once_with('Main2', force_call=False) - runner.config.update.assert_called_once_with() - - def test_withdrawal_uses_public_fleet_emotion(self): - recovered = datetime(2026, 8, 14, 12, 0, 0) - public_fleet = Mock(fleet='Public', current=75) - public_fleet.get_recovered.return_value = recovered - fleet_1 = Mock(fleet=1, current=80) - fleet_2 = Mock(fleet=2, current=90) - emotion = Mock(using_public=True, public_fleet=public_fleet) - emotion.fleets = [fleet_1, fleet_2] - emotion.get_recovered_for_battle.return_value = recovered - campaign = SimpleNamespace( - low_emotion_withdrawn=True, - emotion=emotion, - withdraw=Mock(side_effect=CampaignEnd('Withdraw')), - _map_battle=3, - ) - runner = CampaignRun.__new__(CampaignRun) - runner.__dict__['campaign'] = campaign - runner.__dict__['config'] = Mock() - runner.config.task.command = 'Event3' - runner.config.FLEET_2 = 2 - - self.assertTrue(runner.handle_low_emotion_withdrawal()) - - self.assertEqual(public_fleet.current, 0) - self.assertEqual(fleet_1.current, 80) - self.assertEqual(fleet_2.current, 90) - emotion.get_recovered_for_battle.assert_called_once_with(3) - runner.config.task_delay.assert_called_once_with(target=recovered) - - def test_withdrawal_without_fleet_record_requires_human_takeover(self): - emotion = Mock(using_public=False) - emotion.fleets = [] - campaign = SimpleNamespace( - low_emotion_withdrawn=True, - emotion=emotion, - withdraw=Mock(side_effect=CampaignEnd('Withdraw')), - ) - runner = CampaignRun.__new__(CampaignRun) - runner.__dict__['campaign'] = campaign - runner.__dict__['config'] = Mock() - runner.config.task.command = 'Event' - runner.config.FLEET_2 = 0 - - with self.assertRaises(RequestHumanTakeover): - runner.handle_low_emotion_withdrawal() - - runner.config.task_delay.assert_not_called() - - def test_withdrawal_from_third_event_returns_to_scheduler_queue(self): - recovered = datetime(2026, 8, 14, 12, 0, 0) - fleet = Mock() - emotion = Mock(using_public=False) - emotion.fleets = [fleet] - emotion.get_recovered_for_battle.return_value = recovered - campaign = SimpleNamespace( - low_emotion_withdrawn=True, - emotion=emotion, - withdraw=Mock(side_effect=CampaignEnd('Withdraw')), - _map_battle=2, - ) - runner = CampaignRun.__new__(CampaignRun) - runner.__dict__['campaign'] = campaign - runner.__dict__['config'] = Mock() - runner.config.task.command = 'Event3' - runner.config.FLEET_2 = 0 - - self.assertTrue(runner.handle_low_emotion_withdrawal()) - - emotion.get_recovered_for_battle.assert_called_once_with(2) - runner.config.task_delay.assert_called_once_with(target=recovered) - runner.config.task_call.assert_not_called() - runner.config.update.assert_not_called() - - def test_withdrawal_delays_both_configured_fleets(self): - first_recovered = datetime(2026, 8, 14, 12, 0, 0) - second_recovered = datetime(2026, 8, 14, 12, 12, 0) - fleet_1 = Mock(fleet=1, current=75) - fleet_1.get_recovered.return_value = first_recovered - fleet_2 = Mock(fleet=2, current=90) - fleet_2.get_recovered.return_value = second_recovered - emotion = Mock(using_public=False) - emotion.fleets = [fleet_1, fleet_2] - emotion.get_recovered_for_battle.return_value = second_recovered - campaign = SimpleNamespace( - low_emotion_withdrawn=True, - emotion=emotion, - withdraw=Mock(side_effect=CampaignEnd('Withdraw')), - _map_battle=6, - ) - runner = CampaignRun.__new__(CampaignRun) - runner.__dict__['campaign'] = campaign - runner.__dict__['config'] = Mock() - runner.config.task.command = 'Event' - runner.config.FLEET_2 = 2 - runner.config.SCHEDULER_PRIORITY = self.EVENT_PRIORITY - - self.assertTrue(runner.handle_low_emotion_withdrawal()) - - self.assertEqual(fleet_1.current, 0) - self.assertEqual(fleet_2.current, 0) - emotion.get_recovered_for_battle.assert_called_once_with(6) - runner.config.task_delay.assert_called_once_with(target=second_recovered) - - def test_next_campaign_task_follows_scheduler_priority_configuration(self): - runner = CampaignRun.__new__(CampaignRun) - runner.__dict__['config'] = SimpleNamespace( - SCHEDULER_PRIORITY='Event3 > Main > Event > Main2 > Event2 > Raid > Main3' - ) - - self.assertEqual(runner.get_low_emotion_next_campaign_task('Event'), 'Event2') - self.assertIsNone(runner.get_low_emotion_next_campaign_task('Event2')) - self.assertEqual(runner.get_low_emotion_next_campaign_task('Main'), 'Main2') - self.assertEqual(runner.get_low_emotion_next_campaign_task('Main2'), 'Main3') - - def test_recovery_for_battle_includes_next_sortie_emotion_cost(self): - recovered = datetime(2026, 8, 14, 12, 0, 0) - public_fleet = Mock() - public_fleet.get_recovered.return_value = recovered - emotion = Emotion.__new__(Emotion) - emotion.config = SimpleNamespace(Campaign_Use2xBook=False) - emotion.using_public = True - emotion.public_fleet = public_fleet - emotion.map_is_2x_book = False - emotion.update = Mock() - emotion.record = Mock() - emotion.show = Mock() - - self.assertEqual(emotion.get_recovered_for_battle(5), recovered) - - public_fleet.get_recovered.assert_called_once_with(10) - emotion.update.assert_not_called() - emotion.record.assert_not_called() - emotion.show.assert_not_called() - - def test_check_reduce_updates_and_records_emotion_once(self): - recovered = datetime(2026, 8, 14, 12, 0, 0) - emotion = Emotion.__new__(Emotion) - emotion.update = Mock() - emotion.record = Mock() - emotion.show = Mock() - emotion.get_recovered_for_battle = Mock(return_value=recovered) - - with patch('module.combat.emotion.current_time', return_value=datetime(2026, 8, 14, 13, 0, 0)): - self.assertEqual(emotion._check_reduce(5), (recovered, False)) - - emotion.update.assert_called_once_with() - emotion.record.assert_called_once_with() - emotion.show.assert_called_once_with() - emotion.get_recovered_for_battle.assert_called_once_with(5) - - def test_withdraw_processes_battle_result_before_waiting_for_stage(self): - operation = MapOperation.__new__(MapOperation) - operation.device = SimpleNamespace(screenshot=Mock()) - operation.handle_battle_status = Mock(side_effect=[True, False]) - operation.handle_exp_info = Mock(return_value=False) - operation.handle_get_ship = Mock(return_value=False) - operation.handle_get_items = Mock(return_value=False) - operation.handle_popup_confirm = Mock(return_value=False) - operation.appear_then_click = Mock(return_value=False) - operation.handle_auto_search_exit = Mock(return_value=False) - operation.appear = Mock(return_value=False) - operation.handle_in_stage = Mock(return_value=True) - - with self.assertRaises(CampaignEnd): - operation.withdraw(skip_first_screenshot=False) - - operation.handle_battle_status.assert_called() - operation.handle_in_stage.assert_called_once_with() - - def test_withdraw_exits_battle_preparation_before_opening_withdraw_menu(self): - operation = MapOperation.__new__(MapOperation) - operation.device = SimpleNamespace(click=Mock()) - operation.appear = Mock(return_value=True) - - self.assertTrue(operation.handle_withdraw_battle_preparation()) - - operation.device.click.assert_called_once() - - def test_withdraw_exits_initial_preparation_pages(self): - for preparation_page in (MAP_PREPARATION, FLEET_PREPARATION): - with self.subTest(preparation_page=preparation_page): - operation = MapOperation.__new__(MapOperation) - operation.device = SimpleNamespace(click=Mock()) - operation.appear = Mock(side_effect=lambda button, **_: button is preparation_page) - - self.assertTrue(operation.handle_enter_map_preparation_cancel()) - - operation.device.click.assert_called_once_with(MAP_PREPARATION_CANCEL) - - def test_handle_withdraw_result_falls_back_to_combat_status_popup(self): - operation = MapOperation.__new__(MapOperation) - operation.handle_battle_status = Mock(return_value=False) - operation.handle_exp_info = Mock(return_value=False) - operation.handle_get_ship = Mock(return_value=False) - operation.handle_get_items = Mock(return_value=False) - operation.handle_popup_confirm = Mock(return_value=True) - - self.assertTrue(operation.handle_withdraw_result()) - - operation.handle_popup_confirm.assert_called_once_with('COMBAT_STATUS') - - def test_withdraw_result_marks_handlers_as_non_blocking(self): - operation = MapOperation.__new__(MapOperation) - operation.handle_battle_status = Mock( - side_effect=lambda: operation._withdraw_result_processing - ) - operation.handle_popup_confirm = Mock() - - self.assertTrue(operation.handle_withdraw_result()) - - self.assertFalse(operation._withdraw_result_processing) - operation.handle_popup_confirm.assert_not_called() - - def test_combat_result_handlers_skip_sleep_during_withdrawal(self): - combat = Combat.__new__(Combat) - combat.device = SimpleNamespace(click=Mock(), sleep=Mock()) - combat.is_combat_executing = Mock(return_value=False) - combat.appear = Mock(return_value=True) - combat.appear_then_click = Mock(return_value=True) - combat._withdraw_result_processing = True - - self.assertTrue(combat.handle_battle_status()) - self.assertTrue(combat.handle_exp_info()) - - combat.device.sleep.assert_not_called() - - def test_gems_exp_result_skips_sleep_during_withdrawal(self): - campaign = GemsCampaignOverride.__new__(GemsCampaignOverride) - campaign.device = SimpleNamespace(sleep=Mock()) - campaign.is_combat_executing = Mock(return_value=False) - campaign.appear_then_click = Mock(return_value=True) - campaign._withdraw_result_processing = True - - self.assertTrue(campaign.handle_exp_info()) - - campaign.device.sleep.assert_not_called() - - def test_withdraw_result_supports_non_combat_maritime_escort(self): - escort = MaritimeEscort.__new__(MaritimeEscort) - escort.handle_popup_confirm = Mock(return_value=True) - - self.assertTrue(escort.handle_withdraw_result()) - - escort.handle_popup_confirm.assert_called_once_with('COMBAT_STATUS') - - -if __name__ == '__main__': - unittest.main() From 4a3de48089a6be058e842ca8a75cd79a2ed90a6e Mon Sep 17 00:00:00 2001 From: a2893005741 Date: Sun, 16 Aug 2026 20:37:33 +0800 Subject: [PATCH 11/12] =?UTF-8?q?fix(campaign):=20=E8=B7=B3=E8=BF=87?= =?UTF-8?q?=E7=A6=81=E7=94=A8=E7=9A=84=E4=BD=8E=E5=BF=83=E6=83=85=E5=90=8E?= =?UTF-8?q?=E7=BB=A7=E4=BB=BB=E5=8A=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- module/campaign/run.py | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/module/campaign/run.py b/module/campaign/run.py index 70845173e..3a08392e5 100644 --- a/module/campaign/run.py +++ b/module/campaign/run.py @@ -419,8 +419,8 @@ def after_campaign_run(self): """单次战役完成后的扩展钩子。""" pass - def get_low_emotion_next_campaign_task(self, task): - """从任务优先级配置中获取当前战役系列的后继任务。""" + def get_low_emotion_next_campaign_tasks(self, task): + """从任务优先级配置中获取当前战役系列的后续任务。""" task_prefix = next( ( prefix @@ -439,9 +439,9 @@ def get_low_emotion_next_campaign_task(self, task): or (candidate.startswith(task_prefix) and candidate[len(task_prefix):].isdigit()) ] try: - return campaign_tasks[campaign_tasks.index(task) + 1] - except (IndexError, ValueError): - return None + return campaign_tasks[campaign_tasks.index(task) + 1:] + except ValueError: + return [] def handle_low_emotion_withdrawal(self): """延后因低心情撤退的当前任务,并切换到后续同系列战役图。""" @@ -485,11 +485,13 @@ def handle_low_emotion_withdrawal(self): ) self.config.task_delay(target=recovered) - # 后继同系列战役图由用户的任务优先级配置决定;最后一张图交由调度器处理。 - next_task = self.get_low_emotion_next_campaign_task(task) - if next_task and self.config.task_call(next_task, force_call=False): - logger.info(f'[低心情] {task} 已撤退,立即切换到 {next_task}') - self.config.update() + # 后继同系列战役图由用户的任务优先级配置决定,跳过用户禁用的任务。 + # 所有后继图不可调用时,最后一张图交由调度器处理。 + 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 From af503a7204390a3963889276bdba3de8709f46d2 Mon Sep 17 00:00:00 2001 From: a2893005741 Date: Sun, 16 Aug 2026 20:48:03 +0800 Subject: [PATCH 12/12] =?UTF-8?q?fix(campaign):=20=E9=9D=9E=E7=B3=BB?= =?UTF-8?q?=E5=88=97=E6=88=98=E5=BD=B9=E8=BF=94=E5=9B=9E=E7=A9=BA=E5=90=8E?= =?UTF-8?q?=E7=BB=A7=E4=BB=BB=E5=8A=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- module/campaign/run.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/module/campaign/run.py b/module/campaign/run.py index 3a08392e5..a67ff44d6 100644 --- a/module/campaign/run.py +++ b/module/campaign/run.py @@ -430,7 +430,7 @@ def get_low_emotion_next_campaign_tasks(self, task): None, ) if task_prefix is None: - return None + return [] campaign_tasks = [ candidate