diff --git a/.gitignore b/.gitignore index ba2d7c2..be36ced 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ kotonebot-ui/.vite dumps*/ config.json config.v*.json +conf/ reports/ tmp/ res/sprites_compiled/ diff --git a/kotonebot/kaa/config/produce.py b/kotonebot/kaa/config/produce.py new file mode 100644 index 0000000..9b8dce9 --- /dev/null +++ b/kotonebot/kaa/config/produce.py @@ -0,0 +1,221 @@ +import os +import json +import uuid +import re +import logging +from typing import Literal +from pydantic import BaseModel, ConfigDict + +from .const import ProduceAction, RecommendCardDetectionMode + +logger = logging.getLogger(__name__) + +class ConfigBaseModel(BaseModel): + model_config = ConfigDict(use_attribute_docstrings=True) + + +class ProduceData(ConfigBaseModel): + enabled: bool = False + """是否启用培育""" + mode: Literal['regular', 'pro', 'master'] = 'regular' + """ + 培育模式。 + 进行一次 REGULAR 培育需要 ~30min,进行一次 PRO 培育需要 ~1h(具体视设备性能而定)。 + """ + produce_count: int = 1 + """培育的次数。""" + idols: list[str] = [] + """ + 要培育偶像的 IdolCardSkin.id。将会按顺序循环选择培育。 + """ + memory_sets: list[int] = [] + """要使用的回忆编成编号,从 1 开始。将会按顺序循环选择使用。""" + support_card_sets: list[int] = [] + """要使用的支援卡编成编号,从 1 开始。将会按顺序循环选择使用。""" + auto_set_memory: bool = False + """是否自动编成回忆。此选项优先级高于回忆编成编号。""" + auto_set_support_card: bool = False + """是否自动编成支援卡。此选项优先级高于支援卡编成编号。""" + use_pt_boost: bool = False + """是否使用支援强化 Pt 提升。""" + use_note_boost: bool = False + """是否使用笔记数提升。""" + follow_producer: bool = False + """是否关注租借了支援卡的制作人。""" + self_study_lesson: Literal['dance', 'visual', 'vocal'] = 'dance' + """自习课类型。""" + prefer_lesson_ap: bool = False + """ + 优先 SP 课程。 + + 启用后,若出现 SP 课程,则会优先执行 SP 课程,而不是推荐课程。 + 若出现多个 SP 课程,随机选择一个。 + """ + actions_order: list[ProduceAction] = [ + ProduceAction.RECOMMENDED, + ProduceAction.VISUAL, + ProduceAction.VOCAL, + ProduceAction.DANCE, + ProduceAction.ALLOWANCE, + ProduceAction.OUTING, + ProduceAction.STUDY, + ProduceAction.CONSULT, + ProduceAction.REST, + ] + """ + 行动优先级 + + 每一周的行动将会按这里设置的优先级执行。 + """ + recommend_card_detection_mode: RecommendCardDetectionMode = RecommendCardDetectionMode.NORMAL + """ + 推荐卡检测模式 + + 严格模式下,识别速度会降低,但识别准确率会提高。 + """ + use_ap_drink: bool = False + """ + AP 不足时自动使用 AP 饮料 + """ + skip_commu: bool = True + """检测并跳过交流""" + + +class ProduceSolution(ConfigBaseModel): + """培育方案""" + id: str + """方案唯一标识符""" + name: str + """方案名称""" + description: str | None = None + """方案描述""" + data: ProduceData + """培育数据""" + + +class ProduceSolutionManager: + """培育方案管理器""" + + SOLUTIONS_DIR = "conf/produce" + + def __init__(self): + """初始化管理器,确保目录存在""" + os.makedirs(self.SOLUTIONS_DIR, exist_ok=True) + + def _sanitize_filename(self, name: str) -> str: + """ + 清理文件名中的非法字符 + + :param name: 原始名称 + :return: 清理后的文件名 + """ + # 替换 \/:*?"<>| 为下划线 + return re.sub(r'[\\/:*?"<>|]', '_', name) + + def _get_file_path(self, name: str) -> str: + """ + 根据方案名称获取文件路径 + + :param name: 方案名称 + :return: 文件路径 + """ + safe_name = self._sanitize_filename(name) + return os.path.join(self.SOLUTIONS_DIR, f"{safe_name}.json") + + def new(self, name: str) -> ProduceSolution: + """ + 创建新的培育方案 + + :param name: 方案名称 + :return: 新创建的方案 + """ + solution = ProduceSolution( + id=uuid.uuid4().hex, + name=name, + data=ProduceData() + ) + return solution + + def list(self) -> list[ProduceSolution]: + """ + 列出所有培育方案 + + :return: 方案列表 + """ + solutions = [] + if not os.path.exists(self.SOLUTIONS_DIR): + return solutions + + for filename in os.listdir(self.SOLUTIONS_DIR): + if filename.endswith('.json'): + try: + file_path = os.path.join(self.SOLUTIONS_DIR, filename) + with open(file_path, 'r', encoding='utf-8') as f: + data = json.load(f) + solution = ProduceSolution.model_validate_json(data) + solutions.append(solution) + logger.info(f"Loaded produce solution from {file_path}") + except Exception: + logger.warning(f"Failed to load produce solution from {file_path}") + continue + + return solutions + + def delete(self, id: str) -> None: + """ + 删除指定ID的培育方案 + + :param id: 方案ID + """ + if not os.path.exists(self.SOLUTIONS_DIR): + return + + for filename in os.listdir(self.SOLUTIONS_DIR): + if filename.endswith('.json'): + try: + file_path = os.path.join(self.SOLUTIONS_DIR, filename) + with open(file_path, 'r', encoding='utf-8') as f: + data = json.load(f) + if data.get('id') == id: + os.remove(file_path) + return + except Exception: + continue + + def save(self, id: str, solution: ProduceSolution) -> None: + """ + 保存培育方案 + + :param id: 方案ID + :param solution: 方案对象 + """ + # 确保ID一致 + solution.id = id + + file_path = self._get_file_path(solution.name) + with open(file_path, 'w', encoding='utf-8') as f: + json.dump(solution.model_dump_json(indent=4), f, ensure_ascii=False) + + def read(self, id: str) -> ProduceSolution: + """ + 读取指定ID的培育方案 + + :param id: 方案ID + :return: 方案对象 + :raises FileNotFoundError: 当方案不存在时 + """ + if not os.path.exists(self.SOLUTIONS_DIR): + raise FileNotFoundError(f"Solution with id '{id}' not found") + + for filename in os.listdir(self.SOLUTIONS_DIR): + if filename.endswith('.json'): + try: + file_path = os.path.join(self.SOLUTIONS_DIR, filename) + with open(file_path, 'r', encoding='utf-8') as f: + data = json.load(f) + if data.get('id') == id: + return ProduceSolution.model_validate_json(data) + except Exception: + continue + + raise FileNotFoundError(f"Solution with id '{id}' not found") \ No newline at end of file diff --git a/kotonebot/kaa/config/schema.py b/kotonebot/kaa/config/schema.py index f2b484a..80af69f 100644 --- a/kotonebot/kaa/config/schema.py +++ b/kotonebot/kaa/config/schema.py @@ -2,13 +2,12 @@ from typing import TypeVar, Literal, Sequence from pydantic import BaseModel, ConfigDict from kotonebot import config +from kotonebot.kaa.config.produce import ProduceSolution, ProduceSolutionManager from .const import ( ConfigEnum, Priority, APShopItems, DailyMoneyShopItems, - ProduceAction, - RecommendCardDetectionMode, ) T = TypeVar('T') @@ -70,68 +69,8 @@ class ContestConfig(ConfigBaseModel): class ProduceConfig(ConfigBaseModel): enabled: bool = False """是否启用培育""" - mode: Literal['regular', 'pro', 'master'] = 'regular' - """ - 培育模式。 - 进行一次 REGULAR 培育需要 ~30min,进行一次 PRO 培育需要 ~1h(具体视设备性能而定)。 - """ - produce_count: int = 1 - """培育的次数。""" - idols: list[str] = [] - """ - 要培育偶像的 IdolCardSkin.id。将会按顺序循环选择培育。 - """ - memory_sets: list[int] = [] - """要使用的回忆编成编号,从 1 开始。将会按顺序循环选择使用。""" - support_card_sets: list[int] = [] - """要使用的支援卡编成编号,从 1 开始。将会按顺序循环选择使用。""" - auto_set_memory: bool = False - """是否自动编成回忆。此选项优先级高于回忆编成编号。""" - auto_set_support_card: bool = False - """是否自动编成支援卡。此选项优先级高于支援卡编成编号。""" - use_pt_boost: bool = False - """是否使用支援强化 Pt 提升。""" - use_note_boost: bool = False - """是否使用笔记数提升。""" - follow_producer: bool = False - """是否关注租借了支援卡的制作人。""" - self_study_lesson: Literal['dance', 'visual', 'vocal'] = 'dance' - """自习课类型。""" - prefer_lesson_ap: bool = False - """ - 优先 SP 课程。 - - 启用后,若出现 SP 课程,则会优先执行 SP 课程,而不是推荐课程。 - 若出现多个 SP 课程,随机选择一个。 - """ - actions_order: list[ProduceAction] = [ - ProduceAction.RECOMMENDED, - ProduceAction.VISUAL, - ProduceAction.VOCAL, - ProduceAction.DANCE, - ProduceAction.ALLOWANCE, - ProduceAction.OUTING, - ProduceAction.STUDY, - ProduceAction.CONSULT, - ProduceAction.REST, - ] - """ - 行动优先级 - - 每一周的行动将会按这里设置的优先级执行。 - """ - recommend_card_detection_mode: RecommendCardDetectionMode = RecommendCardDetectionMode.NORMAL - """ - 推荐卡检测模式 - - 严格模式下,识别速度会降低,但识别准确率会提高。 - """ - use_ap_drink: bool = False - """ - AP 不足时自动使用 AP 饮料 - """ - skip_commu: bool = True - """检测并跳过交流""" + selected_solution_id: str | None = None + """选中的培育方案ID""" class MissionRewardConfig(ConfigBaseModel): enabled: bool = False @@ -284,4 +223,12 @@ class BaseConfig(ConfigBaseModel): def conf() -> BaseConfig: """获取当前配置数据""" c = config.to(BaseConfig).current - return c.options \ No newline at end of file + return c.options + +def produce_solution() -> ProduceSolution: + """获取当前培育方案""" + id = conf().produce.selected_solution_id + if id is None: + raise ValueError("No produce solution selected") + # TODO: 这里需要缓存,不能每次都从磁盘读取 + return ProduceSolutionManager().read(id) diff --git a/kotonebot/kaa/tasks/produce/common.py b/kotonebot/kaa/tasks/produce/common.py index 348c2d7..4033c9a 100644 --- a/kotonebot/kaa/tasks/produce/common.py +++ b/kotonebot/kaa/tasks/produce/common.py @@ -9,6 +9,7 @@ from kotonebot import ( sleep, Interval, ) +from kotonebot.kaa.config.schema import produce_solution from kotonebot.primitives import Rect from kotonebot.kaa.tasks import R from .p_drink import acquire_p_drink @@ -188,7 +189,7 @@ def fast_acquisitions() -> AcquisitionType | None: # 跳过未读交流 logger.debug("Check skip commu...") - if conf().produce.skip_commu and handle_unread_commu(img): + if produce_solution().data.skip_commu and handle_unread_commu(img): return "SkipCommu" device.click(10, 10) diff --git a/kotonebot/kaa/tasks/produce/in_purodyuusu.py b/kotonebot/kaa/tasks/produce/in_purodyuusu.py index f330533..3ea51f8 100644 --- a/kotonebot/kaa/tasks/produce/in_purodyuusu.py +++ b/kotonebot/kaa/tasks/produce/in_purodyuusu.py @@ -2,6 +2,7 @@ import logging from typing_extensions import assert_never from typing import Literal +from kotonebot.kaa.config.schema import produce_solution from kotonebot.kaa.game_ui.schedule import Schedule from kotonebot.kaa.tasks import R from ..actions import loading @@ -193,7 +194,7 @@ def practice(): def threshold_predicate(card_count: int, result: CardDetectResult): border_scores = (result.left_score, result.right_score, result.top_score, result.bottom_score) - is_strict_mode = conf().produce.recommend_card_detection_mode == RecommendCardDetectionMode.STRICT + is_strict_mode = produce_solution().data.recommend_card_detection_mode == RecommendCardDetectionMode.STRICT if is_strict_mode: return ( result.score >= 0.05 @@ -225,7 +226,7 @@ def exam(type: Literal['mid', 'final']): logger.info("Exam started") def threshold_predicate(card_count: int, result: CardDetectResult): - is_strict_mode = conf().produce.recommend_card_detection_mode == RecommendCardDetectionMode.STRICT + is_strict_mode = produce_solution().data.recommend_card_detection_mode == RecommendCardDetectionMode.STRICT total = lambda t: result.score >= t def borders(t): # 卡片数量小于三时无遮挡,以及最后一张卡片也总是无遮挡 @@ -423,7 +424,7 @@ def produce_end(): # [screenshots/produce_end/end_follow.png] elif image.find(R.InPurodyuusu.ButtonCancel): logger.info("Follow producer dialog found. Click to close.") - if conf().produce.follow_producer: + if produce_solution().data.follow_producer: logger.info("Follow producer") device.click(image.expect_wait(R.InPurodyuusu.ButtonFollowNoIcon)) else: @@ -507,12 +508,12 @@ def week_normal(week_first: bool = False): action: ProduceAction | None = None # SP 课程 if ( - conf().produce.prefer_lesson_ap + produce_solution().data.prefer_lesson_ap and handle_sp_lesson() ): action = ProduceAction.DANCE else: - actions = conf().produce.actions_order + actions = produce_solution().data.actions_order for action in actions: logger.debug("Checking action: %s", action) if action := handle_action(action): @@ -540,7 +541,7 @@ def week_normal(week_first: bool = False): def week_final_lesson(): until_action_scene() action: ProduceAction | None = None - actions = conf().produce.actions_order + actions = produce_solution().data.actions_order for action in actions: logger.debug("Checking action: %s", action) if action := handle_action(action, True): diff --git a/kotonebot/kaa/tasks/produce/non_lesson_actions.py b/kotonebot/kaa/tasks/produce/non_lesson_actions.py index 5853ec0..5882718 100644 --- a/kotonebot/kaa/tasks/produce/non_lesson_actions.py +++ b/kotonebot/kaa/tasks/produce/non_lesson_actions.py @@ -5,6 +5,7 @@ """ from logging import getLogger +from kotonebot.kaa.config.schema import produce_solution from kotonebot.kaa.game_ui import dialog from kotonebot.kaa.tasks import R @@ -66,7 +67,7 @@ def enter_study(): R.InPurodyuusu.TextSelfStudyVocal ]): logger.info("授業 type: Self study.") - target = conf().produce.self_study_lesson + target = produce_solution().data.self_study_lesson if target == 'dance': logger.debug("Clicking on lesson dance.") device.double_click(image.expect(R.InPurodyuusu.TextSelfStudyDance)) diff --git a/kotonebot/kaa/tasks/produce/produce.py b/kotonebot/kaa/tasks/produce/produce.py index d8cbf15..3986563 100644 --- a/kotonebot/kaa/tasks/produce/produce.py +++ b/kotonebot/kaa/tasks/produce/produce.py @@ -3,6 +3,7 @@ from itertools import cycle from typing import Optional, Literal from typing_extensions import assert_never +from kotonebot.kaa.config.schema import produce_solution from kotonebot.ui import user from kotonebot.kaa.tasks import R from kotonebot.kaa.config import conf @@ -242,7 +243,7 @@ def do_produce( result = False break if not result: - if conf().produce.use_ap_drink: + if produce_solution().data.use_ap_drink: # [kotonebot-resource\sprites\jp\produce\screenshot_no_enough_ap_1.png] # [kotonebot-resource\sprites\jp\produce\screenshot_no_enough_ap_2.png] # [kotonebot-resource\sprites\jp\produce\screenshot_no_enough_ap_3.png] @@ -351,11 +352,11 @@ def do_produce( # 4. 选择道具 [screenshots/produce/screenshot_produce_start_4_end.png] # TODO: 如果道具不足,这里加入推送提醒 - if conf().produce.use_note_boost: + if produce_solution().data.use_note_boost: if image.find(R.Produce.CheckboxIconNoteBoost): device.click() sleep(0.1) - if conf().produce.use_pt_boost: + if produce_solution().data.use_pt_boost: if image.find(R.Produce.CheckboxIconSupportPtBoost): device.click() sleep(0.1) @@ -384,14 +385,14 @@ def produce(): """ 培育任务 """ - if not conf().produce.enabled: + if not produce_solution().data.enabled: logger.info('Produce is disabled.') return import time - count = conf().produce.produce_count - idols = conf().produce.idols - memory_sets = conf().produce.memory_sets - mode = conf().produce.mode + count = produce_solution().data.produce_count + idols = produce_solution().data.idols + memory_sets = produce_solution().data.memory_sets + mode = produce_solution().data.mode # 数据验证 if count < 0: user.warning('配置有误', '培育次数不能小于 0。将跳过本次培育。') @@ -402,7 +403,7 @@ def produce(): for i in range(count): start_time = time.time() idol = next(idol_iterator) - if conf().produce.auto_set_memory: + if produce_solution().data.auto_set_memory: memory_set = None else: memory_set = next(memory_set_iterator, None) @@ -426,12 +427,12 @@ if __name__ == '__main__': from kotonebot.kaa.common import BaseConfig from kotonebot.kaa.main import Kaa - conf().produce.enabled = True - conf().produce.mode = 'pro' - conf().produce.produce_count = 1 - # conf().produce.idols = ['i_card-skin-hski-3-002'] - conf().produce.memory_sets = [1] - conf().produce.auto_set_memory = False + produce_solution().data.enabled = True + produce_solution().data.mode = 'pro' + produce_solution().data.produce_count = 1 + # produce_solution().data.idols = ['i_card-skin-hski-3-002'] + produce_solution().data.memory_sets = [1] + produce_solution().data.auto_set_memory = False # do_produce(PIdol.月村手毬_初声, 'pro', 5) produce() # a()