diff --git a/module/base/emotion.py b/module/base/emotion.py new file mode 100644 index 000000000..5f38670b5 --- /dev/null +++ b/module/base/emotion.py @@ -0,0 +1,35 @@ +"""心情恢复的共享规则和纯计算函数。""" + +SECONDS_PER_TICK = 6 * 60 + +DIC_RECOVER = { + 'not_in_dormitory': 20, + 'dormitory_floor_1': 40, + 'dormitory_floor_2': 50, +} +DIC_RECOVER_MAX = { + 'not_in_dormitory': 119, + 'dormitory_floor_1': 150, + 'dormitory_floor_2': 150, +} +OATH_RECOVER = 10 +ONSEN_RECOVER = 10 + + +def emotion_recovery_speed(recover, oath=False, onsen=False): + """返回每个 6 分钟周期恢复的心情点数。""" + speed = DIC_RECOVER[recover] + if oath: + speed += OATH_RECOVER + if onsen: + speed += ONSEN_RECOVER + return speed // 10 + + +def calculate_emotion_recovery(value, recover, elapsed, oath=False, onsen=False): + """根据经过秒数返回当前心情值和未满一点的恢复余数。""" + speed = emotion_recovery_speed(recover, oath=oath, onsen=onsen) + recovery = speed * max(elapsed, 0) / SECONDS_PER_TICK + recovered_points = int(recovery) + current = min(max(int(value), 0) + recovered_points, DIC_RECOVER_MAX[recover]) + return current, recovery - recovered_points diff --git a/module/combat/emotion.py b/module/combat/emotion.py index bbddabf7e..be1ca21df 100644 --- a/module/combat/emotion.py +++ b/module/combat/emotion.py @@ -17,14 +17,19 @@ 游戏客户端存在已知 bug:长时间运行后情绪计算不准确,需要定期重启。 """ -from datetime import datetime, timedelta +from datetime import timedelta from time import sleep import numpy as np from module.base.decorator import cached_property +from module.base.emotion import ( + DIC_RECOVER_MAX, + SECONDS_PER_TICK, + calculate_emotion_recovery, + emotion_recovery_speed, +) from module.base.utils import random_normal_distribution_int -from module.config.config import AzurLaneConfig from module.config.time_source import now as current_time from module.exception import ScriptEnd, ScriptError, RequestHumanTakeover from module.logger import logger @@ -36,22 +41,6 @@ 'prevent_yellow_face': 30, # 防止黄脸 'prevent_red_face': 2, # 防止红脸 } -# 情绪恢复速度:每 6 分钟恢复的点数 -DIC_RECOVER = { - 'not_in_dormitory': 20, # 港区休息 - 'dormitory_floor_1': 40, # 后宅一楼 - 'dormitory_floor_2': 50, # 后宅二楼 -} -# 情绪上限 -DIC_RECOVER_MAX = { - 'not_in_dormitory': 119, - 'dormitory_floor_1': 150, - 'dormitory_floor_2': 150, -} -OATH_RECOVER = 10 # 誓约额外恢复速度 -ONSEN_RECOVER = 10 # 温泉额外恢复速度 - - class FleetEmotion: """单个舰队的情绪追踪器。 @@ -142,12 +131,7 @@ def speed(self): Returns: int: 每 6 分钟的恢复速度。 """ - speed = DIC_RECOVER[self.recover] - if self.oath: - speed += OATH_RECOVER - if self.onsen: - speed += ONSEN_RECOVER - return speed // 10 + return emotion_recovery_speed(self.recover, self.oath, self.onsen) @property def limit(self): @@ -176,12 +160,15 @@ def update(self): 符合情绪控制的安全方向(宁可低估也不高估)。 """ time_diff = current_time().timestamp() - self.record.timestamp() - time_diff = max(time_diff, 0) - # speed 为每360秒的恢复量,换算为每秒恢复 speed/360 点 - recovery = self.speed * time_diff / 360 - self.current = min(max(self.value, 0) + int(recovery), self.max) + self.current, fractional = calculate_emotion_recovery( + self.value, + self.recover, + time_diff, + oath=self.oath, + onsen=self.onsen, + ) # 保留未满1点的恢复余数对应的秒数,用于 record() 回扣 - self._fractional_seconds = recovery - int(recovery) + self._fractional_seconds = fractional def get_recovered(self, expected_reduce=0): """计算情绪恢复到控制阈值的时间。 @@ -205,8 +192,8 @@ def get_recovered(self, expected_reduce=0): emotion_needed = self.limit + expected_reduce - self.current if emotion_needed <= 0: return current_time() - # speed 为每360秒的恢复量,换算恢复所需秒数 - seconds_needed = emotion_needed * 360 / self.speed + # speed 为每个恢复周期的恢复量,换算恢复所需秒数 + seconds_needed = emotion_needed * SECONDS_PER_TICK / self.speed return current_time() + timedelta(seconds=seconds_needed) class Emotion: @@ -291,7 +278,7 @@ def record(self): fractional = getattr(fleet, '_fractional_seconds', 0) if fractional > 0: # 回扣 fractional_seconds 对应的秒数 - record_time = record_time - timedelta(seconds=fractional * 360 / fleet.speed) + record_time = record_time - timedelta(seconds=fractional * SECONDS_PER_TICK / fleet.speed) with self.config.multi_set(): setattr(self.config, fleet.value_name, new_value) setattr(self.config, fleet.value_name.replace('Value', 'Record'), record_time) @@ -303,7 +290,7 @@ def record(self): record_time = current_time().replace(microsecond=0) fractional = getattr(fleet, '_fractional_seconds', 0) if fractional > 0: - record_time = record_time - timedelta(seconds=fractional * 360 / fleet.speed) + record_time = record_time - timedelta(seconds=fractional * SECONDS_PER_TICK / fleet.speed) setattr(self.config, fleet.value_name, new_value) setattr(self.config, fleet.value_name.replace('Value', 'Record'), record_time) diff --git a/module/config/config_updater.py b/module/config/config_updater.py index 1bdfdb38f..5cd0c97ca 100644 --- a/module/config/config_updater.py +++ b/module/config/config_updater.py @@ -35,9 +35,11 @@ from deploy.utils import DEPLOY_TEMPLATE, poor_yaml_read, poor_yaml_write from module.base.timer import timer from module.config.deep import deep_default, deep_get, deep_iter, deep_set +from module.config.emotion_recovery import recover_emotion_config from module.config.env import IS_ON_PHONE_CLOUD from module.config.server import VALID_CHANNEL_PACKAGE, VALID_PACKAGE, VALID_SERVER_LIST, to_package, to_server from module.config.task_priority import get_scheduler_tasks, merge_task_priority +from module.config.time_source import now as current_time from module.config.utils import * from module.config.redirect_utils.utils import * @@ -706,11 +708,12 @@ class ConfigUpdater: def args(self): return read_file(filepath_args()) - def config_update(self, old, is_template=False): + def config_update(self, old, is_template=False, now=None): """ Args: old: 旧配置字典。 is_template: 是否为模板配置。 + now: 用于刷新心情值的当前时间;测试可传入固定时间。 Returns: 更新后的配置字典。 @@ -804,6 +807,8 @@ def default_stage(t, stage): merge_task_priority(new_priority, template_priority, get_scheduler_tasks(self.args)), ) new = self._override(new) + if not is_template: + recover_emotion_config(new, now or current_time()) return new diff --git a/module/config/emotion_recovery.py b/module/config/emotion_recovery.py new file mode 100644 index 000000000..208dc33f1 --- /dev/null +++ b/module/config/emotion_recovery.py @@ -0,0 +1,71 @@ +"""配置加载阶段的心情恢复计算。""" + +from datetime import datetime, timedelta + +from module.base.emotion import ( + DIC_RECOVER, + DIC_RECOVER_MAX, + SECONDS_PER_TICK, + calculate_emotion_recovery, + emotion_recovery_speed, +) + + +def _recover_fleet(group, prefix, now): + value_key = f'{prefix}Value' + record_key = f'{prefix}Record' + recover_key = f'{prefix}Recover' + if value_key not in group or record_key not in group or recover_key not in group: + return + + value = group[value_key] + record = group[record_key] + recover = group[recover_key] + if not isinstance(value, (int, float)) or not isinstance(record, datetime): + return + if recover not in DIC_RECOVER: + return + + elapsed = now.timestamp() - record.timestamp() + if elapsed <= 0: + return + + oath = bool(group.get(f'{prefix}Oath', False)) + onsen = bool(group.get(f'{prefix}Onsen', False)) + speed = emotion_recovery_speed(recover, oath=oath, onsen=onsen) + maximum = DIC_RECOVER_MAX[recover] + new_value, fractional = calculate_emotion_recovery( + value, + recover, + elapsed, + oath=oath, + onsen=onsen, + ) + + group[value_key] = new_value + if new_value >= maximum: + group[record_key] = now.replace(microsecond=0) + return + + record_time = now.replace(microsecond=0) + if fractional > 0: + record_time -= timedelta(seconds=fractional * SECONDS_PER_TICK / speed) + group[record_key] = record_time + + +def recover_emotion_config(data, now): + """把任务配置中的持久化心情更新到 ``now`` 对应的当前值。""" + for task in data.values(): + if not isinstance(task, dict): + continue + + emotion = task.get('Emotion') + if isinstance(emotion, dict): + _recover_fleet(emotion, 'Fleet1', now) + _recover_fleet(emotion, 'Fleet2', now) + + public_emotion = task.get('PublicEmotion') + if isinstance(public_emotion, dict): + _recover_fleet(public_emotion, 'Fleet', now) + + return data diff --git a/tests/test_emotion_recovery.py b/tests/test_emotion_recovery.py new file mode 100644 index 000000000..64a8e7cac --- /dev/null +++ b/tests/test_emotion_recovery.py @@ -0,0 +1,186 @@ +import unittest +from datetime import datetime, timedelta, timezone + +from module.config.config_updater import ConfigUpdater + + +class TestEmotionConfigRecovery(unittest.TestCase): + def setUp(self): + self.now = datetime(2026, 8, 18, 12, 0, 0) + + def test_config_reload_recovers_stale_task_emotion_to_maximum(self): + old = { + 'Main': { + 'Emotion': { + 'Fleet1Value': 39, + 'Fleet1Record': self.now - timedelta(hours=12), + 'Fleet1Recover': 'not_in_dormitory', + 'Fleet1Oath': False, + 'Fleet1Onsen': False, + }, + }, + } + + new = ConfigUpdater().config_update(old, now=self.now) + + self.assertEqual(new['Main']['Emotion']['Fleet1Value'], 119) + self.assertEqual(new['Main']['Emotion']['Fleet1Record'], self.now) + + def test_config_reload_preserves_fractional_recovery_time(self): + old = { + 'Event': { + 'Emotion': { + 'Fleet1Value': 40, + 'Fleet1Record': self.now - timedelta(minutes=4), + 'Fleet1Recover': 'not_in_dormitory', + 'Fleet1Oath': False, + 'Fleet1Onsen': False, + }, + }, + } + + new = ConfigUpdater().config_update(old, now=self.now) + + self.assertEqual(new['Event']['Emotion']['Fleet1Value'], 41) + self.assertEqual( + new['Event']['Emotion']['Fleet1Record'], + self.now - timedelta(minutes=1), + ) + + reloaded = ConfigUpdater().config_update( + new, + now=self.now + timedelta(minutes=2), + ) + self.assertEqual(reloaded['Event']['Emotion']['Fleet1Value'], 42) + self.assertEqual( + reloaded['Event']['Emotion']['Fleet1Record'], + self.now + timedelta(minutes=2), + ) + + def test_config_reload_recovers_public_emotion(self): + old = { + 'General': { + 'PublicEmotion': { + 'FleetValue': 100, + 'FleetRecord': self.now - timedelta(hours=2), + 'FleetRecover': 'dormitory_floor_1', + 'FleetOath': True, + 'FleetOnsen': False, + }, + }, + } + + new = ConfigUpdater().config_update(old, now=self.now) + + self.assertEqual(new['General']['PublicEmotion']['FleetValue'], 150) + self.assertEqual(new['General']['PublicEmotion']['FleetRecord'], self.now) + + def test_dormitory_recovery_caps_fleet_emotion_at_150(self): + old = { + 'Main': { + 'Emotion': { + 'Fleet1Value': 39, + 'Fleet1Record': self.now - timedelta(hours=12), + 'Fleet1Recover': 'dormitory_floor_1', + 'Fleet1Oath': False, + 'Fleet1Onsen': False, + 'Fleet2Value': 39, + 'Fleet2Record': self.now - timedelta(hours=12), + 'Fleet2Recover': 'dormitory_floor_2', + 'Fleet2Oath': False, + 'Fleet2Onsen': False, + }, + }, + } + + new = ConfigUpdater().config_update(old, now=self.now) + emotion = new['Main']['Emotion'] + + self.assertEqual(emotion['Fleet1Value'], 150) + self.assertEqual(emotion['Fleet2Value'], 150) + self.assertEqual(emotion['Fleet1Record'], self.now) + self.assertEqual(emotion['Fleet2Record'], self.now) + + def test_template_config_skips_emotion_recovery(self): + old_record = self.now - timedelta(hours=12) + old = { + 'Main': { + 'Emotion': { + 'Fleet1Value': 119, + 'Fleet1Record': old_record, + 'Fleet1Recover': 'not_in_dormitory', + 'Fleet1Oath': False, + 'Fleet1Onsen': False, + }, + }, + } + + new = ConfigUpdater().config_update(old, now=self.now, is_template=True) + emotion = new['Main']['Emotion'] + + self.assertEqual(emotion['Fleet1Value'], 119) + self.assertEqual(emotion['Fleet1Record'], datetime(2020, 1, 1)) + self.assertNotEqual(emotion['Fleet1Record'], self.now) + + def test_equal_record_time_does_not_recover_emotion(self): + old = { + 'Main': { + 'Emotion': { + 'Fleet1Value': 39, + 'Fleet1Record': self.now, + 'Fleet1Recover': 'not_in_dormitory', + 'Fleet1Oath': False, + 'Fleet1Onsen': False, + }, + }, + } + + new = ConfigUpdater().config_update(old, now=self.now) + emotion = new['Main']['Emotion'] + + self.assertEqual(emotion['Fleet1Value'], 39) + self.assertEqual(emotion['Fleet1Record'], self.now) + + def test_future_record_time_does_not_recover_emotion(self): + future_record = self.now + timedelta(minutes=1) + old = { + 'Main': { + 'Emotion': { + 'Fleet1Value': 39, + 'Fleet1Record': future_record, + 'Fleet1Recover': 'not_in_dormitory', + 'Fleet1Oath': False, + 'Fleet1Onsen': False, + }, + }, + } + + new = ConfigUpdater().config_update(old, now=self.now) + emotion = new['Main']['Emotion'] + + self.assertEqual(emotion['Fleet1Value'], 39) + self.assertEqual(emotion['Fleet1Record'], future_record) + + def test_offset_aware_record_recovers_from_naive_current_time(self): + record = datetime.fromtimestamp(self.now.timestamp() - 6 * 60, timezone.utc) + old = { + 'Main': { + 'Emotion': { + 'Fleet1Value': 39, + 'Fleet1Record': record, + 'Fleet1Recover': 'not_in_dormitory', + 'Fleet1Oath': False, + 'Fleet1Onsen': False, + }, + }, + } + + new = ConfigUpdater().config_update(old, now=self.now) + emotion = new['Main']['Emotion'] + + self.assertEqual(emotion['Fleet1Value'], 41) + self.assertEqual(emotion['Fleet1Record'], self.now) + + +if __name__ == '__main__': + unittest.main()