feat(task): 配置中支持储存多个培育方案并支持来回切换

This commit is contained in:
XcantloadX 2025-07-08 08:49:39 +08:00
parent 4e4b91d670
commit 41e7c8b4a8
7 changed files with 261 additions and 88 deletions

1
.gitignore vendored
View File

@ -10,6 +10,7 @@ kotonebot-ui/.vite
dumps*/ dumps*/
config.json config.json
config.v*.json config.v*.json
conf/
reports/ reports/
tmp/ tmp/
res/sprites_compiled/ res/sprites_compiled/

View File

@ -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")

View File

@ -2,13 +2,12 @@ from typing import TypeVar, Literal, Sequence
from pydantic import BaseModel, ConfigDict from pydantic import BaseModel, ConfigDict
from kotonebot import config from kotonebot import config
from kotonebot.kaa.config.produce import ProduceSolution, ProduceSolutionManager
from .const import ( from .const import (
ConfigEnum, ConfigEnum,
Priority, Priority,
APShopItems, APShopItems,
DailyMoneyShopItems, DailyMoneyShopItems,
ProduceAction,
RecommendCardDetectionMode,
) )
T = TypeVar('T') T = TypeVar('T')
@ -70,68 +69,8 @@ class ContestConfig(ConfigBaseModel):
class ProduceConfig(ConfigBaseModel): class ProduceConfig(ConfigBaseModel):
enabled: bool = False enabled: bool = False
"""是否启用培育""" """是否启用培育"""
mode: Literal['regular', 'pro', 'master'] = 'regular' selected_solution_id: str | None = None
""" """选中的培育方案ID"""
培育模式
进行一次 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 MissionRewardConfig(ConfigBaseModel): class MissionRewardConfig(ConfigBaseModel):
enabled: bool = False enabled: bool = False
@ -285,3 +224,11 @@ def conf() -> BaseConfig:
"""获取当前配置数据""" """获取当前配置数据"""
c = config.to(BaseConfig).current c = config.to(BaseConfig).current
return c.options 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)

View File

@ -9,6 +9,7 @@ from kotonebot import (
sleep, sleep,
Interval, Interval,
) )
from kotonebot.kaa.config.schema import produce_solution
from kotonebot.primitives import Rect from kotonebot.primitives import Rect
from kotonebot.kaa.tasks import R from kotonebot.kaa.tasks import R
from .p_drink import acquire_p_drink from .p_drink import acquire_p_drink
@ -188,7 +189,7 @@ def fast_acquisitions() -> AcquisitionType | None:
# 跳过未读交流 # 跳过未读交流
logger.debug("Check skip commu...") 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" return "SkipCommu"
device.click(10, 10) device.click(10, 10)

View File

@ -2,6 +2,7 @@ import logging
from typing_extensions import assert_never from typing_extensions import assert_never
from typing import Literal from typing import Literal
from kotonebot.kaa.config.schema import produce_solution
from kotonebot.kaa.game_ui.schedule import Schedule from kotonebot.kaa.game_ui.schedule import Schedule
from kotonebot.kaa.tasks import R from kotonebot.kaa.tasks import R
from ..actions import loading from ..actions import loading
@ -193,7 +194,7 @@ def practice():
def threshold_predicate(card_count: int, result: CardDetectResult): def threshold_predicate(card_count: int, result: CardDetectResult):
border_scores = (result.left_score, result.right_score, result.top_score, result.bottom_score) 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: if is_strict_mode:
return ( return (
result.score >= 0.05 result.score >= 0.05
@ -225,7 +226,7 @@ def exam(type: Literal['mid', 'final']):
logger.info("Exam started") logger.info("Exam started")
def threshold_predicate(card_count: int, result: CardDetectResult): 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 total = lambda t: result.score >= t
def borders(t): def borders(t):
# 卡片数量小于三时无遮挡,以及最后一张卡片也总是无遮挡 # 卡片数量小于三时无遮挡,以及最后一张卡片也总是无遮挡
@ -423,7 +424,7 @@ def produce_end():
# [screenshots/produce_end/end_follow.png] # [screenshots/produce_end/end_follow.png]
elif image.find(R.InPurodyuusu.ButtonCancel): elif image.find(R.InPurodyuusu.ButtonCancel):
logger.info("Follow producer dialog found. Click to close.") logger.info("Follow producer dialog found. Click to close.")
if conf().produce.follow_producer: if produce_solution().data.follow_producer:
logger.info("Follow producer") logger.info("Follow producer")
device.click(image.expect_wait(R.InPurodyuusu.ButtonFollowNoIcon)) device.click(image.expect_wait(R.InPurodyuusu.ButtonFollowNoIcon))
else: else:
@ -507,12 +508,12 @@ def week_normal(week_first: bool = False):
action: ProduceAction | None = None action: ProduceAction | None = None
# SP 课程 # SP 课程
if ( if (
conf().produce.prefer_lesson_ap produce_solution().data.prefer_lesson_ap
and handle_sp_lesson() and handle_sp_lesson()
): ):
action = ProduceAction.DANCE action = ProduceAction.DANCE
else: else:
actions = conf().produce.actions_order actions = produce_solution().data.actions_order
for action in actions: for action in actions:
logger.debug("Checking action: %s", action) logger.debug("Checking action: %s", action)
if action := handle_action(action): if action := handle_action(action):
@ -540,7 +541,7 @@ def week_normal(week_first: bool = False):
def week_final_lesson(): def week_final_lesson():
until_action_scene() until_action_scene()
action: ProduceAction | None = None action: ProduceAction | None = None
actions = conf().produce.actions_order actions = produce_solution().data.actions_order
for action in actions: for action in actions:
logger.debug("Checking action: %s", action) logger.debug("Checking action: %s", action)
if action := handle_action(action, True): if action := handle_action(action, True):

View File

@ -5,6 +5,7 @@
""" """
from logging import getLogger from logging import getLogger
from kotonebot.kaa.config.schema import produce_solution
from kotonebot.kaa.game_ui import dialog from kotonebot.kaa.game_ui import dialog
from kotonebot.kaa.tasks import R from kotonebot.kaa.tasks import R
@ -66,7 +67,7 @@ def enter_study():
R.InPurodyuusu.TextSelfStudyVocal R.InPurodyuusu.TextSelfStudyVocal
]): ]):
logger.info("授業 type: Self study.") logger.info("授業 type: Self study.")
target = conf().produce.self_study_lesson target = produce_solution().data.self_study_lesson
if target == 'dance': if target == 'dance':
logger.debug("Clicking on lesson dance.") logger.debug("Clicking on lesson dance.")
device.double_click(image.expect(R.InPurodyuusu.TextSelfStudyDance)) device.double_click(image.expect(R.InPurodyuusu.TextSelfStudyDance))

View File

@ -3,6 +3,7 @@ from itertools import cycle
from typing import Optional, Literal from typing import Optional, Literal
from typing_extensions import assert_never from typing_extensions import assert_never
from kotonebot.kaa.config.schema import produce_solution
from kotonebot.ui import user from kotonebot.ui import user
from kotonebot.kaa.tasks import R from kotonebot.kaa.tasks import R
from kotonebot.kaa.config import conf from kotonebot.kaa.config import conf
@ -242,7 +243,7 @@ def do_produce(
result = False result = False
break break
if not result: 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_1.png]
# [kotonebot-resource\sprites\jp\produce\screenshot_no_enough_ap_2.png] # [kotonebot-resource\sprites\jp\produce\screenshot_no_enough_ap_2.png]
# [kotonebot-resource\sprites\jp\produce\screenshot_no_enough_ap_3.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] # 4. 选择道具 [screenshots/produce/screenshot_produce_start_4_end.png]
# TODO: 如果道具不足,这里加入推送提醒 # TODO: 如果道具不足,这里加入推送提醒
if conf().produce.use_note_boost: if produce_solution().data.use_note_boost:
if image.find(R.Produce.CheckboxIconNoteBoost): if image.find(R.Produce.CheckboxIconNoteBoost):
device.click() device.click()
sleep(0.1) sleep(0.1)
if conf().produce.use_pt_boost: if produce_solution().data.use_pt_boost:
if image.find(R.Produce.CheckboxIconSupportPtBoost): if image.find(R.Produce.CheckboxIconSupportPtBoost):
device.click() device.click()
sleep(0.1) 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.') logger.info('Produce is disabled.')
return return
import time import time
count = conf().produce.produce_count count = produce_solution().data.produce_count
idols = conf().produce.idols idols = produce_solution().data.idols
memory_sets = conf().produce.memory_sets memory_sets = produce_solution().data.memory_sets
mode = conf().produce.mode mode = produce_solution().data.mode
# 数据验证 # 数据验证
if count < 0: if count < 0:
user.warning('配置有误', '培育次数不能小于 0。将跳过本次培育。') user.warning('配置有误', '培育次数不能小于 0。将跳过本次培育。')
@ -402,7 +403,7 @@ def produce():
for i in range(count): for i in range(count):
start_time = time.time() start_time = time.time()
idol = next(idol_iterator) idol = next(idol_iterator)
if conf().produce.auto_set_memory: if produce_solution().data.auto_set_memory:
memory_set = None memory_set = None
else: else:
memory_set = next(memory_set_iterator, None) memory_set = next(memory_set_iterator, None)
@ -426,12 +427,12 @@ if __name__ == '__main__':
from kotonebot.kaa.common import BaseConfig from kotonebot.kaa.common import BaseConfig
from kotonebot.kaa.main import Kaa from kotonebot.kaa.main import Kaa
conf().produce.enabled = True produce_solution().data.enabled = True
conf().produce.mode = 'pro' produce_solution().data.mode = 'pro'
conf().produce.produce_count = 1 produce_solution().data.produce_count = 1
# conf().produce.idols = ['i_card-skin-hski-3-002'] # produce_solution().data.idols = ['i_card-skin-hski-3-002']
conf().produce.memory_sets = [1] produce_solution().data.memory_sets = [1]
conf().produce.auto_set_memory = False produce_solution().data.auto_set_memory = False
# do_produce(PIdol.月村手毬_初声, 'pro', 5) # do_produce(PIdol.月村手毬_初声, 'pro', 5)
produce() produce()
# a() # a()