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*/
config.json
config.v*.json
conf/
reports/
tmp/
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 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
@ -285,3 +224,11 @@ def conf() -> BaseConfig:
"""获取当前配置数据"""
c = config.to(BaseConfig).current
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,
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)

View File

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

View File

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

View File

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