Compare commits

...

44 Commits

Author SHA1 Message Date
XcantloadX 524ffd58a9 docs: v2025.7.27.1 更新日志 2025-07-28 20:07:37 +08:00
XcantloadX de1328cdff fix(task): 修复检测培育阶段函数过早返回的问题 2025-07-28 20:06:19 +08:00
XcantloadX 6629bc7ae5 docs: v2025.7.27.0 更新日志 2025-07-27 17:14:52 +08:00
XcantloadX f9fafb9d71 Merge branch 'dev' 2025-07-27 17:13:18 +08:00
XcantloadX 738ec9ee78 fix(task): 修复竞赛总是卡在跳过页面
检测 SKIP 按钮默认给的阈值过高,现在调低了点。
2025-07-27 17:12:43 +08:00
XcantloadX 9c9e4af555 feat(task): 即使执行任务出错也会执行关闭游戏任务
比如关机、休眠等。
2025-07-27 17:12:43 +08:00
XcantloadX 0b7054e897 feat(core): Task 新增 pre、post、regular、manual 四种 run_at 类型 2025-07-27 17:12:43 +08:00
XcantloadX 09252c5aa1 refactor(core): 移除了废弃的 dispatcher 与 action 分发器重载 2025-07-27 17:12:43 +08:00
XcantloadX b51f9cdaa4 feat(task): 优化培育方案错误与选人未找到错误的提示 2025-07-27 17:12:43 +08:00
XcantloadX 3e544e92a9 feat(core): 新增 MessageBox 与 TaskDialog 的 Win32API 封装 2025-07-27 17:12:42 +08:00
XcantloadX 3be8485795 feat(task): 竞赛未编成时支持暂停与通知 2025-07-27 17:12:35 +08:00
XcantloadX a167cbfbe1 feat(core): 支持任务执行中只跳过或停止当前任务 2025-07-26 13:26:01 +08:00
XcantloadX ceaaed7896 fix(task): 修复 DMM 版启动游戏的过程中无法中断的问题 2025-07-26 04:54:19 +08:00
XcantloadX a922ce5738 docs: v2025.7.20.0 更新日志 2025-07-20 10:14:45 +08:00
XcantloadX d7a3494d8e Merge branch 'dev' 2025-07-20 10:12:53 +08:00
XcantloadX b07d4d3d23 fix(task): 修复商店购买中购买推荐商品时点击推荐标签不生效的问题 2025-07-20 10:08:28 +08:00
XcantloadX 4deea1d644 fix(task): 修复 DMM 上由于输出分辨率到日志中导致的启动失败问题
原因是获取分辨率时对于 DMM 版,还没有启动游戏,会抛出找不到窗口的异常。
2025-07-20 09:53:21 +08:00
XcantloadX f929046ae2 feat(ui): 将保留截图数据与跟踪推荐卡两个选项统一移动到调试设置中 2025-07-20 09:48:30 +08:00
XcantloadX 3e67627962 fix(ui): 修复新建培育方案时会自动修改当前选中的方案的问题 2025-07-20 09:05:54 +08:00
XcantloadX 1b385c09b1 fix(ui): 修复删除培育时的报错问题 2025-07-19 07:16:21 +08:00
XcantloadX acfb5548b6 fix(task): 修复严格模式下长时间卡在四张卡的推荐卡检测上 2025-07-19 06:51:03 +08:00
XcantloadX b8ade2f48c feat(ui): 首页快速功能区域新增完成后操作 2025-07-15 07:00:28 +08:00
XcantloadX 16360f5764 docs: v2025.7.13.0 更新日志 2025-07-13 12:12:57 +08:00
XcantloadX a4d3b322e0 Merge branch 'dev' 2025-07-13 12:10:50 +08:00
XcantloadX 4bea42238f fix(ui): 修复某些情况下热重载配置失败的问题
原因是上下文初始化前就调用了 config.load() 导致报错。
2025-07-13 12:06:29 +08:00
XcantloadX 5db3ed6526 feat(boostrap): 启动器 EXE 新增多分辨率图标 2025-07-13 10:08:35 +08:00
XcantloadX 5cc9f454ee chore: 增加对 Python 信息与分辨率信息的日志输出 2025-07-12 10:21:27 +08:00
XcantloadX a8a5566f00 feat(task): 上传报告时一并打包配置文件 2025-07-11 22:12:32 +08:00
XcantloadX 63f792db2d fix(bootstrap): 修复当文件夹路径存在空格时启动器无法正确启动 kaa 的问题 2025-07-11 22:08:03 +08:00
XcantloadX 05a69ad947 docs: v2025.7.9.0 更新日志 2025-07-09 12:58:14 +08:00
XcantloadX 8216310173 Merge branch 'dev' 2025-07-09 12:57:48 +08:00
XcantloadX ca83fec19d test: 为新的培育方案编写单元测试 2025-07-08 19:51:16 +08:00
XcantloadX ef725b4e6f chore: v5 到 v6 配置迁移脚本 2025-07-08 19:51:16 +08:00
XcantloadX 68b0cbda73 feat(ui): 为新的培育方案增加 UI 2025-07-08 19:49:54 +08:00
XcantloadX 41e7c8b4a8 feat(task): 配置中支持储存多个培育方案并支持来回切换 2025-07-08 19:48:40 +08:00
XcantloadX 4e4b91d670 refactor(task): 将配置数据中的常量移动到单独一个文件中 2025-07-07 21:22:15 +08:00
XcantloadX e548518dcd fix(task): 尝试修复周数 OCR 失败问题
Fixed #26
2025-07-07 20:44:18 +08:00
XcantloadX a0d3c31b6b feat(core): ContextOcr 类支持设置 OCR 语言 2025-07-07 20:42:20 +08:00
XcantloadX 0651d949d7 fix(task): 修复某些情况下培育会卡在初始饮料技能卡二选一上 2025-07-07 18:36:28 +08:00
XcantloadX 497561c721 fix(task): 修复部分日志缺失的问题
原因是调用 logging.basicConfig 的时机不正确
2025-07-07 18:10:28 +08:00
XcantloadX c7d5cd88d6 refactor(task): 配置迁移代码移动到单独的模块 2025-07-07 18:10:22 +08:00
XcantloadX e0549c6b85 refactor(task): 将配置文件类从 kotonebot.kaa.common 中移动到专门的模块 kotonebot.kaa.config 2025-07-07 18:10:07 +08:00
XcantloadX c3d24018db feat(ui): 新增快速功能启停 2025-07-07 17:43:36 +08:00
XcantloadX 6dd2b3510b fix(ui): 修复修改设置后需要重启才能生效的问题 2025-07-07 15:32:36 +08:00
53 changed files with 4485 additions and 1464 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

@ -1,5 +1,80 @@
# 更新日志
## kaa
### v2025.7.27.1
脚本:
* [修复] 修复检测培育阶段函数过早返回的问题(#de1328c
### v2025.7.27.0
脚本:
* [修复] 修复竞赛总是卡在跳过页面(#738ec9e
* [修复] 修复 DMM 版启动游戏的过程中无法中断的问题(#ceaaed7
* [新增] 即使执行任务出错也会执行关闭游戏任务(#9c9e4af
* [新增] 优化培育方案错误与选人未找到错误的提示(#b51f9cd
* [新增] 竞赛未编成时支持暂停与通知(#3be8485
框架:
* [新增] Task 新增 pre、post、regular、manual 四种 run_at 类型(#0b7054e
* [新增] 新增 MessageBox 与 TaskDialog 的 Win32API 封装(#3e544e9
* [新增] 支持任务执行中只跳过或停止当前任务(#a167cbf
* [重构] 移除了废弃的 dispatcher 与 action 分发器重载(#09252c5
### v2025.7.20.0
脚本:
* [修复] 修复商店购买中购买推荐商品时点击推荐标签不生效的问题(#b07d4d3
* [修复] 修复 DMM 上由于输出分辨率到日志中导致的启动失败问题(#4deea1d
* [修复] 修复严格模式下长时间卡在四张卡的推荐卡检测上(#acfb554
界面:
* [修复] 修复新建培育方案时会自动修改当前选中的方案的问题(#3e67627
* [修复] 修复删除培育时的报错问题(#1b385c0
* [新增] 将保留截图数据与跟踪推荐卡两个选项统一移动到调试设置中(#f929046
* [新增] 首页快速功能区域新增完成后操作(#b8ade2f
### v2025.7.13.0
脚本:
* [新增] 上传报告时一并打包配置文件(#a8a5566
界面:
* [修复] 修复某些情况下热重载配置失败的问题(#4bea422
启动器:
* [修复] 修复当文件夹路径存在空格时启动器无法正确启动 kaa 的问题(#63f792d
* [新增] 启动器 EXE 新增多分辨率图标(#5db3ed6
其他:
* [其他] 增加对 Python 信息与分辨率信息的日志输出(#5cc9f45
### v2025.7.9.0
脚本:
* [新增] 配置中支持储存多个培育方案并支持来回切换(#41e7c8b
* [重构] 将配置数据中的常量移动到单独一个文件中(#4e4b91d
* [重构] 配置迁移代码移动到单独的模块(#c7d5cd8
* [重构] 将配置文件类从 kotonebot.kaa.common 中移动到专门的模块 kotonebot.kaa.config#e0549c6
* [修复] 尝试修复周数 OCR 失败问题(#e548518
* [修复] 修复某些情况下培育会卡在初始饮料技能卡二选一上(#0651d94
* [修复] 修复部分日志缺失的问题(#497561c
* [重构] 配置迁移代码移动到单独的模块(#c7d5cd8
* [重构] 将配置文件类从 kotonebot.kaa.common 中移动到专门的模块 kotonebot.kaa.config#e0549c6
* [修复] 尝试修复周数 OCR 失败问题(#e548518
* [修复] 修复某些情况下培育会卡在初始饮料技能卡二选一上(#0651d94
* [修复] 修复部分日志缺失的问题(#497561c
* [修复] 修复部分日志缺失的问题(#497561c
界面:
* [新增] 为新的培育方案增加 UI#68b0cbd
* [新增] 新增快速功能启停(#c3d2401
* [修复] 修复修改设置后需要重启才能生效的问题(#6dd2b35
框架:
* [新增] ContextOcr 类支持设置 OCR 语言(#a0d3c31
其他:
* [单测] 为新的培育方案编写单元测试(#ca83fec
* [其他] v5 到 v6 配置迁移脚本(#ef725b4
### v2025.7.7.0
脚本:
* [修复] 修复 AP 商店购买时点击坐标偏移问题(#b8b56bb

View File

@ -49,7 +49,7 @@ int APIENTRY wWinMain(_In_ HINSTANCE hInstance,
}
// 构建命令行
std::wstring cmd = pythonPath + L" " + bootstrapPath;
std::wstring cmd = L"\"" + pythonPath + L"\" \"" + bootstrapPath + L"\"";
// 如果有命令行参数,将其传递给 bootstrap
if (lpCmdLine && wcslen(lpCmdLine) > 0) {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 264 KiB

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 264 KiB

After

Width:  |  Height:  |  Size: 160 KiB

View File

@ -12,6 +12,15 @@ from kotonebot.client import Device
from kotonebot.client.host.protocol import Instance
from kotonebot.backend.context import init_context, vars
from kotonebot.backend.context import task_registry, action_registry, Task, Action
from kotonebot.errors import StopCurrentTask, UserFriendlyError
from kotonebot.interop.win.task_dialog import TaskDialog
@dataclass
class PostTaskContext:
has_error: bool
exception: Exception | None
log_stream = io.StringIO()
stream_handler = logging.StreamHandler(log_stream)
@ -19,10 +28,11 @@ stream_handler.setFormatter(logging.Formatter('[%(asctime)s] [%(levelname)s] [%(
logging.getLogger('kotonebot').addHandler(stream_handler)
logger = logging.getLogger(__name__)
TaskStatusValue = Literal['pending', 'running', 'finished', 'error', 'cancelled', 'stopped']
@dataclass
class TaskStatus:
task: Task
status: Literal['pending', 'running', 'finished', 'error', 'cancelled']
status: TaskStatusValue
@dataclass
class RunStatus:
@ -73,7 +83,7 @@ class Event(Generic[Params, Return]):
class KotoneBotEvents:
def __init__(self):
self.task_status_changed = Event[
[Task, Literal['pending', 'running', 'finished', 'error', 'cancelled']], None
[Task, TaskStatusValue], None
]()
self.task_error = Event[
[Task, Exception], None
@ -171,42 +181,80 @@ class KotoneBot:
self._on_after_init_context()
vars.flow.clear_interrupt()
pre_tasks = [task for task in tasks if task.run_at == 'pre']
regular_tasks = [task for task in tasks if task.run_at == 'regular']
post_tasks = [task for task in tasks if task.run_at == 'post']
if by_priority:
tasks = sorted(tasks, key=lambda x: x.priority, reverse=True)
for task in tasks:
pre_tasks = sorted(pre_tasks, key=lambda x: x.priority, reverse=True)
regular_tasks = sorted(regular_tasks, key=lambda x: x.priority, reverse=True)
post_tasks = sorted(post_tasks, key=lambda x: x.priority, reverse=True)
all_tasks = pre_tasks + regular_tasks + post_tasks
for task in all_tasks:
self.events.task_status_changed.trigger(task, 'pending')
for task in tasks:
has_error = False
exception: Exception | None = None
for task in all_tasks:
logger.info(f'Task started: {task.name}')
self.events.task_status_changed.trigger(task, 'running')
if self.debug:
task.func()
if task.run_at == 'post':
task.func(PostTaskContext(has_error, exception))
else:
task.func()
else:
try:
task.func()
if task.run_at == 'post':
task.func(PostTaskContext(has_error, exception))
else:
task.func()
self.events.task_status_changed.trigger(task, 'finished')
except StopCurrentTask:
logger.info(f'Task skipped/stopped: {task.name}')
self.events.task_status_changed.trigger(task, 'stopped')
# 用户中止
except KeyboardInterrupt as e:
logger.exception('Keyboard interrupt detected.')
for task1 in tasks[tasks.index(task):]:
for task1 in all_tasks[all_tasks.index(task):]:
self.events.task_status_changed.trigger(task1, 'cancelled')
vars.flow.clear_interrupt()
break
# 用户可以自行处理的错误
except UserFriendlyError as e:
logger.error(f'Task failed: {task.name}')
logger.exception(f'Error: ')
has_error = True
exception = e
dialog = TaskDialog(
title='琴音小助手',
common_buttons=0,
main_instruction='任务执行失败',
content=e.message,
custom_buttons=e.action_buttons,
main_icon='error'
)
result_custom, _, _ = dialog.show()
e.invoke(result_custom)
# 其他错误
except Exception as e:
logger.error(f'Task failed: {task.name}')
logger.exception(f'Error: ')
has_error = True
exception = e
report_path = None
if self.auto_save_error_report:
raise NotImplementedError
self.events.task_status_changed.trigger(task, 'error')
if not self.resume_on_error:
for task1 in tasks[tasks.index(task)+1:]:
for task1 in all_tasks[all_tasks.index(task)+1:]:
self.events.task_status_changed.trigger(task1, 'cancelled')
break
logger.info(f'Task finished: {task.name}')
logger.info('All tasks finished.')
logger.info(f'Task ended: {task.name}')
logger.info('All tasks ended.')
self.events.finished.trigger()
def run_all(self) -> None:
@ -228,7 +276,7 @@ class KotoneBot:
self.events.finished -= _on_finished
self.events.task_status_changed -= _on_task_status_changed
def _on_task_status_changed(task: Task, status: Literal['pending', 'running', 'finished', 'error', 'cancelled']):
def _on_task_status_changed(task: Task, status: TaskStatusValue):
def _find(task: Task) -> TaskStatus:
for task_status in run_status.tasks:
if task_status.task == task:

View File

@ -50,7 +50,7 @@ from kotonebot.backend.ocr import (
from kotonebot.config.manager import load_config, save_config
from kotonebot.config.base_config import UserConfig
from kotonebot.backend.core import Image, HintBox
from kotonebot.errors import KotonebotWarning
from kotonebot.errors import ContextNotInitializedError, KotonebotWarning
from kotonebot.backend.preprocessor import PreprocessorProtocol
from kotonebot.primitives import Rect
@ -285,11 +285,17 @@ class ContextOcr:
self.context = context
self.__engine = jp()
def raw(self, lang: OcrLanguage = 'jp') -> Ocr:
def _get_engine(self, lang: OcrLanguage | None = None) -> Ocr:
"""获取指定语言的OCR引擎如果lang为None则使用默认引擎。"""
return self.__engine if lang is None else self.raw(lang)
def raw(self, lang: OcrLanguage | None = None) -> Ocr:
"""
返回 `kotonebot.backend.ocr` 中的 Ocr 对象\n
Ocr 对象与此对象ContextOcr的区别是此对象会自动截图 Ocr 对象需要手动传入图像参数
"""
if lang is None:
lang = 'jp'
match lang:
case 'jp':
return jp()
@ -301,9 +307,11 @@ class ContextOcr:
def ocr(
self,
rect: Rect | None = None,
lang: OcrLanguage | None = None,
) -> OcrResultList:
"""OCR 当前设备画面或指定图像。"""
return self.__engine.ocr(ContextStackVars.ensure_current().screenshot, rect=rect)
engine = self._get_engine(lang)
return engine.ocr(ContextStackVars.ensure_current().screenshot, rect=rect)
def find(
self,
@ -311,9 +319,11 @@ class ContextOcr:
*,
hint: HintBox | None = None,
rect: Rect | None = None,
lang: OcrLanguage | None = None,
) -> OcrResult | None:
"""检查当前设备画面是否包含指定文本。"""
ret = self.__engine.find(
engine = self._get_engine(lang)
ret = engine.find(
ContextStackVars.ensure_current().screenshot,
pattern,
hint=hint,
@ -328,9 +338,10 @@ class ContextOcr:
*,
hint: HintBox | None = None,
rect: Rect | None = None,
lang: OcrLanguage | None = None,
) -> list[OcrResult | None]:
return self.__engine.find_all(
engine = self._get_engine(lang)
return engine.find_all(
ContextStackVars.ensure_current().screenshot,
list(patterns),
hint=hint,
@ -343,6 +354,7 @@ class ContextOcr:
*,
rect: Rect | None = None,
hint: HintBox | None = None,
lang: OcrLanguage | None = None,
) -> OcrResult:
"""
@ -350,7 +362,8 @@ class ContextOcr:
`find()` 的区别在于`expect()` 未找到时会抛出异常
"""
ret = self.__engine.expect(ContextStackVars.ensure_current().screenshot, pattern, rect=rect, hint=hint)
engine = self._get_engine(lang)
ret = engine.expect(ContextStackVars.ensure_current().screenshot, pattern, rect=rect, hint=hint)
self.context.device.last_find = ret.original_rect if ret else None
return ret
@ -706,14 +719,14 @@ class Forwarded:
if name.startswith('_FORWARD_'):
return object.__getattribute__(self, name)
if self._FORWARD_getter is None:
raise ValueError(f"Forwarded object {self._FORWARD_name} called before initialization.")
raise ContextNotInitializedError(f"Forwarded object {self._FORWARD_name} called before initialization.")
return getattr(self._FORWARD_getter(), name)
def __setattr__(self, name: str, value: Any):
if name.startswith('_FORWARD_'):
return object.__setattr__(self, name, value)
if self._FORWARD_getter is None:
raise ValueError(f"Forwarded object {self._FORWARD_name} called before initialization.")
raise ContextNotInitializedError(f"Forwarded object {self._FORWARD_name} called before initialization.")
setattr(self._FORWARD_getter(), name, value)
@ -961,7 +974,7 @@ def inject_context(
):
global _c
if _c is None:
raise RuntimeError('Context not initialized')
raise ContextNotInitializedError('Context not initialized')
_c.inject(device=device, ocr=ocr, image=image, color=color, vars=vars, debug=debug, config=config)
class ManualContextManager:

View File

@ -1,19 +1,18 @@
import logging
from typing import Callable, ParamSpec, TypeVar, overload, Concatenate, Literal
from typing import Callable, ParamSpec, TypeVar, overload, Literal
from dataclasses import dataclass
from typing_extensions import deprecated
import cv2
from cv2.typing import MatLike
from .context import ContextStackVars, ScreenshotMode
from ..dispatch import dispatcher as dispatcher_decorator, DispatcherContext
from ...errors import TaskNotFoundError
P = ParamSpec('P')
R = TypeVar('R')
logger = logging.getLogger(__name__)
TaskRunAtType = Literal['pre', 'post', 'manual', 'regular'] | str
@dataclass
class Task:
name: str
@ -24,6 +23,8 @@ class Task:
"""
任务优先级数字越大优先级越高
"""
run_at: TaskRunAtType = 'regular'
@dataclass
class Action:
@ -51,6 +52,7 @@ def task(
pass_through: bool = False,
priority: int = 0,
screenshot_mode: ScreenshotMode = 'auto',
run_at: TaskRunAtType = 'regular'
):
"""
`task` 装饰器用于标记一个函数为任务函数
@ -62,6 +64,7 @@ def task(
默认情况下 @task 装饰器会包裹任务函数跟踪其执行情况
如果不想跟踪则设置此参数为 False
:param priority: 任务优先级数字越大优先级越高
:param run_at: 任务运行时间
"""
# 设置 ID
# 获取 caller 信息
@ -70,7 +73,7 @@ def task(
description = description or func.__doc__ or ''
# TODO: task_id 冲突检测
task_id = task_id or func.__name__
task = Task(name, task_id, description, _placeholder, priority)
task = Task(name, task_id, description, _placeholder, priority, run_at)
task_registry[name] = task
logger.debug(f'Task "{name}" registered.')
if pass_through:
@ -98,7 +101,6 @@ def action(
pass_through: bool = False,
priority: int = 0,
screenshot_mode: ScreenshotMode | None = None,
dispatcher: Literal[False] = False,
) -> Callable[[Callable[P, R]], Callable[P, R]]:
"""
`action` 装饰器用于标记一个函数为动作函数
@ -110,38 +112,6 @@ def action(
如果不想跟踪则设置此参数为 False
:param priority: 动作优先级数字越大优先级越高
:param screenshot_mode: 截图模式
:param dispatcher:
是否为分发器模式默认为假
如果使用分发器则函数的第一个参数必须为 `ctx: DispatcherContext`
"""
...
@overload
@deprecated('使用普通 while 循环代替')
def action(
name: str,
*,
description: str|None = None,
pass_through: bool = False,
priority: int = 0,
screenshot_mode: ScreenshotMode | None = None,
dispatcher: Literal[True, 'fragment'] = True,
) -> Callable[[Callable[Concatenate[DispatcherContext, P], R]], Callable[P, R]]:
"""
`action` 装饰器用于标记一个函数为动作函数
此重载启用了分发器模式被装饰函数的第一个参数必须为 `ctx: DispatcherContext`
:param name: 动作名称如果为 None则使用函数的名称作为名称
:param description: 动作描述如果为 None则使用函数的 docstring 作为描述
:param pass_through:
默认情况下 @action 装饰器会包裹动作函数跟踪其执行情况
如果不想跟踪则设置此参数为 False
:param priority: 动作优先级数字越大优先级越高
:param screenshot_mode: 截图模式必须为 `'manual' / None`
:param dispatcher:
是否为分发器模式默认为假
如果使用分发器则函数的第一个参数必须为 `ctx: DispatcherContext`
"""
...
@ -176,11 +146,6 @@ def action(*args, **kwargs):
pass_through = kwargs.get('pass_through', False)
priority = kwargs.get('priority', 0)
screenshot_mode = kwargs.get('screenshot_mode', None)
dispatcher = kwargs.get('dispatcher', False)
if dispatcher == True or dispatcher == 'fragment':
if not (screenshot_mode is None or screenshot_mode == 'manual'):
raise ValueError('`screenshot_mode` must be None or "manual" when `dispatcher=True`.')
screenshot_mode = 'manual'
def _action_decorator(func: Callable):
nonlocal pass_through
action = _register(_placeholder, name, description)
@ -188,8 +153,6 @@ def action(*args, **kwargs):
if pass_through:
return func
else:
if dispatcher:
func = dispatcher_decorator(func, fragment=(dispatcher == 'fragment')) # type: ignore
def _wrapper(*args: P.args, **kwargs: P.kwargs):
current_callstack.append(action)
vars = ContextStackVars.push(screenshot_mode=screenshot_mode)

View File

@ -1,12 +1,7 @@
import time
import uuid
import logging
import inspect
from logging import Logger
from types import CodeType
from dataclasses import dataclass
from typing import Annotated, Any, Callable, Concatenate, Sequence, TypeVar, ParamSpec, Literal, Protocol, cast
from typing_extensions import deprecated
from typing import Any, Callable, Literal
from dataclasses import dataclass
@ -16,104 +11,6 @@ from kotonebot.primitives import Rect, is_rect
from .core import Image
logger = logging.getLogger(__name__)
P = ParamSpec('P')
R = TypeVar('R')
ThenAction = Literal['click', 'log']
DoAction = Literal['click']
# TODO: 需要找个地方统一管理这些属性名
ATTR_DISPATCHER_MARK = '__kb_dispatcher_mark'
ATTR_ORIGINAL_FUNC = '_kb_inner'
class DispatchFunc: pass
wrapper_to_func: dict[Callable, Callable] = {}
class DispatcherContext:
def __init__(self):
self.finished: bool = False
self._first_run: bool = True
def finish(self):
"""标记已完成 dispatcher 循环。循环将在下次条件检测时退出。"""
self.finished = True
def expand(self, func: Annotated[Callable[[], Any], DispatchFunc], ignore_finish: bool = True):
"""
调用其他 dispatcher 函数
使用 `expand` 和直接调用的区别是
* 直接调用会执行 while 循环直到满足结束条件
* 而使用 `expand` 则只会执行一次效果类似于将目标函数里的代码直接复制粘贴过来
"""
# 获取原始函数
original_func = func
while not getattr(original_func, ATTR_DISPATCHER_MARK, False):
original_func = getattr(original_func, ATTR_ORIGINAL_FUNC)
original_func = getattr(original_func, ATTR_ORIGINAL_FUNC)
if not original_func:
raise ValueError(f'{repr(func)} is not a dispatcher function.')
elif not callable(original_func):
raise ValueError(f'{repr(original_func)} is not callable.')
original_func = cast(Callable[[DispatcherContext], Any], original_func)
old_finished = self.finished
ret = original_func(self)
if ignore_finish:
self.finished = old_finished
return ret
@property
def beginning(self) -> bool:
"""是否为第一次运行"""
return self._first_run
@property
def finishing(self) -> bool:
"""是否即将结束运行"""
return self.finished
@deprecated('使用 SimpleDispatcher 类或 while 循环替代')
def dispatcher(
func: Callable[Concatenate[DispatcherContext, P], R],
*,
fragment: bool = False
) -> Annotated[Callable[P, R], DispatchFunc]:
"""
注意\n
此装饰器必须在应用 @action/@task 装饰器后再应用 `screenshot_mode='manual'` 参数必须设置
或者也可以使用 @action/@task 装饰器中的 `dispatcher=True` 参数
那么就没有上面两个要求了
:param fragment:
片段模式默认不启用
启用后被装饰函数将会只执行依次
而不会一直循环到 ctx.finish() 被调用
"""
def wrapper(*args: P.args, **kwargs: P.kwargs):
ctx = DispatcherContext()
while not ctx.finished:
from kotonebot import device
device.screenshot()
ret = func(ctx, *args, **kwargs)
ctx._first_run = False
return ret
def fragment_wrapper(*args: P.args, **kwargs: P.kwargs):
ctx = DispatcherContext()
from kotonebot import device
device.screenshot()
return func(ctx, *args, **kwargs)
setattr(wrapper, ATTR_ORIGINAL_FUNC, func)
setattr(fragment_wrapper, ATTR_ORIGINAL_FUNC, func)
setattr(wrapper, ATTR_DISPATCHER_MARK, True)
setattr(fragment_wrapper, ATTR_DISPATCHER_MARK, True)
wrapper_to_func[wrapper] = func
if fragment:
return fragment_wrapper
else:
return wrapper
@dataclass
class ClickParams:

View File

@ -95,7 +95,7 @@ class FlowController:
logger.info('Interrupt requested.')
self.interrupt_event.set()
def request_pause(self) -> None:
def request_pause(self, *, wait_resume: bool = False) -> None:
"""
请求暂停任务
@ -106,6 +106,8 @@ class FlowController:
if not self.paused:
logger.info('Pause requested.')
self.paused = True
if wait_resume:
self.check()
def request_resume(self) -> None:
"""

View File

@ -1,9 +1,41 @@
from typing import Callable
class KotonebotError(Exception):
pass
class KotonebotWarning(Warning):
pass
class UserFriendlyError(KotonebotError):
def __init__(
self,
message: str,
actions: list[tuple[int, str, Callable[[], None]]] = [],
*args, **kwargs
) -> None:
super().__init__(*args, **kwargs)
self.message = message
self.actions = actions or []
@property
def action_buttons(self) -> list[tuple[int, str]]:
"""
(id: int, btn_text: str) 的形式返回所有按钮定义
"""
return [(id, text) for id, text, _ in self.actions]
def invoke(self, action_id: int):
"""
执行指定 ID action
"""
for id, _, func in self.actions:
if id == action_id:
func()
break
else:
raise ValueError(f'Action with id {action_id} not found.')
class UnrecoverableError(KotonebotError):
pass
@ -30,4 +62,11 @@ class UnscalableResolutionError(KotonebotError):
self.target_resolution = target_resolution
self.screen_size = screen_size
super().__init__(f'Cannot scale to target resolution {target_resolution}. '
f'Screen size: {screen_size}')
f'Screen size: {screen_size}')
class ContextNotInitializedError(KotonebotError):
def __init__(self, msg: str = 'Context not initialized'):
super().__init__(msg)
class StopCurrentTask(KotonebotError):
pass

View File

@ -0,0 +1,314 @@
import ctypes
from typing import Optional, Literal, List, overload
from typing_extensions import assert_never
# 按钮常量
MB_OK = 0x00000000
MB_OKCANCEL = 0x00000001
MB_ABORTRETRYIGNORE = 0x00000002
MB_YESNOCANCEL = 0x00000003
MB_YESNO = 0x00000004
MB_RETRYCANCEL = 0x00000005
MB_CANCELTRYCONTINUE = 0x00000006
# 图标常量
MB_ICONSTOP = 0x00000010
MB_ICONERROR = 0x00000010
MB_ICONQUESTION = 0x00000020
MB_ICONWARNING = 0x00000030
MB_ICONINFORMATION = 0x00000040
# 默认按钮常量
MB_DEFBUTTON1 = 0x00000000
MB_DEFBUTTON2 = 0x00000100
MB_DEFBUTTON3 = 0x00000200
MB_DEFBUTTON4 = 0x00000300
# 模态常量
MB_APPLMODAL = 0x00000000
MB_SYSTEMMODAL = 0x00001000
MB_TASKMODAL = 0x00002000
# 其他选项
MB_HELP = 0x00004000
MB_NOFOCUS = 0x00008000
MB_SETFOREGROUND = 0x00010000
MB_DEFAULT_DESKTOP_ONLY = 0x00020000
MB_TOPMOST = 0x00040000
MB_RIGHT = 0x00080000
MB_RTLREADING = 0x00100000
MB_SERVICE_NOTIFICATION = 0x00200000
# 返回值常量
IDOK = 1
IDCANCEL = 2
IDABORT = 3
IDRETRY = 4
IDIGNORE = 5
IDYES = 6
IDNO = 7
IDCLOSE = 8
IDHELP = 9
IDTRYAGAIN = 10
IDCONTINUE = 11
# 为清晰起见,定义类型别名
ButtonsType = Literal['ok', 'ok_cancel', 'abort_retry_ignore', 'yes_no_cancel', 'yes_no', 'retry_cancel', 'cancel_try_continue']
IconType = Optional[Literal['stop', 'error', 'question', 'warning', 'information']]
DefaultButtonType = Literal['button1', 'button2', 'button3', 'button4']
ModalType = Literal['application', 'system', 'task']
OptionsType = Optional[List[Literal['help', 'no_focus', 'set_foreground', 'default_desktop_only', 'topmost', 'right', 'rtl_reading', 'service_notification']]]
ReturnType = Literal['ok', 'cancel', 'abort', 'retry', 'ignore', 'yes', 'no', 'close', 'help', 'try_again', 'continue']
user32 = ctypes.windll.user32
@overload
def message_box(
hWnd: Optional[int],
text: str,
caption: str,
buttons: Literal['ok'] = 'ok',
icon: IconType = None,
default_button: DefaultButtonType = 'button1',
modal: ModalType = 'application',
options: OptionsType = None
) -> Literal['ok']: ...
@overload
def message_box(
hWnd: Optional[int],
text: str,
caption: str,
buttons: Literal['ok_cancel'],
icon: IconType = None,
default_button: DefaultButtonType = 'button1',
modal: ModalType = 'application',
options: OptionsType = None
) -> Literal['ok', 'cancel']: ...
@overload
def message_box(
hWnd: Optional[int],
text: str,
caption: str,
buttons: Literal['abort_retry_ignore'],
icon: IconType = None,
default_button: DefaultButtonType = 'button1',
modal: ModalType = 'application',
options: OptionsType = None
) -> Literal['abort', 'retry', 'ignore']: ...
@overload
def message_box(
hWnd: Optional[int],
text: str,
caption: str,
buttons: Literal['yes_no_cancel'],
icon: IconType = None,
default_button: DefaultButtonType = 'button1',
modal: ModalType = 'application',
options: OptionsType = None
) -> Literal['yes', 'no', 'cancel']: ...
@overload
def message_box(
hWnd: Optional[int],
text: str,
caption: str,
buttons: Literal['yes_no'],
icon: IconType = None,
default_button: DefaultButtonType = 'button1',
modal: ModalType = 'application',
options: OptionsType = None
) -> Literal['yes', 'no']: ...
@overload
def message_box(
hWnd: Optional[int],
text: str,
caption: str,
buttons: Literal['retry_cancel'],
icon: IconType = None,
default_button: DefaultButtonType = 'button1',
modal: ModalType = 'application',
options: OptionsType = None
) -> Literal['retry', 'cancel']: ...
@overload
def message_box(
hWnd: Optional[int],
text: str,
caption: str,
buttons: Literal['cancel_try_continue'],
icon: IconType = None,
default_button: DefaultButtonType = 'button1',
modal: ModalType = 'application',
options: OptionsType = None
) -> Literal['cancel', 'try_again', 'continue']: ...
def message_box(
hWnd: Optional[int],
text: str,
caption: str,
buttons: ButtonsType = 'ok',
icon: IconType = None,
default_button: DefaultButtonType = 'button1',
modal: ModalType = 'application',
options: OptionsType = None
) -> ReturnType:
"""
显示消息框
:param hWnd: 所属窗口的句柄可以为 None
:param text: 要显示的消息
:param caption: 消息框的标题
:param buttons: 要显示的按钮
:param icon: 要显示的图标
:param default_button: 默认按钮
:param modal: 消息框的模态
:param options: 其他杂项选项列表
:return: 表示用户点击的按钮的字符串
"""
uType = 0
# --- 按钮类型 ---
match buttons:
case 'ok':
uType |= MB_OK
case 'ok_cancel':
uType |= MB_OKCANCEL
case 'abort_retry_ignore':
uType |= MB_ABORTRETRYIGNORE
case 'yes_no_cancel':
uType |= MB_YESNOCANCEL
case 'yes_no':
uType |= MB_YESNO
case 'retry_cancel':
uType |= MB_RETRYCANCEL
case 'cancel_try_continue':
uType |= MB_CANCELTRYCONTINUE
case _:
assert_never(buttons)
# --- 图标类型 ---
if icon:
match icon:
case 'stop' | 'error':
uType |= MB_ICONSTOP
case 'question':
uType |= MB_ICONQUESTION
case 'warning':
uType |= MB_ICONWARNING
case 'information':
uType |= MB_ICONINFORMATION
case _:
assert_never(icon)
# --- 默认按钮 ---
match default_button:
case 'button1':
uType |= MB_DEFBUTTON1
case 'button2':
uType |= MB_DEFBUTTON2
case 'button3':
uType |= MB_DEFBUTTON3
case 'button4':
uType |= MB_DEFBUTTON4
case _:
assert_never(default_button)
# --- 模态 ---
match modal:
case 'application':
uType |= MB_APPLMODAL
case 'system':
uType |= MB_SYSTEMMODAL
case 'task':
uType |= MB_TASKMODAL
case _:
assert_never(modal)
# --- 其他选项 ---
if options:
for option in options:
match option:
case 'help':
uType |= MB_HELP
case 'no_focus':
uType |= MB_NOFOCUS
case 'set_foreground':
uType |= MB_SETFOREGROUND
case 'default_desktop_only':
uType |= MB_DEFAULT_DESKTOP_ONLY
case 'topmost':
uType |= MB_TOPMOST
case 'right':
uType |= MB_RIGHT
case 'rtl_reading':
uType |= MB_RTLREADING
case 'service_notification':
uType |= MB_SERVICE_NOTIFICATION
case _:
assert_never(option)
result = user32.MessageBoxW(hWnd, text, caption, uType)
match result:
case 1: # IDOK
return 'ok'
case 2: # IDCANCEL
return 'cancel'
case 3: # IDABORT
return 'abort'
case 4: # IDRETRY
return 'retry'
case 5: # IDIGNORE
return 'ignore'
case 6: # IDYES
return 'yes'
case 7: # IDNO
return 'no'
case 8: # IDCLOSE
return 'close'
case 9: # IDHELP
return 'help'
case 10: # IDTRYAGAIN
return 'try_again'
case 11: # IDCONTINUE
return 'continue'
case _:
# 对于标准消息框,不应发生这种情况
raise RuntimeError(f"Unknown MessageBox return code: {result}")
if __name__ == '__main__':
# 示例用法
response = message_box(
None,
"是否要退出程序?",
"确认",
buttons='yes_no',
icon='question'
)
if response == 'yes':
print("程序退出。")
else:
print("程序继续运行。")
message_box(
None,
"操作已完成。",
"通知",
buttons='ok',
icon='information'
)

View File

@ -0,0 +1,469 @@
import ctypes
from ctypes import wintypes
import time
from typing import List, Tuple, Optional
from typing import Literal
__all__ = [
"TaskDialog",
"TDCBF_OK_BUTTON", "TDCBF_YES_BUTTON", "TDCBF_NO_BUTTON", "TDCBF_CANCEL_BUTTON",
"TDCBF_RETRY_BUTTON", "TDCBF_CLOSE_BUTTON",
"IDOK", "IDCANCEL", "IDABORT", "IDRETRY", "IDIGNORE", "IDYES", "IDNO", "IDCLOSE",
"TD_WARNING_ICON", "TD_ERROR_ICON", "TD_INFORMATION_ICON", "TD_SHIELD_ICON"
]
# --- Windows API 常量定义 ---
# 常用按钮
TDCBF_OK_BUTTON = 0x0001
TDCBF_YES_BUTTON = 0x0002
TDCBF_NO_BUTTON = 0x0004
TDCBF_CANCEL_BUTTON = 0x0008
TDCBF_RETRY_BUTTON = 0x0010
TDCBF_CLOSE_BUTTON = 0x0020
# 对话框返回值
IDOK = 1
IDCANCEL = 2
IDABORT = 3
IDRETRY = 4
IDIGNORE = 5
IDYES = 6
IDNO = 7
IDCLOSE = 8
# 标准图标 (使用 MAKEINTRESOURCE 宏)
def MAKEINTRESOURCE(i: int) -> wintypes.LPWSTR:
return wintypes.LPWSTR(i)
TD_WARNING_ICON = MAKEINTRESOURCE(65535)
TD_ERROR_ICON = MAKEINTRESOURCE(65534)
TD_INFORMATION_ICON = MAKEINTRESOURCE(65533)
TD_SHIELD_ICON = MAKEINTRESOURCE(65532)
# Task Dialog 标志
TDF_ENABLE_HYPERLINKS = 0x0001
TDF_USE_HICON_MAIN = 0x0002
TDF_USE_HICON_FOOTER = 0x0004
TDF_ALLOW_DIALOG_CANCELLATION = 0x0008
TDF_USE_COMMAND_LINKS = 0x0010
TDF_USE_COMMAND_LINKS_NO_ICON = 0x0020
TDF_EXPAND_FOOTER_AREA = 0x0040
TDF_EXPANDED_BY_DEFAULT = 0x0080
TDF_VERIFICATION_FLAG_CHECKED = 0x0100
TDF_SHOW_PROGRESS_BAR = 0x0200
TDF_SHOW_MARQUEE_PROGRESS_BAR = 0x0400
TDF_CALLBACK_TIMER = 0x0800
TDF_POSITION_RELATIVE_TO_WINDOW = 0x1000
TDF_RTL_LAYOUT = 0x2000
TDF_NO_DEFAULT_RADIO_BUTTON = 0x4000
TDF_CAN_BE_MINIMIZED = 0x8000
# Task Dialog 通知
TDN_CREATED = 0
TDN_NAVIGATED = 1
TDN_BUTTON_CLICKED = 2
TDN_HYPERLINK_CLICKED = 3
TDN_TIMER = 4
TDN_DESTROYED = 5
TDN_RADIO_BUTTON_CLICKED = 6
TDN_DIALOG_CONSTRUCTED = 7
TDN_VERIFICATION_CLICKED = 8
TDN_HELP = 9
TDN_EXPANDO_BUTTON_CLICKED = 10
# Windows 消息
WM_USER = 0x0400
TDM_SET_PROGRESS_BAR_POS = WM_USER + 114
CommonButtonLiteral = Literal["ok", "yes", "no", "cancel", "retry", "close"]
IconLiteral = Literal["warning", "error", "information", "shield"]
# --- C 结构体定义 (使用 ctypes) ---
class TASKDIALOG_BUTTON(ctypes.Structure):
_pack_ = 1
_fields_ = [("nButtonID", ctypes.c_int),
("pszButtonText", wintypes.LPCWSTR)]
# 定义回调函数指针原型
PFTASKDIALOGCALLBACK = ctypes.WINFUNCTYPE(
ctypes.HRESULT, # 返回值
wintypes.HWND, # hwnd
ctypes.c_uint, # msg
ctypes.c_size_t, # wParam
ctypes.c_size_t, # lParam
ctypes.c_ssize_t # lpRefData
)
class TASKDIALOGCONFIG(ctypes.Structure):
_pack_ = 1
_fields_ = [
("cbSize", ctypes.c_uint),
("hwndParent", wintypes.HWND),
("hInstance", wintypes.HINSTANCE),
("dwFlags", ctypes.c_uint),
("dwCommonButtons", ctypes.c_uint),
("pszWindowTitle", wintypes.LPCWSTR),
("pszMainIcon", wintypes.LPCWSTR),
("pszMainInstruction", wintypes.LPCWSTR),
("pszContent", wintypes.LPCWSTR),
("cButtons", ctypes.c_uint),
("pButtons", ctypes.POINTER(TASKDIALOG_BUTTON)),
("nDefaultButton", ctypes.c_int),
("cRadioButtons", ctypes.c_uint),
("pRadioButtons", ctypes.POINTER(TASKDIALOG_BUTTON)),
("nDefaultRadioButton", ctypes.c_int),
("pszVerificationText", wintypes.LPCWSTR),
("pszExpandedInformation", wintypes.LPCWSTR),
("pszExpandedControlText", wintypes.LPCWSTR),
("pszCollapsedControlText", wintypes.LPCWSTR),
("pszFooterIcon", wintypes.LPCWSTR),
("pszFooter", wintypes.LPCWSTR),
("pfCallback", PFTASKDIALOGCALLBACK), # 使用定义好的原型
("lpCallbackData", ctypes.c_ssize_t),
("cxWidth", ctypes.c_uint)
]
# --- 加载 comctl32.dll 并定义函数原型 ---
comctl32 = ctypes.WinDLL('comctl32')
user32 = ctypes.WinDLL('user32')
TaskDialogIndirect = comctl32.TaskDialogIndirect
TaskDialogIndirect.restype = ctypes.HRESULT
TaskDialogIndirect.argtypes = [
ctypes.POINTER(TASKDIALOGCONFIG),
ctypes.POINTER(ctypes.c_int),
ctypes.POINTER(ctypes.c_int),
ctypes.POINTER(wintypes.BOOL)
]
# --- Python 封装类 ---
class TaskDialog:
"""
一个用于显示 Windows TaskDialog Python 封装类
支持自定义按钮单选按钮进度条验证框等
"""
def __init__(self,
parent_hwnd: Optional[int] = None,
title: str = "Task Dialog",
main_instruction: str = "",
content: str = "",
common_buttons: int | List[CommonButtonLiteral] = TDCBF_OK_BUTTON,
main_icon: Optional[wintypes.LPWSTR | int | IconLiteral] = None,
footer: str = "",
custom_buttons: Optional[List[Tuple[int, str]]] = None,
default_button: int = 0,
radio_buttons: Optional[List[Tuple[int, str]]] = None,
default_radio_button: int = 0,
verification_text: Optional[str] = None,
verification_checked_by_default: bool = False,
show_progress_bar: bool = False,
show_marquee_progress_bar: bool = False
):
"""初始化 TaskDialog 实例。
:param parent_hwnd: 父窗口的句柄
:param title: 对话框窗口的标题
:param main_instruction: 对话框的主要指令文本
:param content: 对话框的详细内容文本
:param common_buttons: 要显示的通用按钮可以是以下两种形式之一
1. TDCBF_* 常量的按位或组合 (例如 TDCBF_OK_BUTTON | TDCBF_CANCEL_BUTTON)
2. 字符串列表支持 "ok", "yes", "no", "cancel", "retry", "close"
:param main_icon: 主图标可以是以下几种形式之一
1. TD_*_ICON 常量之一
2. HICON 句柄
3. 字符串"warning", "error", "information", "shield"
:param footer: 页脚区域显示的文本
:param custom_buttons: 自定义按钮列表每个元组包含 (按钮ID, 按钮文本)
:param default_button: 默认按钮的ID可以是通用按钮ID (例如 IDOK) 或自定义按钮ID
:param radio_buttons: 单选按钮列表每个元组包含 (按钮ID, 按钮文本)
:param default_radio_button: 默认选中的单选按钮的ID
:param verification_text: 验证复选框的文本如果为 None则不显示复选框
:param verification_checked_by_default: 验证复选框是否默认勾选
:param show_progress_bar: 是否显示标准进度条
:param show_marquee_progress_bar: 是否显示跑马灯式进度条
"""
self.config = TASKDIALOGCONFIG()
self.config.cbSize = ctypes.sizeof(TASKDIALOGCONFIG)
self.config.hwndParent = parent_hwnd
self.config.dwFlags = TDF_ALLOW_DIALOG_CANCELLATION | TDF_POSITION_RELATIVE_TO_WINDOW
self.config.dwCommonButtons = self._process_common_buttons(common_buttons)
self.config.pszWindowTitle = title
self.config.pszMainInstruction = main_instruction
self.config.pszContent = content
self.config.pszFooter = footer
self.progress: int = 0
if show_progress_bar or show_marquee_progress_bar:
# 进度条暂时还没实现
raise NotImplementedError("Progress bar is not implemented yet.")
self.config.dwFlags |= TDF_CALLBACK_TIMER
if show_progress_bar:
self.config.dwFlags |= TDF_SHOW_PROGRESS_BAR
else:
self.config.dwFlags |= TDF_SHOW_MARQUEE_PROGRESS_BAR
# 将实例方法转为 C 回调函数指针。
# 必须将其保存为实例成员,否则会被垃圾回收!
self._callback_func_ptr = PFTASKDIALOGCALLBACK(self._callback)
self.config.pfCallback = self._callback_func_ptr
# 将本实例的id作为lpCallbackData传递以便在回调中识别
self.config.lpCallbackData = id(self)
# --- 图标设置 ---
processed_icon = self._process_main_icon(main_icon)
if processed_icon is not None:
if isinstance(processed_icon, wintypes.LPWSTR):
self.config.pszMainIcon = processed_icon
else:
self.config.dwFlags |= TDF_USE_HICON_MAIN
self.config.hMainIcon = processed_icon
# --- 自定义按钮设置 ---
self.custom_buttons_list = []
if custom_buttons:
self.config.cButtons = len(custom_buttons)
button_array_type = TASKDIALOG_BUTTON * len(custom_buttons)
self.custom_buttons_list = button_array_type()
for i, (btn_id, btn_text) in enumerate(custom_buttons):
self.custom_buttons_list[i].nButtonID = btn_id
self.custom_buttons_list[i].pszButtonText = btn_text
self.config.pButtons = self.custom_buttons_list
if default_button:
self.config.nDefaultButton = default_button
# --- 单选按钮设置 ---
self.radio_buttons_list = []
if radio_buttons:
self.config.cRadioButtons = len(radio_buttons)
radio_array_type = TASKDIALOG_BUTTON * len(radio_buttons)
self.radio_buttons_list = radio_array_type()
for i, (btn_id, btn_text) in enumerate(radio_buttons):
self.radio_buttons_list[i].nButtonID = btn_id
self.radio_buttons_list[i].pszButtonText = btn_text
self.config.pRadioButtons = self.radio_buttons_list
if default_radio_button:
self.config.nDefaultRadioButton = default_radio_button
# --- 验证复选框设置 ---
if verification_text:
self.config.pszVerificationText = verification_text
if verification_checked_by_default:
self.config.dwFlags |= TDF_VERIFICATION_FLAG_CHECKED
def _process_common_buttons(self, common_buttons: int | List[CommonButtonLiteral]) -> int:
"""处理 common_buttons 参数,支持常量和字符串列表两种形式"""
if isinstance(common_buttons, int):
# 直接使用 Win32 常量
return common_buttons
elif isinstance(common_buttons, list):
# 处理字符串列表
result = 0
for button in common_buttons:
# 使用 match 和 assert_never 进行类型检查
match button:
case "ok":
result |= TDCBF_OK_BUTTON
case "yes":
result |= TDCBF_YES_BUTTON
case "no":
result |= TDCBF_NO_BUTTON
case "cancel":
result |= TDCBF_CANCEL_BUTTON
case "retry":
result |= TDCBF_RETRY_BUTTON
case "close":
result |= TDCBF_CLOSE_BUTTON
case _:
# 这在实际中不会发生,因为类型检查会阻止它
from typing import assert_never
assert_never(button)
return result
else:
raise TypeError("common_buttons must be either an int or a list of strings")
def _process_main_icon(self, main_icon: Optional[wintypes.LPWSTR | int | IconLiteral]) -> Optional[wintypes.LPWSTR | int]:
"""处理 main_icon 参数,支持常量和字符串两种形式"""
if main_icon is None:
return None
elif isinstance(main_icon, (wintypes.LPWSTR, int)):
# 直接使用 Win32 常量或 HICON 句柄
return main_icon
elif isinstance(main_icon, str):
# 处理字符串
match main_icon:
case "warning":
return TD_WARNING_ICON
case "error":
return TD_ERROR_ICON
case "information":
return TD_INFORMATION_ICON
case "shield":
return TD_SHIELD_ICON
case _:
# 这在实际中不会发生,因为类型检查会阻止它
from typing import assert_never
assert_never(main_icon)
else:
raise TypeError("main_icon must be None, a Windows constant, or a string")
def _callback(self, hwnd: wintypes.HWND, msg: int, wParam: int, lParam: int, lpRefData: int) -> int:
# 仅当 lpRefData 指向的是当前这个对象实例时才处理
if lpRefData != id(self):
return 0 # S_OK
if msg == TDN_TIMER:
# 更新进度条
if self.progress < 100:
self.progress += 5
# 发送消息给对话框来更新进度条位置
user32.SendMessageW(hwnd, TDM_SET_PROGRESS_BAR_POS, self.progress, 0)
else:
# 示例进度达到100%后可以模拟点击OK按钮关闭对话框
# from ctypes import wintypes
# user32.PostMessageW(hwnd, wintypes.UINT(1125), IDOK, 0) # TDM_CLICK_BUTTON
pass
elif msg == TDN_DESTROYED:
# 对话框已销毁
pass
return 0 # S_OK
def show(self) -> Tuple[int, int, bool]:
"""
显示对话框并返回用户交互的结果
:return: 一个元组 (button_id, radio_button_id, verification_checked)
- button_id: 用户点击的按钮ID (例如 IDOK, IDCANCEL)
- radio_button_id: 用户选择的单选按钮的ID
- verification_checked: 验证复选框是否被勾选 (True/False)
"""
pnButton = ctypes.c_int(0)
pnRadioButton = ctypes.c_int(0)
pfVerificationFlagChecked = wintypes.BOOL(False)
hr = TaskDialogIndirect(
ctypes.byref(self.config),
ctypes.byref(pnButton),
ctypes.byref(pnRadioButton),
ctypes.byref(pfVerificationFlagChecked)
)
if hr == 0: # S_OK
return pnButton.value, pnRadioButton.value, bool(pfVerificationFlagChecked.value)
else:
raise ctypes.WinError(hr)
# --- 示例用法 ---
if __name__ == '__main__':
print("--- 示例 1: 简单信息框 ---")
dlg_simple = TaskDialog(
title="操作成功",
main_instruction="您的操作已成功完成。",
content="文件已保存到您的文档目录。",
common_buttons=["ok"],
main_icon="information"
)
result_simple, _, _ = dlg_simple.show()
print(f"用户点击了按钮: {result_simple} (1=OK)\n")
print("--- 示例 2: 确认框 ---")
dlg_confirm = TaskDialog(
title="确认删除",
main_instruction="您确定要永久删除这个文件吗?",
content="这个操作无法撤销。文件将被立即删除。",
common_buttons=["yes", "no", "cancel"],
main_icon="warning",
default_button=IDNO
)
result_confirm, _, _ = dlg_confirm.show()
if result_confirm == IDYES:
print("用户选择了“是”。")
elif result_confirm == IDNO:
print("用户选择了“否”。")
elif result_confirm == IDCANCEL:
print("用户选择了“取消”。")
print(f"返回的按钮ID: {result_confirm}\n")
# 示例 3
print("--- 示例 3: 自定义按钮 ---")
CUSTOM_BUTTON_SAVE_ID = 101
CUSTOM_BUTTON_DONT_SAVE_ID = 102
my_buttons = [
(CUSTOM_BUTTON_SAVE_ID, "保存并退出"),
(CUSTOM_BUTTON_DONT_SAVE_ID, "不保存直接退出")
]
dlg_custom = TaskDialog(
title="未保存的更改",
main_instruction="文档中有未保存的更改,您想如何处理?",
custom_buttons=my_buttons,
common_buttons=["cancel"],
main_icon="warning",
footer="这是一个重要的提醒!"
)
result_custom, _, _ = dlg_custom.show()
if result_custom == CUSTOM_BUTTON_SAVE_ID:
print("用户选择了“保存并退出”。")
elif result_custom == CUSTOM_BUTTON_DONT_SAVE_ID:
print("用户选择了“不保存直接退出”。")
elif result_custom == IDCANCEL:
print("用户选择了“取消”。")
print(f"返回的按钮ID: {result_custom}\n")
# 示例 4: 带单选按钮和验证框的对话框
print("--- 示例 4: 单选按钮和验证框 ---")
RADIO_BTN_WORD_ID = 201
RADIO_BTN_EXCEL_ID = 202
RADIO_BTN_PDF_ID = 203
radio_buttons = [
(RADIO_BTN_WORD_ID, "保存为 Word 文档 (.docx)"),
(RADIO_BTN_EXCEL_ID, "保存为 Excel 表格 (.xlsx)"),
(RADIO_BTN_PDF_ID, "导出为 PDF 文档 (.pdf)")
]
dlg_radio = TaskDialog(
title="选择导出格式",
main_instruction="请选择您想要导出的文件格式。",
content="选择一个格式后,点击“确定”继续。",
common_buttons=["ok", "cancel"],
main_icon="information",
radio_buttons=radio_buttons,
default_radio_button=RADIO_BTN_PDF_ID, # 默认选中PDF
verification_text="设为我的默认导出选项",
verification_checked_by_default=True
)
btn_id, radio_id, checked = dlg_radio.show()
if btn_id == IDOK:
print(f"用户点击了“确定”。")
if radio_id == RADIO_BTN_WORD_ID:
print("选择了导出为 Word。")
elif radio_id == RADIO_BTN_EXCEL_ID:
print("选择了导出为 Excel。")
elif radio_id == RADIO_BTN_PDF_ID:
print("选择了导出为 PDF。")
if checked:
print("用户勾选了“设为我的默认导出选项”。")
else:
print("用户未勾选“设为我的默认导出选项”。")
else:
print("用户点击了“取消”。")
print(f"返回的按钮ID: {btn_id}, 单选按钮ID: {radio_id}, 验证框状态: {checked}\n")

View File

@ -11,986 +11,12 @@ from pydantic import BaseModel, ConfigDict
# TODO: from kotonebot import config (context) 会和 kotonebot.config 冲突
from kotonebot import logging
from kotonebot.backend.context import config
from kotonebot.kaa.config.schema import BaseConfig
logger = logging.getLogger(__name__)
T = TypeVar('T')
class ConfigEnum(Enum):
def display(self) -> str:
return self.value[1]
class Priority(IntEnum):
"""
任务优先级数字越大优先级越高越先执行
"""
START_GAME = 1
DEFAULT = 0
CLAIM_MISSION_REWARD = -1
END_GAME = -2
class APShopItems(IntEnum):
PRODUCE_PT_UP = 0
"""获取支援强化 Pt 提升"""
PRODUCE_NOTE_UP = 1
"""获取笔记数提升"""
RECHALLENGE = 2
"""再挑战券"""
REGENERATE_MEMORY = 3
"""回忆再生成券"""
class DailyMoneyShopItems(IntEnum):
"""日常商店物品"""
Recommendations = -1
"""所有推荐商品"""
LessonNote = 0
"""レッスンノート"""
VeteranNote = 1
"""ベテランノート"""
SupportEnhancementPt = 2
"""サポート強化Pt 支援强化Pt"""
SenseNoteVocal = 3
"""センスノート(ボーカル)感性笔记(声乐)"""
SenseNoteDance = 4
"""センスノート(ダンス)感性笔记(舞蹈)"""
SenseNoteVisual = 5
"""センスノート(ビジュアル)感性笔记(形象)"""
LogicNoteVocal = 6
"""ロジックノート(ボーカル)理性笔记(声乐)"""
LogicNoteDance = 7
"""ロジックノート(ダンス)理性笔记(舞蹈)"""
LogicNoteVisual = 8
"""ロジックノート(ビジュアル)理性笔记(形象)"""
AnomalyNoteVocal = 9
"""アノマリーノート(ボーカル)非凡笔记(声乐)"""
AnomalyNoteDance = 10
"""アノマリーノート(ダンス)非凡笔记(舞蹈)"""
AnomalyNoteVisual = 11
"""アノマリーノート(ビジュアル)非凡笔记(形象)"""
RechallengeTicket = 12
"""再挑戦チケット 重新挑战券"""
RecordKey = 13
"""記録の鍵 解锁交流的物品"""
# 碎片
IdolPiece_倉本千奈_WonderScale = 14
"""倉本千奈 WonderScale 碎片"""
IdolPiece_篠泽广_光景 = 15
"""篠泽广 光景 碎片"""
IdolPiece_紫云清夏_TameLieOneStep = 16
"""紫云清夏 Tame-Lie-One-Step 碎片"""
IdolPiece_葛城リーリヤ_白線 = 17
"""葛城リーリヤ 白線 碎片"""
IdolPiece_姬崎莉波_clumsy_trick = 18
"""姫崎薪波 cIclumsy trick 碎片"""
IdolPiece_花海咲季_FightingMyWay = 19
"""花海咲季 FightingMyWay 碎片"""
IdolPiece_藤田ことね_世界一可愛い私 = 20
"""藤田ことね 世界一可愛い私 碎片"""
IdolPiece_花海佑芽_TheRollingRiceball = 21
"""花海佑芽 The Rolling Riceball 碎片"""
IdolPiece_月村手毬_LunaSayMaybe = 22
"""月村手毬 Luna say maybe 碎片"""
IdolPiece_有村麻央_Fluorite = 23
"""有村麻央 Fluorite 碎片"""
@classmethod
def to_ui_text(cls, item: "DailyMoneyShopItems") -> str:
"""获取枚举值对应的UI显示文本"""
match item:
case cls.Recommendations:
return "所有推荐商品"
case cls.LessonNote:
return "课程笔记"
case cls.VeteranNote:
return "老手笔记"
case cls.SupportEnhancementPt:
return "支援强化点数"
case cls.SenseNoteVocal:
return "感性笔记(声乐)"
case cls.SenseNoteDance:
return "感性笔记(舞蹈)"
case cls.SenseNoteVisual:
return "感性笔记(形象)"
case cls.LogicNoteVocal:
return "理性笔记(声乐)"
case cls.LogicNoteDance:
return "理性笔记(舞蹈)"
case cls.LogicNoteVisual:
return "理性笔记(形象)"
case cls.AnomalyNoteVocal:
return "非凡笔记(声乐)"
case cls.AnomalyNoteDance:
return "非凡笔记(舞蹈)"
case cls.AnomalyNoteVisual:
return "非凡笔记(形象)"
case cls.RechallengeTicket:
return "重新挑战券"
case cls.RecordKey:
return "记录钥匙"
case cls.IdolPiece_倉本千奈_WonderScale:
return "倉本千奈 WonderScale 碎片"
case cls.IdolPiece_篠泽广_光景:
return "篠泽广 光景 碎片"
case cls.IdolPiece_紫云清夏_TameLieOneStep:
return "紫云清夏 Tame-Lie-One-Step 碎片"
case cls.IdolPiece_葛城リーリヤ_白線:
return "葛城リーリヤ 白線 碎片"
case cls.IdolPiece_姬崎莉波_clumsy_trick:
return "姫崎薪波 clumsy trick 碎片"
case cls.IdolPiece_花海咲季_FightingMyWay:
return "花海咲季 FightingMyWay 碎片"
case cls.IdolPiece_藤田ことね_世界一可愛い私:
return "藤田ことね 世界一可愛い私 碎片"
case cls.IdolPiece_花海佑芽_TheRollingRiceball:
return "花海佑芽 The Rolling Riceball 碎片"
case cls.IdolPiece_月村手毬_LunaSayMaybe:
return "月村手毬 Luna say maybe 碎片"
case cls.IdolPiece_有村麻央_Fluorite:
return "有村麻央 Fluorite 碎片"
case _:
assert_never(item)
@classmethod
def all(cls) -> list[tuple[str, 'DailyMoneyShopItems']]:
"""获取所有枚举值及其对应的UI显示文本"""
return [(cls.to_ui_text(item), item) for item in cls]
@classmethod
def _is_note(cls, item: 'DailyMoneyShopItems') -> bool:
"""判断是否为笔记"""
return 'Note' in item.name and not item.name.startswith('Note') and not item.name.endswith('Note')
@classmethod
def note_items(cls) -> list[tuple[str, 'DailyMoneyShopItems']]:
"""获取所有枚举值及其对应的UI显示文本"""
return [(cls.to_ui_text(item), item) for item in cls if cls._is_note(item)]
def to_resource(self):
from kotonebot.kaa.tasks import R
match self:
case DailyMoneyShopItems.Recommendations:
return R.Daily.TextShopRecommended
case DailyMoneyShopItems.LessonNote:
return R.Shop.ItemLessonNote
case DailyMoneyShopItems.VeteranNote:
return R.Shop.ItemVeteranNote
case DailyMoneyShopItems.SupportEnhancementPt:
return R.Shop.ItemSupportEnhancementPt
case DailyMoneyShopItems.SenseNoteVocal:
return R.Shop.ItemSenseNoteVocal
case DailyMoneyShopItems.SenseNoteDance:
return R.Shop.ItemSenseNoteDance
case DailyMoneyShopItems.SenseNoteVisual:
return R.Shop.ItemSenseNoteVisual
case DailyMoneyShopItems.LogicNoteVocal:
return R.Shop.ItemLogicNoteVocal
case DailyMoneyShopItems.LogicNoteDance:
return R.Shop.ItemLogicNoteDance
case DailyMoneyShopItems.LogicNoteVisual:
return R.Shop.ItemLogicNoteVisual
case DailyMoneyShopItems.AnomalyNoteVocal:
return R.Shop.ItemAnomalyNoteVocal
case DailyMoneyShopItems.AnomalyNoteDance:
return R.Shop.ItemAnomalyNoteDance
case DailyMoneyShopItems.AnomalyNoteVisual:
return R.Shop.ItemAnomalyNoteVisual
case DailyMoneyShopItems.RechallengeTicket:
return R.Shop.ItemRechallengeTicket
case DailyMoneyShopItems.RecordKey:
return R.Shop.ItemRecordKey
case DailyMoneyShopItems.IdolPiece_倉本千奈_WonderScale:
return R.Shop.IdolPiece.倉本千奈_WonderScale
case DailyMoneyShopItems.IdolPiece_篠泽广_光景:
return R.Shop.IdolPiece.篠泽广_光景
case DailyMoneyShopItems.IdolPiece_紫云清夏_TameLieOneStep:
return R.Shop.IdolPiece.紫云清夏_TameLieOneStep
case DailyMoneyShopItems.IdolPiece_葛城リーリヤ_白線:
return R.Shop.IdolPiece.葛城リーリヤ_白線
case DailyMoneyShopItems.IdolPiece_姬崎莉波_clumsy_trick:
return R.Shop.IdolPiece.姬崎莉波_clumsy_trick
case DailyMoneyShopItems.IdolPiece_花海咲季_FightingMyWay:
return R.Shop.IdolPiece.花海咲季_FightingMyWay
case DailyMoneyShopItems.IdolPiece_藤田ことね_世界一可愛い私:
return R.Shop.IdolPiece.藤田ことね_世界一可愛い私
case DailyMoneyShopItems.IdolPiece_花海佑芽_TheRollingRiceball:
return R.Shop.IdolPiece.花海佑芽_TheRollingRiceball
case DailyMoneyShopItems.IdolPiece_月村手毬_LunaSayMaybe:
return R.Shop.IdolPiece.月村手毬_LunaSayMaybe
case DailyMoneyShopItems.IdolPiece_有村麻央_Fluorite:
return R.Shop.IdolPiece.有村麻央_Fluorite
case _:
assert_never(self)
class ConfigBaseModel(BaseModel):
model_config = ConfigDict(use_attribute_docstrings=True)
class PurchaseConfig(ConfigBaseModel):
enabled: bool = False
"""是否启用商店购买"""
money_enabled: bool = False
"""是否启用金币购买"""
money_items: list[DailyMoneyShopItems] = []
"""金币商店要购买的物品"""
money_refresh: bool = True
"""
是否使用每日一次免费刷新金币商店
"""
ap_enabled: bool = False
"""是否启用AP购买"""
ap_items: Sequence[Literal[0, 1, 2, 3]] = []
"""AP商店要购买的物品"""
class ActivityFundsConfig(ConfigBaseModel):
enabled: bool = False
"""是否启用收取活动费"""
class PresentsConfig(ConfigBaseModel):
enabled: bool = False
"""是否启用收取礼物"""
class AssignmentConfig(ConfigBaseModel):
enabled: bool = False
"""是否启用工作"""
mini_live_reassign_enabled: bool = False
"""是否启用重新分配 MiniLive"""
mini_live_duration: Literal[4, 6, 12] = 12
"""MiniLive 工作时长"""
online_live_reassign_enabled: bool = False
"""是否启用重新分配 OnlineLive"""
online_live_duration: Literal[4, 6, 12] = 12
"""OnlineLive 工作时长"""
class ContestConfig(ConfigBaseModel):
enabled: bool = False
"""是否启用竞赛"""
select_which_contestant: Literal[1, 2, 3] = 1
"""选择第几个挑战者"""
class ProduceAction(Enum):
RECOMMENDED = 'recommended'
VISUAL = 'visual'
VOCAL = 'vocal'
DANCE = 'dance'
# VISUAL_SP = 'visual_sp'
# VOCAL_SP = 'vocal_sp'
# DANCE_SP = 'dance_sp'
OUTING = 'outing'
STUDY = 'study'
ALLOWANCE = 'allowance'
REST = 'rest'
CONSULT = 'consult'
@property
def display_name(self):
MAP = {
ProduceAction.RECOMMENDED: '推荐行动',
ProduceAction.VISUAL: '形象课程',
ProduceAction.VOCAL: '声乐课程',
ProduceAction.DANCE: '舞蹈课程',
ProduceAction.OUTING: '外出(おでかけ)',
ProduceAction.STUDY: '文化课(授業)',
ProduceAction.ALLOWANCE: '活动支给(活動支給)',
ProduceAction.REST: '休息',
ProduceAction.CONSULT: '咨询(相談)',
}
return MAP[self]
class RecommendCardDetectionMode(Enum):
NORMAL = 'normal'
STRICT = 'strict'
@property
def display_name(self):
MAP = {
RecommendCardDetectionMode.NORMAL: '正常模式',
RecommendCardDetectionMode.STRICT: '严格模式',
}
return MAP[self]
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
"""检测并跳过交流"""
class MissionRewardConfig(ConfigBaseModel):
enabled: bool = False
"""是否启用领取任务奖励"""
class ClubRewardConfig(ConfigBaseModel):
enabled: bool = False
"""是否启用领取社团奖励"""
selected_note: DailyMoneyShopItems = DailyMoneyShopItems.AnomalyNoteVisual
"""想在社团奖励中获取到的笔记"""
class UpgradeSupportCardConfig(ConfigBaseModel):
enabled: bool = False
"""是否启用支援卡升级"""
class CapsuleToysConfig(ConfigBaseModel):
enabled: bool = False
"""是否启用扭蛋机"""
friend_capsule_toys_count: int = 0
"""好友扭蛋机次数"""
sense_capsule_toys_count: int = 0
"""感性扭蛋机次数"""
logic_capsule_toys_count: int = 0
"""理性扭蛋机次数"""
anomaly_capsule_toys_count: int = 0
"""非凡扭蛋机次数"""
class TraceConfig(ConfigBaseModel):
recommend_card_detection: bool = False
"""跟踪推荐卡检测"""
class StartGameConfig(ConfigBaseModel):
enabled: bool = True
"""是否启用自动启动游戏。默认为True"""
start_through_kuyo: bool = False
"""是否通过Kuyo来启动游戏"""
game_package_name: str = 'com.bandainamcoent.idolmaster_gakuen'
"""游戏包名"""
kuyo_package_name: str = 'org.kuyo.game'
"""Kuyo包名"""
disable_gakumas_localify: bool = False
"""
自动检测并禁用 Gakumas Localify 汉化插件
目前仅对 DMM 版有效
"""
dmm_game_path: str | None = None
"""
DMM 版游戏路径若不填写会自动检测
`F:\\Games\\gakumas\\gakumas.exe`
"""
class EndGameConfig(ConfigBaseModel):
exit_kaa: bool = False
"""退出 kaa"""
kill_game: bool = False
"""关闭游戏"""
kill_dmm: bool = False
"""关闭 DMMGamePlayer"""
kill_emulator: bool = False
"""关闭模拟器"""
shutdown: bool = False
"""关闭系统"""
hibernate: bool = False
"""休眠系统"""
restore_gakumas_localify: bool = False
"""
恢复 Gakumas Localify 汉化插件状态至启动前通常与
`disable_gakumas_localify` 配对使用
目前仅对 DMM 版有效
"""
class MiscConfig(ConfigBaseModel):
check_update: Literal['never', 'startup'] = 'startup'
"""
检查更新时机
* never: 从不检查更新
* startup: 启动时检查更新
"""
auto_install_update: bool = True
"""
是否自动安装更新
若启用则每次自动检查更新时若有新版本会自动安装否则只是会提示
"""
expose_to_lan: bool = False
"""
是否允许局域网访问 Web 界面
启用后局域网内的其他设备可以通过本机 IP 地址访问 Web 界面
"""
class BaseConfig(ConfigBaseModel):
purchase: PurchaseConfig = PurchaseConfig()
"""商店购买配置"""
activity_funds: ActivityFundsConfig = ActivityFundsConfig()
"""活动费配置"""
presents: PresentsConfig = PresentsConfig()
"""收取礼物配置"""
assignment: AssignmentConfig = AssignmentConfig()
"""工作配置"""
contest: ContestConfig = ContestConfig()
"""竞赛配置"""
produce: ProduceConfig = ProduceConfig()
"""培育配置"""
mission_reward: MissionRewardConfig = MissionRewardConfig()
"""领取任务奖励配置"""
club_reward: ClubRewardConfig = ClubRewardConfig()
"""领取社团奖励配置"""
upgrade_support_card: UpgradeSupportCardConfig = UpgradeSupportCardConfig()
"""支援卡升级配置"""
capsule_toys: CapsuleToysConfig = CapsuleToysConfig()
"""扭蛋机配置"""
trace: TraceConfig = TraceConfig()
"""跟踪配置"""
start_game: StartGameConfig = StartGameConfig()
"""启动游戏配置"""
end_game: EndGameConfig = EndGameConfig()
"""关闭游戏配置"""
misc: MiscConfig = MiscConfig()
"""杂项配置"""
def conf() -> BaseConfig:
"""获取当前配置数据"""
c = config.to(BaseConfig).current
return c.options
def sprite_path(path: str) -> str:
standalone = os.path.join('kotonebot/kaa/sprites', path)
if os.path.exists(standalone):
return standalone
return str(resources.files('kotonebot.kaa.sprites') / path)
def upgrade_config() -> str | None:
"""
升级配置文件
"""
if not os.path.exists('config.json'):
return None
with open('config.json', 'r', encoding='utf-8') as f:
root = json.load(f)
user_configs = root['user_configs']
old_version = root['version']
messages = []
def upgrade_user_config(version: int, user_config: dict[str, Any]) -> int:
nonlocal messages
while True:
match version:
case 1:
logger.info('Upgrading config: v1 -> v2')
user_config, msg = upgrade_v1_to_v2(user_config['options'])
messages.append(msg)
version = 2
case 2:
logger.info('Upgrading config: v2 -> v3')
user_config, msg = upgrade_v2_to_v3(user_config['options'])
messages.append(msg)
version = 3
case 3:
logger.info('Upgrading config: v3 -> v4')
user_config, msg = upgrade_v3_to_v4(user_config['options'])
messages.append(msg)
version = 4
case 4:
logger.info('Upgrading config: v4 -> v5')
user_config, msg = upgrade_v4_to_v5(user_config, user_config['options'])
messages.append(msg)
version = 5
case _:
logger.info('No config upgrade needed.')
return version
for user_config in user_configs:
new_version = upgrade_user_config(old_version, user_config)
root['version'] = new_version
with open('config.json', 'w', encoding='utf-8') as f:
json.dump(root, f, ensure_ascii=False, indent=4)
return '\n'.join(messages)
倉本千奈_BASE = 0
十王星南_BASE = 100
姫崎莉波_BASE = 200
月村手毬_BASE = 300
有村麻央_BASE = 400
篠泽广_BASE = 500
紫云清夏_BASE = 600
花海佑芽_BASE = 700
花海咲季_BASE = 800
葛城リーリヤ_BASE = 900
藤田ことね_BASE = 1000
class PIdol(IntEnum):
"""
P偶像已废弃仅为 upgrade_v1_to_v2()upgrade_v2_to_v3() 而保留
"""
倉本千奈_Campusmode = 倉本千奈_BASE + 0
倉本千奈_WonderScale = 倉本千奈_BASE + 1
倉本千奈_ようこそ初星温泉 = 倉本千奈_BASE + 2
倉本千奈_仮装狂騒曲 = 倉本千奈_BASE + 3
倉本千奈_初心 = 倉本千奈_BASE + 4
倉本千奈_学園生活 = 倉本千奈_BASE + 5
倉本千奈_日々_発見的ステップ = 倉本千奈_BASE + 6
倉本千奈_胸を張って一歩ずつ = 倉本千奈_BASE + 7
十王星南_Campusmode = 十王星南_BASE + 0
十王星南_一番星 = 十王星南_BASE + 1
十王星南_学園生活 = 十王星南_BASE + 2
十王星南_小さな野望 = 十王星南_BASE + 3
姫崎莉波_clumsytrick = 姫崎莉波_BASE + 0
姫崎莉波_私らしさのはじまり = 姫崎莉波_BASE + 1
姫崎莉波_キミとセミブルー = 姫崎莉波_BASE + 2
姫崎莉波_Campusmode = 姫崎莉波_BASE + 3
姫崎莉波_LUV = 姫崎莉波_BASE + 4
姫崎莉波_ようこそ初星温泉 = 姫崎莉波_BASE + 5
姫崎莉波_ハッピーミルフィーユ = 姫崎莉波_BASE + 6
姫崎莉波_初心 = 姫崎莉波_BASE + 7
姫崎莉波_学園生活 = 姫崎莉波_BASE + 8
月村手毬_Lunasaymaybe = 月村手毬_BASE + 0
月村手毬_一匹狼 = 月村手毬_BASE + 1
月村手毬_Campusmode = 月村手毬_BASE + 2
月村手毬_アイヴイ = 月村手毬_BASE + 3
月村手毬_初声 = 月村手毬_BASE + 4
月村手毬_学園生活 = 月村手毬_BASE + 5
月村手毬_仮装狂騒曲 = 月村手毬_BASE + 6
有村麻央_Fluorite = 有村麻央_BASE + 0
有村麻央_はじまりはカッコよく = 有村麻央_BASE + 1
有村麻央_Campusmode = 有村麻央_BASE + 2
有村麻央_FeelJewelDream = 有村麻央_BASE + 3
有村麻央_キミとセミブルー = 有村麻央_BASE + 4
有村麻央_初恋 = 有村麻央_BASE + 5
有村麻央_学園生活 = 有村麻央_BASE + 6
篠泽广_コントラスト = 篠泽广_BASE + 0
篠泽广_一番向いていないこと = 篠泽广_BASE + 1
篠泽广_光景 = 篠泽广_BASE + 2
篠泽广_Campusmode = 篠泽广_BASE + 3
篠泽广_仮装狂騒曲 = 篠泽广_BASE + 4
篠泽广_ハッピーミルフィーユ = 篠泽广_BASE + 5
篠泽广_初恋 = 篠泽广_BASE + 6
篠泽广_学園生活 = 篠泽广_BASE + 7
紫云清夏_TameLieOneStep = 紫云清夏_BASE + 0
紫云清夏_カクシタワタシ = 紫云清夏_BASE + 1
紫云清夏_夢へのリスタート = 紫云清夏_BASE + 2
紫云清夏_Campusmode = 紫云清夏_BASE + 3
紫云清夏_キミとセミブルー = 紫云清夏_BASE + 4
紫云清夏_初恋 = 紫云清夏_BASE + 5
紫云清夏_学園生活 = 紫云清夏_BASE + 6
花海佑芽_WhiteNightWhiteWish = 花海佑芽_BASE + 0
花海佑芽_学園生活 = 花海佑芽_BASE + 1
花海佑芽_Campusmode = 花海佑芽_BASE + 2
花海佑芽_TheRollingRiceball = 花海佑芽_BASE + 3
花海佑芽_アイドル_はじめっ = 花海佑芽_BASE + 4
花海咲季_BoomBoomPow = 花海咲季_BASE + 0
花海咲季_Campusmode = 花海咲季_BASE + 1
花海咲季_FightingMyWay = 花海咲季_BASE + 2
花海咲季_わたしが一番 = 花海咲季_BASE + 3
花海咲季_冠菊 = 花海咲季_BASE + 4
花海咲季_初声 = 花海咲季_BASE + 5
花海咲季_古今東西ちょちょいのちょい = 花海咲季_BASE + 6
花海咲季_学園生活 = 花海咲季_BASE + 7
葛城リーリヤ_一つ踏み出した先に = 葛城リーリヤ_BASE + 0
葛城リーリヤ_白線 = 葛城リーリヤ_BASE + 1
葛城リーリヤ_Campusmode = 葛城リーリヤ_BASE + 2
葛城リーリヤ_WhiteNightWhiteWish = 葛城リーリヤ_BASE + 3
葛城リーリヤ_冠菊 = 葛城リーリヤ_BASE + 4
葛城リーリヤ_初心 = 葛城リーリヤ_BASE + 5
葛城リーリヤ_学園生活 = 葛城リーリヤ_BASE + 6
藤田ことね_カワイイ_はじめました = 藤田ことね_BASE + 0
藤田ことね_世界一可愛い私 = 藤田ことね_BASE + 1
藤田ことね_Campusmode = 藤田ことね_BASE + 2
藤田ことね_YellowBigBang = 藤田ことね_BASE + 3
藤田ことね_WhiteNightWhiteWish = 藤田ことね_BASE + 4
藤田ことね_冠菊 = 藤田ことね_BASE + 5
藤田ことね_初声 = 藤田ことね_BASE + 6
藤田ことね_学園生活 = 藤田ことね_BASE + 7
def upgrade_v1_to_v2(options: dict[str, Any]) -> tuple[dict[str, Any], str]:
"""
v1 -> v2 变更
1. PIdol 的枚举值改为整数
"""
msg = ''
# 转换 PIdol 的枚举值
def map_idol(idol: list[str]) -> PIdol | None:
logger.debug("Converting %s", idol)
match idol:
case ["倉本千奈", "Campus mode!!"]:
return PIdol.倉本千奈_Campusmode
case ["倉本千奈", "Wonder Scale"]:
return PIdol.倉本千奈_WonderScale
case ["倉本千奈", "ようこそ初星温泉"]:
return PIdol.倉本千奈_ようこそ初星温泉
case ["倉本千奈", "仮装狂騒曲"]:
return PIdol.倉本千奈_仮装狂騒曲
case ["倉本千奈", "初心"]:
return PIdol.倉本千奈_初心
case ["倉本千奈", "学園生活"]:
return PIdol.倉本千奈_学園生活
case ["倉本千奈", "日々、発見的ステップ!"]:
return PIdol.倉本千奈_日々_発見的ステップ
case ["倉本千奈", "胸を張って一歩ずつ"]:
return PIdol.倉本千奈_胸を張って一歩ずつ
case ["十王星南", "Campus mode!!"]:
return PIdol.十王星南_Campusmode
case ["十王星南", "一番星"]:
return PIdol.十王星南_一番星
case ["十王星南", "学園生活"]:
return PIdol.十王星南_学園生活
case ["十王星南", "小さな野望"]:
return PIdol.十王星南_小さな野望
case ["姫崎莉波", "clumsy trick"]:
return PIdol.姫崎莉波_clumsytrick
case ["姫崎莉波", "『私らしさ』のはじまり"]:
return PIdol.姫崎莉波_私らしさのはじまり
case ["姫崎莉波", "キミとセミブルー"]:
return PIdol.姫崎莉波_キミとセミブルー
case ["姫崎莉波", "Campus mode!!"]:
return PIdol.姫崎莉波_Campusmode
case ["姫崎莉波", "L.U.V"]:
return PIdol.姫崎莉波_LUV
case ["姫崎莉波", "ようこそ初星温泉"]:
return PIdol.姫崎莉波_ようこそ初星温泉
case ["姫崎莉波", "ハッピーミルフィーユ"]:
return PIdol.姫崎莉波_ハッピーミルフィーユ
case ["姫崎莉波", "初心"]:
return PIdol.姫崎莉波_初心
case ["姫崎莉波", "学園生活"]:
return PIdol.姫崎莉波_学園生活
case ["月村手毬", "Luna say maybe"]:
return PIdol.月村手毬_Lunasaymaybe
case ["月村手毬", "一匹狼"]:
return PIdol.月村手毬_一匹狼
case ["月村手毬", "Campus mode!!"]:
return PIdol.月村手毬_Campusmode
case ["月村手毬", "アイヴイ"]:
return PIdol.月村手毬_アイヴイ
case ["月村手毬", "初声"]:
return PIdol.月村手毬_初声
case ["月村手毬", "学園生活"]:
return PIdol.月村手毬_学園生活
case ["月村手毬", "仮装狂騒曲"]:
return PIdol.月村手毬_仮装狂騒曲
case ["有村麻央", "Fluorite"]:
return PIdol.有村麻央_Fluorite
case ["有村麻央", "はじまりはカッコよく"]:
return PIdol.有村麻央_はじまりはカッコよく
case ["有村麻央", "Campus mode!!"]:
return PIdol.有村麻央_Campusmode
case ["有村麻央", "Feel Jewel Dream"]:
return PIdol.有村麻央_FeelJewelDream
case ["有村麻央", "キミとセミブルー"]:
return PIdol.有村麻央_キミとセミブルー
case ["有村麻央", "初恋"]:
return PIdol.有村麻央_初恋
case ["有村麻央", "学園生活"]:
return PIdol.有村麻央_学園生活
case ["篠泽广", "コントラスト"]:
return PIdol.篠泽广_コントラスト
case ["篠泽广", "一番向いていないこと"]:
return PIdol.篠泽广_一番向いていないこと
case ["篠泽广", "光景"]:
return PIdol.篠泽广_光景
case ["篠泽广", "Campus mode!!"]:
return PIdol.篠泽广_Campusmode
case ["篠泽广", "仮装狂騒曲"]:
return PIdol.篠泽广_仮装狂騒曲
case ["篠泽广", "ハッピーミルフィーユ"]:
return PIdol.篠泽广_ハッピーミルフィーユ
case ["篠泽广", "初恋"]:
return PIdol.篠泽广_初恋
case ["篠泽广", "学園生活"]:
return PIdol.篠泽广_学園生活
case ["紫云清夏", "Tame-Lie-One-Step"]:
return PIdol.紫云清夏_TameLieOneStep
case ["紫云清夏", "カクシタワタシ"]:
return PIdol.紫云清夏_カクシタワタシ
case ["紫云清夏", "夢へのリスタート"]:
return PIdol.紫云清夏_夢へのリスタート
case ["紫云清夏", "Campus mode!!"]:
return PIdol.紫云清夏_Campusmode
case ["紫云清夏", "キミとセミブルー"]:
return PIdol.紫云清夏_キミとセミブルー
case ["紫云清夏", "初恋"]:
return PIdol.紫云清夏_初恋
case ["紫云清夏", "学園生活"]:
return PIdol.紫云清夏_学園生活
case ["花海佑芽", "White Night! White Wish!"]:
return PIdol.花海佑芽_WhiteNightWhiteWish
case ["花海佑芽", "学園生活"]:
return PIdol.花海佑芽_学園生活
case ["花海佑芽", "Campus mode!!"]:
return PIdol.花海佑芽_Campusmode
case ["花海佑芽", "The Rolling Riceball"]:
return PIdol.花海佑芽_TheRollingRiceball
case ["花海佑芽", "アイドル、はじめっ!"]:
return PIdol.花海佑芽_アイドル_はじめっ
case ["花海咲季", "Boom Boom Pow"]:
return PIdol.花海咲季_BoomBoomPow
case ["花海咲季", "Campus mode!!"]:
return PIdol.花海咲季_Campusmode
case ["花海咲季", "Fighting My Way"]:
return PIdol.花海咲季_FightingMyWay
case ["花海咲季", "わたしが一番!"]:
return PIdol.花海咲季_わたしが一番
case ["花海咲季", "冠菊"]:
return PIdol.花海咲季_冠菊
case ["花海咲季", "初声"]:
return PIdol.花海咲季_初声
case ["花海咲季", "古今東西ちょちょいのちょい"]:
return PIdol.花海咲季_古今東西ちょちょいのちょい
case ["花海咲季", "学園生活"]:
return PIdol.花海咲季_学園生活
case ["葛城リーリヤ", "一つ踏み出した先に"]:
return PIdol.葛城リーリヤ_一つ踏み出した先に
case ["葛城リーリヤ", "白線"]:
return PIdol.葛城リーリヤ_白線
case ["葛城リーリヤ", "Campus mode!!"]:
return PIdol.葛城リーリヤ_Campusmode
case ["葛城リーリヤ", "White Night! White Wish!"]:
return PIdol.葛城リーリヤ_WhiteNightWhiteWish
case ["葛城リーリヤ", "冠菊"]:
return PIdol.葛城リーリヤ_冠菊
case ["葛城リーリヤ", "初心"]:
return PIdol.葛城リーリヤ_初心
case ["葛城リーリヤ", "学園生活"]:
return PIdol.葛城リーリヤ_学園生活
case ["藤田ことね", "カワイイ", "はじめました"]:
return PIdol.藤田ことね_カワイイ_はじめました
case ["藤田ことね", "世界一可愛い私"]:
return PIdol.藤田ことね_世界一可愛い私
case ["藤田ことね", "Campus mode!!"]:
return PIdol.藤田ことね_Campusmode
case ["藤田ことね", "Yellow Big Bang"]:
return PIdol.藤田ことね_YellowBigBang
case ["藤田ことね", "White Night! White Wish!"]:
return PIdol.藤田ことね_WhiteNightWhiteWish
case ["藤田ことね", "冠菊"]:
return PIdol.藤田ことね_冠菊
case ["藤田ことね", "初声"]:
return PIdol.藤田ことね_初声
case ["藤田ことね", "学園生活"]:
return PIdol.藤田ことね_学園生活
case _:
nonlocal msg
if msg == '':
msg = '培育设置中的以下偶像升级失败。请尝试手动添加。\n'
msg += f'{idol} 未找到\n'
return None
old_idols = options['produce']['idols']
new_idols = list(filter(lambda x: x is not None, map(map_idol, old_idols)))
options['produce']['idols'] = new_idols
shutil.copy('config.json', 'config.v1.json')
return options, msg
def upgrade_v2_to_v3(options: dict[str, Any]) -> tuple[dict[str, Any], str]:
"""
v2 -> v3 变更\n
引入了游戏解包数据因此 PIdol 枚举废弃直接改用游戏内 ID
"""
msg = ''
def map_idol(idol: PIdol) -> str | None:
match idol:
case PIdol.倉本千奈_Campusmode: return "i_card-skin-kcna-3-007"
case PIdol.倉本千奈_WonderScale: return "i_card-skin-kcna-3-000"
case PIdol.倉本千奈_ようこそ初星温泉: return "i_card-skin-kcna-3-005"
case PIdol.倉本千奈_仮装狂騒曲: return "i_card-skin-kcna-3-002"
case PIdol.倉本千奈_初心: return "i_card-skin-kcna-1-001"
case PIdol.倉本千奈_学園生活: return "i_card-skin-kcna-1-000"
case PIdol.倉本千奈_日々_発見的ステップ: return "i_card-skin-kcna-3-001"
case PIdol.倉本千奈_胸を張って一歩ずつ: return "i_card-skin-kcna-2-000"
case PIdol.十王星南_Campusmode: return "i_card-skin-jsna-3-002"
case PIdol.十王星南_一番星: return "i_card-skin-jsna-2-000"
case PIdol.十王星南_学園生活: return "i_card-skin-jsna-1-000"
case PIdol.十王星南_小さな野望: return "i_card-skin-jsna-3-000"
case PIdol.姫崎莉波_clumsytrick: return "i_card-skin-hrnm-3-000"
case PIdol.姫崎莉波_私らしさのはじまり: return "i_card-skin-hrnm-2-000"
case PIdol.姫崎莉波_キミとセミブルー: return "i_card-skin-hrnm-3-001"
case PIdol.姫崎莉波_Campusmode: return "i_card-skin-hrnm-3-007"
case PIdol.姫崎莉波_LUV: return "i_card-skin-hrnm-3-002"
case PIdol.姫崎莉波_ようこそ初星温泉: return "i_card-skin-hrnm-3-004"
case PIdol.姫崎莉波_ハッピーミルフィーユ: return "i_card-skin-hrnm-3-008"
case PIdol.姫崎莉波_初心: return "i_card-skin-hrnm-1-001"
case PIdol.姫崎莉波_学園生活: return "i_card-skin-hrnm-1-000"
case PIdol.月村手毬_Lunasaymaybe: return "i_card-skin-ttmr-3-000"
case PIdol.月村手毬_一匹狼: return "i_card-skin-ttmr-2-000"
case PIdol.月村手毬_Campusmode: return "i_card-skin-ttmr-3-007"
case PIdol.月村手毬_アイヴイ: return "i_card-skin-ttmr-3-001"
case PIdol.月村手毬_初声: return "i_card-skin-ttmr-1-001"
case PIdol.月村手毬_学園生活: return "i_card-skin-ttmr-1-000"
case PIdol.月村手毬_仮装狂騒曲: return "i_card-skin-ttmr-3-002"
case PIdol.有村麻央_Fluorite: return "i_card-skin-amao-3-000"
case PIdol.有村麻央_はじまりはカッコよく: return "i_card-skin-amao-2-000"
case PIdol.有村麻央_Campusmode: return "i_card-skin-amao-3-007"
case PIdol.有村麻央_FeelJewelDream: return "i_card-skin-amao-3-002"
case PIdol.有村麻央_キミとセミブルー: return "i_card-skin-amao-3-001"
case PIdol.有村麻央_初恋: return "i_card-skin-amao-1-001"
case PIdol.有村麻央_学園生活: return "i_card-skin-amao-1-000"
case PIdol.篠泽广_コントラスト: return "i_card-skin-shro-3-001"
case PIdol.篠泽广_一番向いていないこと: return "i_card-skin-shro-2-000"
case PIdol.篠泽广_光景: return "i_card-skin-shro-3-000"
case PIdol.篠泽广_Campusmode: return "i_card-skin-shro-3-007"
case PIdol.篠泽广_仮装狂騒曲: return "i_card-skin-shro-3-002"
case PIdol.篠泽广_ハッピーミルフィーユ: return "i_card-skin-shro-3-008"
case PIdol.篠泽广_初恋: return "i_card-skin-shro-1-001"
case PIdol.篠泽广_学園生活: return "i_card-skin-shro-1-000"
case PIdol.紫云清夏_TameLieOneStep: return "i_card-skin-ssmk-3-000"
case PIdol.紫云清夏_カクシタワタシ: return "i_card-skin-ssmk-3-002"
case PIdol.紫云清夏_夢へのリスタート: return "i_card-skin-ssmk-2-000"
case PIdol.紫云清夏_Campusmode: return "i_card-skin-ssmk-3-007"
case PIdol.紫云清夏_キミとセミブルー: return "i_card-skin-ssmk-3-001"
case PIdol.紫云清夏_初恋: return "i_card-skin-ssmk-1-001"
case PIdol.紫云清夏_学園生活: return "i_card-skin-ssmk-1-000"
case PIdol.花海佑芽_WhiteNightWhiteWish: return "i_card-skin-hume-3-005"
case PIdol.花海佑芽_学園生活: return "i_card-skin-hume-1-000"
case PIdol.花海佑芽_Campusmode: return "i_card-skin-hume-3-006"
case PIdol.花海佑芽_TheRollingRiceball: return "i_card-skin-hume-3-000"
case PIdol.花海佑芽_アイドル_はじめっ: return "i_card-skin-hume-2-000"
case PIdol.花海咲季_BoomBoomPow: return "i_card-skin-hski-3-001"
case PIdol.花海咲季_Campusmode: return "i_card-skin-hski-3-008"
case PIdol.花海咲季_FightingMyWay: return "i_card-skin-hski-3-000"
case PIdol.花海咲季_わたしが一番: return "i_card-skin-hski-2-000"
case PIdol.花海咲季_冠菊: return "i_card-skin-hski-3-002"
case PIdol.花海咲季_初声: return "i_card-skin-hski-1-001"
case PIdol.花海咲季_古今東西ちょちょいのちょい: return "i_card-skin-hski-3-006"
case PIdol.花海咲季_学園生活: return "i_card-skin-hski-1-000"
case PIdol.葛城リーリヤ_一つ踏み出した先に: return "i_card-skin-kllj-2-000"
case PIdol.葛城リーリヤ_白線: return "i_card-skin-kllj-3-000"
case PIdol.葛城リーリヤ_Campusmode: return "i_card-skin-kllj-3-006"
case PIdol.葛城リーリヤ_WhiteNightWhiteWish: return "i_card-skin-kllj-3-005"
case PIdol.葛城リーリヤ_冠菊: return "i_card-skin-kllj-3-001"
case PIdol.葛城リーリヤ_初心: return "i_card-skin-kllj-1-001"
case PIdol.葛城リーリヤ_学園生活: return "i_card-skin-kllj-1-000"
case PIdol.藤田ことね_カワイイ_はじめました: return "i_card-skin-fktn-2-000"
case PIdol.藤田ことね_世界一可愛い私: return "i_card-skin-fktn-3-000"
case PIdol.藤田ことね_Campusmode: return "i_card-skin-fktn-3-007"
case PIdol.藤田ことね_YellowBigBang: return "i_card-skin-fktn-3-001"
case PIdol.藤田ことね_WhiteNightWhiteWish: return "i_card-skin-fktn-3-006"
case PIdol.藤田ことね_冠菊: return "i_card-skin-fktn-3-002"
case PIdol.藤田ことね_初声: return "i_card-skin-fktn-1-001"
case PIdol.藤田ことね_学園生活: return "i_card-skin-fktn-1-000"
case _:
nonlocal msg
if msg == '':
msg = '培育设置中的以下偶像升级失败。请尝试手动添加。\n'
msg += f'{idol} 未找到\n'
return None
old_idols = options['produce']['idols']
new_idols = list(filter(lambda x: x is not None, map(map_idol, old_idols)))
options['produce']['idols'] = new_idols
shutil.copy('config.json', 'config.v2.json')
return options, msg
def upgrade_v3_to_v4(options: dict[str, Any]) -> tuple[dict[str, Any], str]:
"""
v3 -> v4 变更
自动纠正错误游戏包名
"""
shutil.copy('config.json', 'config.v3.json')
if options['start_game']['game_package_name'] == 'com.bandinamcoent.idolmaster_gakuen':
options['start_game']['game_package_name'] = 'com.bandainamcoent.idolmaster_gakuen'
logger.info('Corrected game package name to com.bandainamcoent.idolmaster_gakuen')
return options, ''
def upgrade_v4_to_v5(user_config: dict[str, Any], options: dict[str, Any]) -> tuple[dict[str, Any], str]:
"""
v4 -> v5 变更
windows windows_remote 截图方式的 type 设置为 dmm
"""
shutil.copy('config.json', 'config.v4.json')
if user_config['backend']['screenshot_impl'] in ['windows', 'remote_windows']:
logger.info('Set backend type to dmm.')
user_config['backend']['type'] = 'dmm'
return options, ''
if __name__ == '__main__':
print(PurchaseConfig.model_fields['money_refresh_on'].description)
return str(resources.files('kotonebot.kaa.sprites') / path)

View File

@ -0,0 +1,62 @@
from .schema import (
BaseConfig,
PurchaseConfig,
ActivityFundsConfig,
PresentsConfig,
AssignmentConfig,
ContestConfig,
ProduceConfig,
MissionRewardConfig,
ClubRewardConfig,
UpgradeSupportCardConfig,
CapsuleToysConfig,
TraceConfig,
StartGameConfig,
EndGameConfig,
MiscConfig,
conf,
)
from .const import (
ConfigEnum,
Priority,
APShopItems,
DailyMoneyShopItems,
ProduceAction,
RecommendCardDetectionMode,
)
# 配置升级逻辑
from .upgrade import upgrade_config
from .migrations import MIGRATION_REGISTRY, LATEST_VERSION
__all__ = [
# schema 导出
"BaseConfig",
"PurchaseConfig",
"ActivityFundsConfig",
"PresentsConfig",
"AssignmentConfig",
"ContestConfig",
"ProduceConfig",
"MissionRewardConfig",
"ClubRewardConfig",
"UpgradeSupportCardConfig",
"CapsuleToysConfig",
"TraceConfig",
"StartGameConfig",
"EndGameConfig",
"MiscConfig",
"conf",
# const 导出
"ConfigEnum",
"Priority",
"APShopItems",
"DailyMoneyShopItems",
"ProduceAction",
"RecommendCardDetectionMode",
# upgrade 导出
"upgrade_config",
"migrations",
"MIGRATION_REGISTRY",
"LATEST_VERSION",
]

View File

@ -0,0 +1,255 @@
from enum import IntEnum, Enum
from typing_extensions import assert_never
class ConfigEnum(Enum):
def display(self) -> str:
return self.value[1]
class Priority(IntEnum):
"""
任务优先级数字越大优先级越高越先执行
"""
START_GAME = 1
DEFAULT = 0
CLAIM_MISSION_REWARD = -1
END_GAME = -2
class APShopItems(IntEnum):
PRODUCE_PT_UP = 0
"""获取支援强化 Pt 提升"""
PRODUCE_NOTE_UP = 1
"""获取笔记数提升"""
RECHALLENGE = 2
"""再挑战券"""
REGENERATE_MEMORY = 3
"""回忆再生成券"""
class DailyMoneyShopItems(IntEnum):
"""日常商店物品"""
Recommendations = -1
"""所有推荐商品"""
LessonNote = 0
"""レッスンノート"""
VeteranNote = 1
"""ベテランノート"""
SupportEnhancementPt = 2
"""サポート強化Pt 支援强化Pt"""
SenseNoteVocal = 3
"""センスノート(ボーカル)感性笔记(声乐)"""
SenseNoteDance = 4
"""センスノート(ダンス)感性笔记(舞蹈)"""
SenseNoteVisual = 5
"""センスノート(ビジュアル)感性笔记(形象)"""
LogicNoteVocal = 6
"""ロジックノート(ボーカル)理性笔记(声乐)"""
LogicNoteDance = 7
"""ロジックノート(ダンス)理性笔记(舞蹈)"""
LogicNoteVisual = 8
"""ロジックノート(ビジュアル)理性笔记(形象)"""
AnomalyNoteVocal = 9
"""アノマリーノート(ボーカル)非凡笔记(声乐)"""
AnomalyNoteDance = 10
"""アノマリーノート(ダンス)非凡笔记(舞蹈)"""
AnomalyNoteVisual = 11
"""アノマリーノート(ビジュアル)非凡笔记(形象)"""
RechallengeTicket = 12
"""再挑戦チケット 重新挑战券"""
RecordKey = 13
"""記録の鍵 解锁交流的物品"""
# 碎片
IdolPiece_倉本千奈_WonderScale = 14
"""倉本千奈 WonderScale 碎片"""
IdolPiece_篠泽广_光景 = 15
"""篠泽广 光景 碎片"""
IdolPiece_紫云清夏_TameLieOneStep = 16
"""紫云清夏 Tame-Lie-One-Step 碎片"""
IdolPiece_葛城リーリヤ_白線 = 17
"""葛城リーリヤ 白線 碎片"""
IdolPiece_姬崎莉波_clumsy_trick = 18
"""姫崎薪波 cIclumsy trick 碎片"""
IdolPiece_花海咲季_FightingMyWay = 19
"""花海咲季 FightingMyWay 碎片"""
IdolPiece_藤田ことね_世界一可愛い私 = 20
"""藤田ことね 世界一可愛い私 碎片"""
IdolPiece_花海佑芽_TheRollingRiceball = 21
"""花海佑芽 The Rolling Riceball 碎片"""
IdolPiece_月村手毬_LunaSayMaybe = 22
"""月村手毬 Luna say maybe 碎片"""
IdolPiece_有村麻央_Fluorite = 23
"""有村麻央 Fluorite 碎片"""
@classmethod
def to_ui_text(cls, item: "DailyMoneyShopItems") -> str:
"""获取枚举值对应的UI显示文本"""
match item:
case cls.Recommendations:
return "所有推荐商品"
case cls.LessonNote:
return "课程笔记"
case cls.VeteranNote:
return "老手笔记"
case cls.SupportEnhancementPt:
return "支援强化点数"
case cls.SenseNoteVocal:
return "感性笔记(声乐)"
case cls.SenseNoteDance:
return "感性笔记(舞蹈)"
case cls.SenseNoteVisual:
return "感性笔记(形象)"
case cls.LogicNoteVocal:
return "理性笔记(声乐)"
case cls.LogicNoteDance:
return "理性笔记(舞蹈)"
case cls.LogicNoteVisual:
return "理性笔记(形象)"
case cls.AnomalyNoteVocal:
return "非凡笔记(声乐)"
case cls.AnomalyNoteDance:
return "非凡笔记(舞蹈)"
case cls.AnomalyNoteVisual:
return "非凡笔记(形象)"
case cls.RechallengeTicket:
return "重新挑战券"
case cls.RecordKey:
return "记录钥匙"
case cls.IdolPiece_倉本千奈_WonderScale:
return "倉本千奈 WonderScale 碎片"
case cls.IdolPiece_篠泽广_光景:
return "篠泽广 光景 碎片"
case cls.IdolPiece_紫云清夏_TameLieOneStep:
return "紫云清夏 Tame-Lie-One-Step 碎片"
case cls.IdolPiece_葛城リーリヤ_白線:
return "葛城リーリヤ 白線 碎片"
case cls.IdolPiece_姬崎莉波_clumsy_trick:
return "姫崎薪波 clumsy trick 碎片"
case cls.IdolPiece_花海咲季_FightingMyWay:
return "花海咲季 FightingMyWay 碎片"
case cls.IdolPiece_藤田ことね_世界一可愛い私:
return "藤田ことね 世界一可愛い私 碎片"
case cls.IdolPiece_花海佑芽_TheRollingRiceball:
return "花海佑芽 The Rolling Riceball 碎片"
case cls.IdolPiece_月村手毬_LunaSayMaybe:
return "月村手毬 Luna say maybe 碎片"
case cls.IdolPiece_有村麻央_Fluorite:
return "有村麻央 Fluorite 碎片"
case _:
assert_never(item)
@classmethod
def all(cls) -> list[tuple[str, 'DailyMoneyShopItems']]:
"""获取所有枚举值及其对应的UI显示文本"""
return [(cls.to_ui_text(item), item) for item in cls]
@classmethod
def _is_note(cls, item: 'DailyMoneyShopItems') -> bool:
"""判断是否为笔记"""
return 'Note' in item.name and not item.name.startswith('Note') and not item.name.endswith('Note')
@classmethod
def note_items(cls) -> list[tuple[str, 'DailyMoneyShopItems']]:
"""获取所有枚举值及其对应的UI显示文本"""
return [(cls.to_ui_text(item), item) for item in cls if cls._is_note(item)]
def to_resource(self):
from kotonebot.kaa.tasks import R
match self:
case DailyMoneyShopItems.Recommendations:
return R.Daily.TextShopRecommended
case DailyMoneyShopItems.LessonNote:
return R.Shop.ItemLessonNote
case DailyMoneyShopItems.VeteranNote:
return R.Shop.ItemVeteranNote
case DailyMoneyShopItems.SupportEnhancementPt:
return R.Shop.ItemSupportEnhancementPt
case DailyMoneyShopItems.SenseNoteVocal:
return R.Shop.ItemSenseNoteVocal
case DailyMoneyShopItems.SenseNoteDance:
return R.Shop.ItemSenseNoteDance
case DailyMoneyShopItems.SenseNoteVisual:
return R.Shop.ItemSenseNoteVisual
case DailyMoneyShopItems.LogicNoteVocal:
return R.Shop.ItemLogicNoteVocal
case DailyMoneyShopItems.LogicNoteDance:
return R.Shop.ItemLogicNoteDance
case DailyMoneyShopItems.LogicNoteVisual:
return R.Shop.ItemLogicNoteVisual
case DailyMoneyShopItems.AnomalyNoteVocal:
return R.Shop.ItemAnomalyNoteVocal
case DailyMoneyShopItems.AnomalyNoteDance:
return R.Shop.ItemAnomalyNoteDance
case DailyMoneyShopItems.AnomalyNoteVisual:
return R.Shop.ItemAnomalyNoteVisual
case DailyMoneyShopItems.RechallengeTicket:
return R.Shop.ItemRechallengeTicket
case DailyMoneyShopItems.RecordKey:
return R.Shop.ItemRecordKey
case DailyMoneyShopItems.IdolPiece_倉本千奈_WonderScale:
return R.Shop.IdolPiece.倉本千奈_WonderScale
case DailyMoneyShopItems.IdolPiece_篠泽广_光景:
return R.Shop.IdolPiece.篠泽广_光景
case DailyMoneyShopItems.IdolPiece_紫云清夏_TameLieOneStep:
return R.Shop.IdolPiece.紫云清夏_TameLieOneStep
case DailyMoneyShopItems.IdolPiece_葛城リーリヤ_白線:
return R.Shop.IdolPiece.葛城リーリヤ_白線
case DailyMoneyShopItems.IdolPiece_姬崎莉波_clumsy_trick:
return R.Shop.IdolPiece.姬崎莉波_clumsy_trick
case DailyMoneyShopItems.IdolPiece_花海咲季_FightingMyWay:
return R.Shop.IdolPiece.花海咲季_FightingMyWay
case DailyMoneyShopItems.IdolPiece_藤田ことね_世界一可愛い私:
return R.Shop.IdolPiece.藤田ことね_世界一可愛い私
case DailyMoneyShopItems.IdolPiece_花海佑芽_TheRollingRiceball:
return R.Shop.IdolPiece.花海佑芽_TheRollingRiceball
case DailyMoneyShopItems.IdolPiece_月村手毬_LunaSayMaybe:
return R.Shop.IdolPiece.月村手毬_LunaSayMaybe
case DailyMoneyShopItems.IdolPiece_有村麻央_Fluorite:
return R.Shop.IdolPiece.有村麻央_Fluorite
case _:
assert_never(self)
class ProduceAction(Enum):
RECOMMENDED = 'recommended'
VISUAL = 'visual'
VOCAL = 'vocal'
DANCE = 'dance'
# VISUAL_SP = 'visual_sp'
# VOCAL_SP = 'vocal_sp'
# DANCE_SP = 'dance_sp'
OUTING = 'outing'
STUDY = 'study'
ALLOWANCE = 'allowance'
REST = 'rest'
CONSULT = 'consult'
@property
def display_name(self):
MAP = {
ProduceAction.RECOMMENDED: '推荐行动',
ProduceAction.VISUAL: '形象课程',
ProduceAction.VOCAL: '声乐课程',
ProduceAction.DANCE: '舞蹈课程',
ProduceAction.OUTING: '外出(おでかけ)',
ProduceAction.STUDY: '文化课(授業)',
ProduceAction.ALLOWANCE: '活动支给(活動支給)',
ProduceAction.REST: '休息',
ProduceAction.CONSULT: '咨询(相談)',
}
return MAP[self]
class RecommendCardDetectionMode(Enum):
NORMAL = 'normal'
STRICT = 'strict'
@property
def display_name(self):
MAP = {
RecommendCardDetectionMode.NORMAL: '正常模式',
RecommendCardDetectionMode.STRICT: '严格模式',
}
return MAP[self]

View File

@ -0,0 +1,28 @@
from typing import Callable, Any, Dict
# 迁移函数类型:接收单个 user_config(dict),就地修改并返回提示信息
Migration = Callable[[dict[str, Any]], str | None]
# 导入各版本迁移实现
from . import _v1_to_v2
from . import _v2_to_v3
from . import _v3_to_v4
from . import _v4_to_v5
from . import _v5_to_v6
# 注册表:键为旧版本号,值为迁移函数
MIGRATION_REGISTRY: Dict[int, Migration] = {
1: _v1_to_v2.migrate,
2: _v2_to_v3.migrate,
3: _v3_to_v4.migrate,
4: _v4_to_v5.migrate,
5: _v5_to_v6.migrate,
}
# 当前最新配置版本
LATEST_VERSION: int = 6
__all__ = [
"MIGRATION_REGISTRY",
"LATEST_VERSION",
]

View File

@ -0,0 +1,106 @@
from enum import IntEnum
倉本千奈_BASE = 0
十王星南_BASE = 100
姫崎莉波_BASE = 200
月村手毬_BASE = 300
有村麻央_BASE = 400
篠泽广_BASE = 500
紫云清夏_BASE = 600
花海佑芽_BASE = 700
花海咲季_BASE = 800
葛城リーリヤ_BASE = 900
藤田ことね_BASE = 1000
class PIdol(IntEnum):
"""P 偶像。(仅用于旧版配置升级。)"""
倉本千奈_Campusmode = 倉本千奈_BASE + 0
倉本千奈_WonderScale = 倉本千奈_BASE + 1
倉本千奈_ようこそ初星温泉 = 倉本千奈_BASE + 2
倉本千奈_仮装狂騒曲 = 倉本千奈_BASE + 3
倉本千奈_初心 = 倉本千奈_BASE + 4
倉本千奈_学園生活 = 倉本千奈_BASE + 5
倉本千奈_日々_発見的ステップ = 倉本千奈_BASE + 6
倉本千奈_胸を張って一歩ずつ = 倉本千奈_BASE + 7
十王星南_Campusmode = 十王星南_BASE + 0
十王星南_一番星 = 十王星南_BASE + 1
十王星南_学園生活 = 十王星南_BASE + 2
十王星南_小さな野望 = 十王星南_BASE + 3
姫崎莉波_clumsytrick = 姫崎莉波_BASE + 0
姫崎莉波_私らしさのはじまり = 姫崎莉波_BASE + 1
姫崎莉波_キミとセミブルー = 姫崎莉波_BASE + 2
姫崎莉波_Campusmode = 姫崎莉波_BASE + 3
姫崎莉波_LUV = 姫崎莉波_BASE + 4
姫崎莉波_ようこそ初星温泉 = 姫崎莉波_BASE + 5
姫崎莉波_ハッピーミルフィーユ = 姫崎莉波_BASE + 6
姫崎莉波_初心 = 姫崎莉波_BASE + 7
姫崎莉波_学園生活 = 姫崎莉波_BASE + 8
月村手毬_Lunasaymaybe = 月村手毬_BASE + 0
月村手毬_一匹狼 = 月村手毬_BASE + 1
月村手毬_Campusmode = 月村手毬_BASE + 2
月村手毬_アイヴイ = 月村手毬_BASE + 3
月村手毬_初声 = 月村手毬_BASE + 4
月村手毬_学園生活 = 月村手毬_BASE + 5
月村手毬_仮装狂騒曲 = 月村手毬_BASE + 6
有村麻央_Fluorite = 有村麻央_BASE + 0
有村麻央_はじまりはカッコよく = 有村麻央_BASE + 1
有村麻央_Campusmode = 有村麻央_BASE + 2
有村麻央_FeelJewelDream = 有村麻央_BASE + 3
有村麻央_キミとセミブルー = 有村麻央_BASE + 4
有村麻央_初恋 = 有村麻央_BASE + 5
有村麻央_学園生活 = 有村麻央_BASE + 6
篠泽广_コントラスト = 篠泽广_BASE + 0
篠泽广_一番向いていないこと = 篠泽广_BASE + 1
篠泽广_光景 = 篠泽广_BASE + 2
篠泽广_Campusmode = 篠泽广_BASE + 3
篠泽广_仮装狂騒曲 = 篠泽广_BASE + 4
篠泽广_ハッピーミルフィーユ = 篠泽广_BASE + 5
篠泽广_初恋 = 篠泽广_BASE + 6
篠泽广_学園生活 = 篠泽广_BASE + 7
紫云清夏_TameLieOneStep = 紫云清夏_BASE + 0
紫云清夏_カクシタワタシ = 紫云清夏_BASE + 1
紫云清夏_夢へのリスタート = 紫云清夏_BASE + 2
紫云清夏_Campusmode = 紫云清夏_BASE + 3
紫云清夏_キミとセミブルー = 紫云清夏_BASE + 4
紫云清夏_初恋 = 紫云清夏_BASE + 5
紫云清夏_学園生活 = 紫云清夏_BASE + 6
花海佑芽_WhiteNightWhiteWish = 花海佑芽_BASE + 0
花海佑芽_学園生活 = 花海佑芽_BASE + 1
花海佑芽_Campusmode = 花海佑芽_BASE + 2
花海佑芽_TheRollingRiceball = 花海佑芽_BASE + 3
花海佑芽_アイドル_はじめっ = 花海佑芽_BASE + 4
花海咲季_BoomBoomPow = 花海咲季_BASE + 0
花海咲季_Campusmode = 花海咲季_BASE + 1
花海咲季_FightingMyWay = 花海咲季_BASE + 2
花海咲季_わたしが一番 = 花海咲季_BASE + 3
花海咲季_冠菊 = 花海咲季_BASE + 4
花海咲季_初声 = 花海咲季_BASE + 5
花海咲季_古今東西ちょちょいのちょい = 花海咲季_BASE + 6
花海咲季_学園生活 = 花海咲季_BASE + 7
葛城リーリヤ_一つ踏み出した先に = 葛城リーリヤ_BASE + 0
葛城リーリヤ_白線 = 葛城リーリヤ_BASE + 1
葛城リーリヤ_Campusmode = 葛城リーリヤ_BASE + 2
葛城リーリヤ_WhiteNightWhiteWish = 葛城リーリヤ_BASE + 3
葛城リーリヤ_冠菊 = 葛城リーリヤ_BASE + 4
葛城リーリヤ_初心 = 葛城リーリヤ_BASE + 5
葛城リーリヤ_学園生活 = 葛城リーリヤ_BASE + 6
藤田ことね_カワイイ_はじめました = 藤田ことね_BASE + 0
藤田ことね_世界一可愛い私 = 藤田ことね_BASE + 1
藤田ことね_Campusmode = 藤田ことね_BASE + 2
藤田ことね_YellowBigBang = 藤田ことね_BASE + 3
藤田ことね_WhiteNightWhiteWish =藤田ことね_BASE + 4
藤田ことね_冠菊 = 藤田ことね_BASE + 5
藤田ことね_初声 = 藤田ことね_BASE + 6
藤田ことね_学園生活 = 藤田ことね_BASE + 7
__all__ = ["PIdol"]

View File

@ -0,0 +1,203 @@
"""v1 -> v2 迁移脚本
1. PIdol 字符串列表转换为整数枚举值
"""
from __future__ import annotations
import logging
from typing import Any
from ._idol import PIdol
logger = logging.getLogger(__name__)
def migrate(user_config: dict[str, Any]) -> str | None: # noqa: D401
"""执行 v1→v2 迁移。
参数 ``user_config`` 为单个用户配置 (dict)本函数允许就地修改
返回提示信息 (str)若无需提示可返回 ``None``
"""
options = user_config.get("options")
if options is None:
logger.debug("No 'options' in user_config, skip v1→v2 migration.")
return None
msg: str = ""
# 将旧格式的 idol 描述 (list[str]) 映射到 PIdol 枚举
def map_idol(idol: list[str]) -> PIdol | None:
logger.debug("Converting idol spec: %s", idol)
# 以下内容直接复制自旧实现
match idol:
case ["倉本千奈", "Campus mode!!"]:
return PIdol.倉本千奈_Campusmode
case ["倉本千奈", "Wonder Scale"]:
return PIdol.倉本千奈_WonderScale
case ["倉本千奈", "ようこそ初星温泉"]:
return PIdol.倉本千奈_ようこそ初星温泉
case ["倉本千奈", "仮装狂騒曲"]:
return PIdol.倉本千奈_仮装狂騒曲
case ["倉本千奈", "初心"]:
return PIdol.倉本千奈_初心
case ["倉本千奈", "学園生活"]:
return PIdol.倉本千奈_学園生活
case ["倉本千奈", "日々、発見的ステップ!"]:
return PIdol.倉本千奈_日々_発見的ステップ
case ["倉本千奈", "胸を張って一歩ずつ"]:
return PIdol.倉本千奈_胸を張って一歩ずつ
case ["十王星南", "Campus mode!!"]:
return PIdol.十王星南_Campusmode
case ["十王星南", "一番星"]:
return PIdol.十王星南_一番星
case ["十王星南", "学園生活"]:
return PIdol.十王星南_学園生活
case ["十王星南", "小さな野望"]:
return PIdol.十王星南_小さな野望
case ["姫崎莉波", "clumsy trick"]:
return PIdol.姫崎莉波_clumsytrick
case ["姫崎莉波", "『私らしさ』のはじまり"]:
return PIdol.姫崎莉波_私らしさのはじまり
case ["姫崎莉波", "キミとセミブルー"]:
return PIdol.姫崎莉波_キミとセミブルー
case ["姫崎莉波", "Campus mode!!"]:
return PIdol.姫崎莉波_Campusmode
case ["姫崎莉波", "L.U.V"]:
return PIdol.姫崎莉波_LUV
case ["姫崎莉波", "ようこそ初星温泉"]:
return PIdol.姫崎莉波_ようこそ初星温泉
case ["姫崎莉波", "ハッピーミルフィーユ"]:
return PIdol.姫崎莉波_ハッピーミルフィーユ
case ["姫崎莉波", "初心"]:
return PIdol.姫崎莉波_初心
case ["姫崎莉波", "学園生活"]:
return PIdol.姫崎莉波_学園生活
case ["月村手毬", "Luna say maybe"]:
return PIdol.月村手毬_Lunasaymaybe
case ["月村手毬", "一匹狼"]:
return PIdol.月村手毬_一匹狼
case ["月村手毬", "Campus mode!!"]:
return PIdol.月村手毬_Campusmode
case ["月村手毬", "アイヴイ"]:
return PIdol.月村手毬_アイヴイ
case ["月村手毬", "初声"]:
return PIdol.月村手毬_初声
case ["月村手毬", "学園生活"]:
return PIdol.月村手毬_学園生活
case ["月村手毬", "仮装狂騒曲"]:
return PIdol.月村手毬_仮装狂騒曲
case ["有村麻央", "Fluorite"]:
return PIdol.有村麻央_Fluorite
case ["有村麻央", "はじまりはカッコよく"]:
return PIdol.有村麻央_はじまりはカッコよく
case ["有村麻央", "Campus mode!!"]:
return PIdol.有村麻央_Campusmode
case ["有村麻央", "Feel Jewel Dream"]:
return PIdol.有村麻央_FeelJewelDream
case ["有村麻央", "キミとセミブルー"]:
return PIdol.有村麻央_キミとセミブルー
case ["有村麻央", "初恋"]:
return PIdol.有村麻央_初恋
case ["有村麻央", "学園生活"]:
return PIdol.有村麻央_学園生活
case ["篠泽广", "コントラスト"]:
return PIdol.篠泽广_コントラスト
case ["篠泽广", "一番向いていないこと"]:
return PIdol.篠泽广_一番向いていないこと
case ["篠泽广", "光景"]:
return PIdol.篠泽广_光景
case ["篠泽广", "Campus mode!!"]:
return PIdol.篠泽广_Campusmode
case ["篠泽广", "仮装狂騒曲"]:
return PIdol.篠泽广_仮装狂騒曲
case ["篠泽广", "ハッピーミルフィーユ"]:
return PIdol.篠泽广_ハッピーミルフィーユ
case ["篠泽广", "初恋"]:
return PIdol.篠泽广_初恋
case ["篠泽广", "学園生活"]:
return PIdol.篠泽广_学園生活
case ["紫云清夏", "Tame Lie One Step"]:
return PIdol.紫云清夏_TameLieOneStep
case ["紫云清夏", "カクシタワタシ"]:
return PIdol.紫云清夏_カクシタワタシ
case ["紫云清夏", "夢へのリスタート"]:
return PIdol.紫云清夏_夢へのリスタート
case ["紫云清夏", "Campus mode!!"]:
return PIdol.紫云清夏_Campusmode
case ["紫云清夏", "キミとセミブルー"]:
return PIdol.紫云清夏_キミとセミブルー
case ["紫云清夏", "初恋"]:
return PIdol.紫云清夏_初恋
case ["紫云清夏", "学園生活"]:
return PIdol.紫云清夏_学園生活
case ["花海佑芽", "White Night! White Wish!"]:
return PIdol.花海佑芽_WhiteNightWhiteWish
case ["花海佑芽", "学園生活"]:
return PIdol.花海佑芽_学園生活
case ["花海佑芽", "Campus mode!!"]:
return PIdol.花海佑芽_Campusmode
case ["花海佑芽", "The Rolling Riceball"]:
return PIdol.花海佑芽_TheRollingRiceball
case ["花海佑芽", "アイドル、はじめっ!"]:
return PIdol.花海佑芽_アイドル_はじめっ
case ["花海咲季", "Boom Boom Pow"]:
return PIdol.花海咲季_BoomBoomPow
case ["花海咲季", "Campus mode!!"]:
return PIdol.花海咲季_Campusmode
case ["花海咲季", "Fighting My Way"]:
return PIdol.花海咲季_FightingMyWay
case ["花海咲季", "わたしが一番!"]:
return PIdol.花海咲季_わたしが一番
case ["花海咲季", "冠菊"]:
return PIdol.花海咲季_冠菊
case ["花海咲季", "初声"]:
return PIdol.花海咲季_初声
case ["花海咲季", "古今東西ちょちょいのちょい"]:
return PIdol.花海咲季_古今東西ちょちょいのちょい
case ["花海咲季", "学園生活"]:
return PIdol.花海咲季_学園生活
case ["葛城リーリヤ", "一つ踏み出した先に"]:
return PIdol.葛城リーリヤ_一つ踏み出した先に
case ["葛城リーリヤ", "白線"]:
return PIdol.葛城リーリヤ_白線
case ["葛城リーリヤ", "Campus mode!!"]:
return PIdol.葛城リーリヤ_Campusmode
case ["葛城リーリヤ", "White Night! White Wish!"]:
return PIdol.葛城リーリヤ_WhiteNightWhiteWish
case ["葛城リーリヤ", "冠菊"]:
return PIdol.葛城リーリヤ_冠菊
case ["葛城リーリヤ", "初心"]:
return PIdol.葛城リーリヤ_初心
case ["葛城リーリヤ", "学園生活"]:
return PIdol.葛城リーリヤ_学園生活
case ["藤田ことね", "カワイイ", "はじめました"]:
return PIdol.藤田ことね_カワイイ_はじめました
case ["藤田ことね", "世界一可愛い私"]:
return PIdol.藤田ことね_世界一可愛い私
case ["藤田ことね", "Campus mode!!"]:
return PIdol.藤田ことね_Campusmode
case ["藤田ことね", "Yellow Big Bang"]:
return PIdol.藤田ことね_YellowBigBang
case ["藤田ことね", "White Night! White Wish!"]:
return PIdol.藤田ことね_WhiteNightWhiteWish
case ["藤田ことね", "冠菊"]:
return PIdol.藤田ことね_冠菊
case ["藤田ことね", "初声"]:
return PIdol.藤田ことね_初声
case ["藤田ことね", "学園生活"]:
return PIdol.藤田ことね_学園生活
case _:
nonlocal msg
if msg == "":
msg = "培育设置中的以下偶像升级失败。请尝试手动添加。\n"
msg += f"{idol} 未找到\n"
return None
produce_conf = options.get("produce", {})
old_idols = produce_conf.get("idols", [])
new_idols = list(filter(lambda x: x is not None, map(map_idol, old_idols)))
produce_conf["idols"] = new_idols
options["produce"] = produce_conf
user_config["options"] = options
return msg or None

View File

@ -0,0 +1,126 @@
"""v2 → v3 迁移脚本
引入游戏解包数据后`produce.idols` 不再使用 `PIdol` 枚举而是直接使用
游戏内的 idol skin id (字符串)这里负责完成枚举到字符串的转换
"""
from __future__ import annotations
import logging
from typing import Any
from ._idol import PIdol
logger = logging.getLogger(__name__)
# 枚举 → skin_id 映射表(复制自旧实现)。
_PIDOL_TO_SKIN: dict[PIdol, str] = {
PIdol.倉本千奈_Campusmode: "i_card-skin-kcna-3-007",
PIdol.倉本千奈_WonderScale: "i_card-skin-kcna-3-000",
PIdol.倉本千奈_ようこそ初星温泉: "i_card-skin-kcna-3-005",
PIdol.倉本千奈_仮装狂騒曲: "i_card-skin-kcna-3-002",
PIdol.倉本千奈_初心: "i_card-skin-kcna-1-001",
PIdol.倉本千奈_学園生活: "i_card-skin-kcna-1-000",
PIdol.倉本千奈_日々_発見的ステップ: "i_card-skin-kcna-3-001",
PIdol.倉本千奈_胸を張って一歩ずつ: "i_card-skin-kcna-2-000",
PIdol.十王星南_Campusmode: "i_card-skin-jsna-3-002",
PIdol.十王星南_一番星: "i_card-skin-jsna-2-000",
PIdol.十王星南_学園生活: "i_card-skin-jsna-1-000",
PIdol.十王星南_小さな野望: "i_card-skin-jsna-3-000",
PIdol.姫崎莉波_clumsytrick: "i_card-skin-hrnm-3-000",
PIdol.姫崎莉波_私らしさのはじまり: "i_card-skin-hrnm-2-000",
PIdol.姫崎莉波_キミとセミブルー: "i_card-skin-hrnm-3-001",
PIdol.姫崎莉波_Campusmode: "i_card-skin-hrnm-3-007",
PIdol.姫崎莉波_LUV: "i_card-skin-hrnm-3-002",
PIdol.姫崎莉波_ようこそ初星温泉: "i_card-skin-hrnm-3-004",
PIdol.姫崎莉波_ハッピーミルフィーユ: "i_card-skin-hrnm-3-008",
PIdol.姫崎莉波_初心: "i_card-skin-hrnm-1-001",
PIdol.姫崎莉波_学園生活: "i_card-skin-hrnm-1-000",
PIdol.月村手毬_Lunasaymaybe: "i_card-skin-ttmr-3-000",
PIdol.月村手毬_一匹狼: "i_card-skin-ttmr-2-000",
PIdol.月村手毬_Campusmode: "i_card-skin-ttmr-3-007",
PIdol.月村手毬_アイヴイ: "i_card-skin-ttmr-3-001",
PIdol.月村手毬_初声: "i_card-skin-ttmr-1-001",
PIdol.月村手毬_学園生活: "i_card-skin-ttmr-1-000",
PIdol.月村手毬_仮装狂騒曲: "i_card-skin-ttmr-3-002",
PIdol.有村麻央_Fluorite: "i_card-skin-amao-3-000",
PIdol.有村麻央_はじまりはカッコよく: "i_card-skin-amao-2-000",
PIdol.有村麻央_Campusmode: "i_card-skin-amao-3-007",
PIdol.有村麻央_FeelJewelDream: "i_card-skin-amao-3-002",
PIdol.有村麻央_キミとセミブルー: "i_card-skin-amao-3-001",
PIdol.有村麻央_初恋: "i_card-skin-amao-1-001",
PIdol.有村麻央_学園生活: "i_card-skin-amao-1-000",
PIdol.篠泽广_コントラスト: "i_card-skin-shro-3-001",
PIdol.篠泽广_一番向いていないこと: "i_card-skin-shro-2-000",
PIdol.篠泽广_光景: "i_card-skin-shro-3-000",
PIdol.篠泽广_Campusmode: "i_card-skin-shro-3-007",
PIdol.篠泽广_仮装狂騒曲: "i_card-skin-shro-3-002",
PIdol.篠泽广_ハッピーミルフィーユ: "i_card-skin-shro-3-008",
PIdol.篠泽广_初恋: "i_card-skin-shro-1-001",
PIdol.篠泽广_学園生活: "i_card-skin-shro-1-000",
PIdol.紫云清夏_TameLieOneStep: "i_card-skin-ssmk-3-000",
PIdol.紫云清夏_カクシタワタシ: "i_card-skin-ssmk-3-002",
PIdol.紫云清夏_夢へのリスタート: "i_card-skin-ssmk-2-000",
PIdol.紫云清夏_Campusmode: "i_card-skin-ssmk-3-007",
PIdol.紫云清夏_キミとセミブルー: "i_card-skin-ssmk-3-001",
PIdol.紫云清夏_初恋: "i_card-skin-ssmk-1-001",
PIdol.紫云清夏_学園生活: "i_card-skin-ssmk-1-000",
PIdol.花海佑芽_WhiteNightWhiteWish: "i_card-skin-hume-3-005",
PIdol.花海佑芽_学園生活: "i_card-skin-hume-1-000",
PIdol.花海佑芽_Campusmode: "i_card-skin-hume-3-006",
PIdol.花海佑芽_TheRollingRiceball: "i_card-skin-hume-3-000",
PIdol.花海佑芽_アイドル_はじめっ: "i_card-skin-hume-2-000",
PIdol.花海咲季_BoomBoomPow: "i_card-skin-hski-3-001",
PIdol.花海咲季_Campusmode: "i_card-skin-hski-3-008",
PIdol.花海咲季_FightingMyWay: "i_card-skin-hski-3-000",
PIdol.花海咲季_わたしが一番: "i_card-skin-hski-2-000",
PIdol.花海咲季_冠菊: "i_card-skin-hski-3-001",
PIdol.花海咲季_初声: "i_card-skin-hski-1-001",
PIdol.花海咲季_古今東西ちょちょいのちょい: "i_card-skin-hski-3-006",
PIdol.花海咲季_学園生活: "i_card-skin-hski-1-000",
PIdol.葛城リーリヤ_一つ踏み出した先に: "i_card-skin-kllj-2-000",
PIdol.葛城リーリヤ_白線: "i_card-skin-kllj-3-000",
PIdol.葛城リーリヤ_Campusmode: "i_card-skin-kllj-3-006",
PIdol.葛城リーリヤ_WhiteNightWhiteWish: "i_card-skin-kllj-3-005",
PIdol.葛城リーリヤ_冠菊: "i_card-skin-kllj-3-001",
PIdol.葛城リーリヤ_初心: "i_card-skin-kllj-1-001",
PIdol.葛城リーリヤ_学園生活: "i_card-skin-kllj-1-000",
PIdol.藤田ことね_カワイイ_はじめました: "i_card-skin-fktn-2-000",
PIdol.藤田ことね_世界一可愛い私: "i_card-skin-fktn-3-000",
PIdol.藤田ことね_Campusmode: "i_card-skin-fktn-3-007",
PIdol.藤田ことね_YellowBigBang: "i_card-skin-fktn-3-001",
PIdol.藤田ことね_WhiteNightWhiteWish: "i_card-skin-fktn-3-006",
PIdol.藤田ことね_冠菊: "i_card-skin-fktn-3-002",
PIdol.藤田ことね_初声: "i_card-skin-fktn-1-001",
PIdol.藤田ことね_学園生活: "i_card-skin-fktn-1-000",
}
def migrate(user_config: dict[str, Any]) -> str | None: # noqa: D401
"""执行 v2→v3 迁移。"""
options = user_config.get("options")
if options is None:
logger.debug("No 'options' in user_config, skip v2→v3 migration.")
return None
produce_conf = options.get("produce", {})
old_idols = produce_conf.get("idols", [])
msg = ""
new_idols: list[str] = []
for idol in old_idols:
if isinstance(idol, int): # 原本已是 int(PIdol)
try:
skin = _PIDOL_TO_SKIN[PIdol(idol)]
new_idols.append(skin)
except (ValueError, KeyError):
msg += f"未知 PIdol: {idol}\n"
else:
msg += f"旧 idol 数据格式异常: {idol}\n"
produce_conf["idols"] = new_idols
options["produce"] = produce_conf
user_config["options"] = options
return msg or None

View File

@ -0,0 +1,29 @@
"""v3 -> v4 迁移脚本
修正游戏包名错误
"""
from __future__ import annotations
import logging
from typing import Any
logger = logging.getLogger(__name__)
def migrate(user_config: dict[str, Any]) -> str | None: # noqa: D401
"""执行 v3→v4 迁移:修正错误的游戏包名。"""
options = user_config.get("options")
if options is None:
logger.debug("No 'options' in user_config, skip v3→v4 migration.")
return None
start_conf = options.get("start_game", {})
old_pkg = start_conf.get("game_package_name")
if old_pkg == "com.bandinamcoent.idolmaster_gakuen":
start_conf["game_package_name"] = "com.bandainamcoent.idolmaster_gakuen"
logger.info("Corrected game package name to com.bandainamcoent.idolmaster_gakuen")
options["start_game"] = start_conf
user_config["options"] = options
return None

View File

@ -0,0 +1,26 @@
"""v4 -> v5 迁移脚本
Windows 截图方式的配置统一设置 backend.type = 'dmm'
"""
from __future__ import annotations
import logging
from typing import Any
logger = logging.getLogger(__name__)
def migrate(user_config: dict[str, Any]) -> str | None: # noqa: D401
"""执行 v4→v5 迁移:
当截图方式为 windows / remote_windows backend.type 统一设置为 'dmm'
"""
backend = user_config.get("backend", {})
impl = backend.get("screenshot_impl")
if impl in {"windows", "remote_windows"}:
logger.info("Set backend type to dmm for screenshot_impl=%s", impl)
backend["type"] = "dmm"
user_config["backend"] = backend
# v4→v5 无 options 结构更改,直接返回
return None

View File

@ -0,0 +1,134 @@
"""v5 -> v6 迁移脚本
重构培育配置将原有的 ProduceConfig 中的培育参数迁移到新的 ProduceSolution 结构中
"""
from __future__ import annotations
import logging
import os
import json
import uuid
import re
from typing import Any
logger = logging.getLogger(__name__)
def _sanitize_filename(name: str) -> str:
"""
清理文件名中的非法字符
:param name: 原始名称
:return: 清理后的文件名
"""
# 替换 \/:*?"<>| 为下划线
return re.sub(r'[\\/:*?"<>|]', '_', name)
def _create_default_solution(old_produce_config: dict[str, Any]) -> tuple[dict[str, Any], str]:
"""
根据旧的培育配置创建默认的培育方案
:param old_produce_config: 旧的培育配置
:return: (新的培育方案数据, 方案ID)
"""
# 生成唯一ID
solution_id = uuid.uuid4().hex
# 构建培育数据
produce_data = {
"mode": old_produce_config.get("mode", "regular"),
"idol": old_produce_config.get("idols", [None])[0] if old_produce_config.get("idols") else None,
"memory_set": old_produce_config.get("memory_sets", [None])[0] if old_produce_config.get("memory_sets") else None,
"support_card_set": old_produce_config.get("support_card_sets", [None])[0] if old_produce_config.get("support_card_sets") else None,
"auto_set_memory": old_produce_config.get("auto_set_memory", False),
"auto_set_support_card": old_produce_config.get("auto_set_support_card", False),
"use_pt_boost": old_produce_config.get("use_pt_boost", False),
"use_note_boost": old_produce_config.get("use_note_boost", False),
"follow_producer": old_produce_config.get("follow_producer", False),
"self_study_lesson": old_produce_config.get("self_study_lesson", "dance"),
"prefer_lesson_ap": old_produce_config.get("prefer_lesson_ap", False),
"actions_order": old_produce_config.get("actions_order", [
"recommended", "visual", "vocal", "dance",
"allowance", "outing", "study", "consult", "rest"
]),
"recommend_card_detection_mode": old_produce_config.get("recommend_card_detection_mode", "normal"),
"use_ap_drink": old_produce_config.get("use_ap_drink", False),
"skip_commu": old_produce_config.get("skip_commu", True)
}
# 构建方案对象
solution = {
"type": "produce_solution",
"id": solution_id,
"name": "默认方案",
"description": "从旧配置迁移的默认培育方案",
"data": produce_data
}
return solution, solution_id
def _save_solution_to_file(solution: dict[str, Any]) -> None:
"""
将培育方案保存到文件
:param solution: 培育方案数据
"""
solutions_dir = "conf/produce"
os.makedirs(solutions_dir, exist_ok=True)
safe_name = _sanitize_filename(solution["name"])
file_path = os.path.join(solutions_dir, f"{safe_name}.json")
with open(file_path, 'w', encoding='utf-8') as f:
json.dump(solution, f, ensure_ascii=False, indent=4)
def migrate(user_config: dict[str, Any]) -> str | None: # noqa: D401
"""执行 v5→v6 迁移:重构培育配置结构。
将原有的 ProduceConfig 中的培育参数迁移到新的 ProduceSolution 结构中
"""
options = user_config.get("options")
if options is None:
logger.debug("No 'options' in user_config, skip v5→v6 migration.")
return None
produce_conf = options.get("produce", {})
if not produce_conf:
logger.debug("No 'produce' config found, skip v5→v6 migration.")
return None
# 检查是否已经是新格式(有 selected_solution_id 字段)
if "selected_solution_id" in produce_conf:
logger.debug("Produce config already in v6 format, skip migration.")
return None
msg = ""
try:
# 创建默认培育方案
solution, solution_id = _create_default_solution(produce_conf)
# 保存方案到文件
_save_solution_to_file(solution)
# 更新配置为新格式
new_produce_conf = {
"enabled": produce_conf.get("enabled", False),
"selected_solution_id": solution_id,
"produce_count": produce_conf.get("produce_count", 1)
}
options["produce"] = new_produce_conf
user_config["options"] = options
msg = f"已将培育配置迁移到新的方案系统。默认方案已创建并保存为 '{solution['name']}'"
logger.info("Successfully migrated produce config to v6 format with solution ID: %s", solution_id)
except Exception as e:
logger.error("Failed to migrate produce config: %s", e)
msg = f"培育配置迁移失败:{e}"
return msg or None

View File

@ -0,0 +1,257 @@
import os
import json
import uuid
import re
import logging
from typing import Literal
from pydantic import BaseModel, ConfigDict, ValidationError, field_serializer, field_validator
from kotonebot.kaa.errors import ProduceSolutionInvalidError, ProduceSolutionNotFoundError
from .const import ProduceAction, RecommendCardDetectionMode
logger = logging.getLogger(__name__)
class ConfigBaseModel(BaseModel):
model_config = ConfigDict(use_attribute_docstrings=True)
class ProduceData(ConfigBaseModel):
mode: Literal['regular', 'pro', 'master'] = 'regular'
"""
培育模式
进行一次 REGULAR 培育需要 ~30min进行一次 PRO 培育需要 ~1h具体视设备性能而定
"""
idol: str | None = None
"""
要培育偶像的 IdolCardSkin.id
"""
memory_set: int | None = None
"""要使用的回忆编成编号,从 1 开始。"""
support_card_set: int | None = None
"""要使用的支援卡编成编号,从 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):
"""培育方案"""
type: Literal['produce_solution'] = 'produce_solution'
"""方案类型标识"""
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 _find_file_path_by_id(self, id: str) -> str | None:
"""
根据方案ID查找文件路径
:param id: 方案ID
:return: 文件路径如果未找到则返回 None
"""
if not os.path.exists(self.SOLUTIONS_DIR):
return None
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 file_path
except Exception:
continue
return None
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:
solution = ProduceSolution.model_validate_json(f.read())
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
"""
file_path = self._find_file_path_by_id(id)
if file_path:
os.remove(file_path)
def save(self, id: str, solution: ProduceSolution) -> None:
"""
保存培育方案
:param id: 方案ID
:param solution: 方案对象
"""
# 确保ID一致
solution.id = id
# 先删除具有相同ID的旧文件如果存在避免名称变更时产生重复文件
old_file_path = self._find_file_path_by_id(id)
if old_file_path:
os.remove(old_file_path)
# 保存新文件
file_path = self._get_file_path(solution.name)
with open(file_path, 'w', encoding='utf-8') as f:
# 使用 model_dump 并指定 mode='json' 来正确序列化枚举
data = solution.model_dump(mode='json')
json.dump(data, f, ensure_ascii=False, indent=4)
def read(self, id: str) -> ProduceSolution:
"""
读取指定ID的培育方案
:param id: 方案ID
:return: 方案对象
:raises ProduceSloutionNotFoundError: 当方案不存在时
"""
file_path = self._find_file_path_by_id(id)
if not file_path:
raise ProduceSolutionNotFoundError(id)
try:
with open(file_path, 'r', encoding='utf-8') as f:
return ProduceSolution.model_validate_json(f.read())
except ValidationError as e:
raise ProduceSolutionInvalidError(id, file_path, e)
def duplicate(self, id: str) -> ProduceSolution:
"""
复制指定ID的培育方案
:param id: 要复制的方案ID
:return: 新的方案对象具有新的ID和名称
:raises ProduceSolutionNotFoundError: 当原方案不存在时
"""
original = self.read(id)
# 生成新的ID和名称
new_id = uuid.uuid4().hex
new_name = f"{original.name} - 副本"
# 创建新的方案对象
new_solution = ProduceSolution(
type=original.type,
id=new_id,
name=new_name,
description=original.description,
data=original.data.model_copy() # 深拷贝数据
)
return new_solution

View File

@ -0,0 +1,238 @@
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,
)
T = TypeVar('T')
class ConfigBaseModel(BaseModel):
model_config = ConfigDict(use_attribute_docstrings=True)
class PurchaseConfig(ConfigBaseModel):
enabled: bool = False
"""是否启用商店购买"""
money_enabled: bool = False
"""是否启用金币购买"""
money_items: list[DailyMoneyShopItems] = []
"""金币商店要购买的物品"""
money_refresh: bool = True
"""
是否使用每日一次免费刷新金币商店
"""
ap_enabled: bool = False
"""是否启用AP购买"""
ap_items: Sequence[Literal[0, 1, 2, 3]] = []
"""AP商店要购买的物品"""
class ActivityFundsConfig(ConfigBaseModel):
enabled: bool = False
"""是否启用收取活动费"""
class PresentsConfig(ConfigBaseModel):
enabled: bool = False
"""是否启用收取礼物"""
class AssignmentConfig(ConfigBaseModel):
enabled: bool = False
"""是否启用工作"""
mini_live_reassign_enabled: bool = False
"""是否启用重新分配 MiniLive"""
mini_live_duration: Literal[4, 6, 12] = 12
"""MiniLive 工作时长"""
online_live_reassign_enabled: bool = False
"""是否启用重新分配 OnlineLive"""
online_live_duration: Literal[4, 6, 12] = 12
"""OnlineLive 工作时长"""
class ContestConfig(ConfigBaseModel):
enabled: bool = False
"""是否启用竞赛"""
select_which_contestant: Literal[1, 2, 3] = 1
"""选择第几个挑战者"""
when_no_set: Literal['remind', 'wait', 'auto_set', 'auto_set_silent'] = 'remind'
"""竞赛队伍未编成时应该remind=通知我并跳过竞赛wait=提醒我并等待手动编成auto_set=使用自动编成并提醒auto_set_silent=使用自动编成不提醒"""
class ProduceConfig(ConfigBaseModel):
enabled: bool = False
"""是否启用培育"""
selected_solution_id: str | None = None
"""选中的培育方案ID"""
produce_count: int = 1
"""培育的次数。"""
class MissionRewardConfig(ConfigBaseModel):
enabled: bool = False
"""是否启用领取任务奖励"""
class ClubRewardConfig(ConfigBaseModel):
enabled: bool = False
"""是否启用领取社团奖励"""
selected_note: DailyMoneyShopItems = DailyMoneyShopItems.AnomalyNoteVisual
"""想在社团奖励中获取到的笔记"""
class UpgradeSupportCardConfig(ConfigBaseModel):
enabled: bool = False
"""是否启用支援卡升级"""
class CapsuleToysConfig(ConfigBaseModel):
enabled: bool = False
"""是否启用扭蛋机"""
friend_capsule_toys_count: int = 0
"""好友扭蛋机次数"""
sense_capsule_toys_count: int = 0
"""感性扭蛋机次数"""
logic_capsule_toys_count: int = 0
"""理性扭蛋机次数"""
anomaly_capsule_toys_count: int = 0
"""非凡扭蛋机次数"""
class TraceConfig(ConfigBaseModel):
recommend_card_detection: bool = False
"""跟踪推荐卡检测"""
class StartGameConfig(ConfigBaseModel):
enabled: bool = True
"""是否启用自动启动游戏。默认为True"""
start_through_kuyo: bool = False
"""是否通过Kuyo来启动游戏"""
game_package_name: str = 'com.bandainamcoent.idolmaster_gakuen'
"""游戏包名"""
kuyo_package_name: str = 'org.kuyo.game'
"""Kuyo包名"""
disable_gakumas_localify: bool = False
"""
自动检测并禁用 Gakumas Localify 汉化插件
目前仅对 DMM 版有效
"""
dmm_game_path: str | None = None
"""
DMM 版游戏路径若不填写会自动检测
`F:\\Games\\gakumas\\gakumas.exe`
"""
class EndGameConfig(ConfigBaseModel):
exit_kaa: bool = False
"""退出 kaa"""
kill_game: bool = False
"""关闭游戏"""
kill_dmm: bool = False
"""关闭 DMMGamePlayer"""
kill_emulator: bool = False
"""关闭模拟器"""
shutdown: bool = False
"""关闭系统"""
hibernate: bool = False
"""休眠系统"""
restore_gakumas_localify: bool = False
"""
恢复 Gakumas Localify 汉化插件状态至启动前通常与
`disable_gakumas_localify` 配对使用
目前仅对 DMM 版有效
"""
class MiscConfig(ConfigBaseModel):
check_update: Literal['never', 'startup'] = 'startup'
"""
检查更新时机
* never: 从不检查更新
* startup: 启动时检查更新
"""
auto_install_update: bool = True
"""
是否自动安装更新
若启用则每次自动检查更新时若有新版本会自动安装否则只是会提示
"""
expose_to_lan: bool = False
"""
是否允许局域网访问 Web 界面
启用后局域网内的其他设备可以通过本机 IP 地址访问 Web 界面
"""
class BaseConfig(ConfigBaseModel):
purchase: PurchaseConfig = PurchaseConfig()
"""商店购买配置"""
activity_funds: ActivityFundsConfig = ActivityFundsConfig()
"""活动费配置"""
presents: PresentsConfig = PresentsConfig()
"""收取礼物配置"""
assignment: AssignmentConfig = AssignmentConfig()
"""工作配置"""
contest: ContestConfig = ContestConfig()
"""竞赛配置"""
produce: ProduceConfig = ProduceConfig()
"""培育配置"""
mission_reward: MissionRewardConfig = MissionRewardConfig()
"""领取任务奖励配置"""
club_reward: ClubRewardConfig = ClubRewardConfig()
"""领取社团奖励配置"""
upgrade_support_card: UpgradeSupportCardConfig = UpgradeSupportCardConfig()
"""支援卡升级配置"""
capsule_toys: CapsuleToysConfig = CapsuleToysConfig()
"""扭蛋机配置"""
trace: TraceConfig = TraceConfig()
"""跟踪配置"""
start_game: StartGameConfig = StartGameConfig()
"""启动游戏配置"""
end_game: EndGameConfig = EndGameConfig()
"""关闭游戏配置"""
misc: MiscConfig = MiscConfig()
"""杂项配置"""
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

@ -0,0 +1,63 @@
import os
import json
import logging
import shutil
from typing import Any
logger = logging.getLogger(__name__)
def upgrade_config() -> str | None:
"""检查并升级 `config.json` 到最新版本。
若配置已是最新版本则返回 ``None``否则返回合并后的迁移提示信息
"""
# 避免循环依赖,这里再进行本地导入
from .migrations import MIGRATION_REGISTRY, LATEST_VERSION # pylint: disable=import-outside-toplevel
logger.setLevel(logging.DEBUG)
print('1212121212')
config_path = "config.json"
if not os.path.exists(config_path):
logger.debug("config.json not found. Skip upgrade.")
return None
# 读取配置
with open(config_path, "r", encoding="utf-8") as f:
root: dict[str, Any] = json.load(f)
version: int = root.get("version", 1)
if version >= LATEST_VERSION:
logger.info("Config already at latest version (v%s).", version)
return None
logger.info("Start upgrading config: current v%s → target v%s", version, LATEST_VERSION)
messages: list[str] = []
# 循环依次升级
while version < LATEST_VERSION:
migrator = MIGRATION_REGISTRY.get(version)
if migrator is None:
logger.warning("No migrator registered for version v%s. Abort upgrade.", version)
break
# 备份文件
backup_path = f"config.v{version}.json"
shutil.copy(config_path, backup_path)
logger.info("Backup saved: %s", backup_path)
# 对每个 user_config 应用迁移
for user_cfg in root.get("user_configs", []):
msg = migrator(user_cfg)
if msg:
messages.append(f"v{version} → v{version+1}:\n{msg}")
# 更新版本号并写回
version += 1
root["version"] = version
with open(config_path, "w", encoding="utf-8") as f:
json.dump(root, f, ensure_ascii=False, indent=4)
logger.info("Config upgrade finished. Now at v%s", version)
return "\n---\n".join(messages) if messages else None

38
kotonebot/kaa/errors.py Normal file
View File

@ -0,0 +1,38 @@
import os
from kotonebot.errors import UserFriendlyError
class KaaError(Exception):
pass
class KaaUserFriendlyError(UserFriendlyError, KaaError):
def __init__(self, message: str, help_link: str):
super().__init__(message, [
(0, '打开帮助', lambda: os.startfile(help_link)),
(1, '知道了', lambda: None)
])
class ProduceSolutionNotFoundError(KaaUserFriendlyError):
def __init__(self, solution_id: str):
self.solution_id = solution_id
super().__init__(
f'培育方案「{solution_id}」不存在,请检查设置是否正确。',
'https://kdocs.cn/l/cetCY8mGKHLj?linkname=saPrDAmMd4'
)
class ProduceSolutionInvalidError(KaaUserFriendlyError):
def __init__(self, solution_id: str, file_path: str, reason: Exception):
self.solution_id = solution_id
self.reason = reason
super().__init__(
f'培育方案「{solution_id}」(路径 {file_path})存在无效配置,载入失败。',
'https://kdocs.cn/l/cetCY8mGKHLj?linkname=xnLUW1YYKz'
)
class IdolCardNotFoundError(KaaUserFriendlyError):
def __init__(self, skin_id: str):
self.skin_id = skin_id
super().__init__(
f'未找到 ID 为「{skin_id}」的偶像卡。请检查游戏内偶像皮肤与培育方案中偶像皮肤是否一致。',
'https://kdocs.cn/l/cetCY8mGKHLj?linkname=cySASqoPGj'
)

View File

@ -32,10 +32,13 @@ YELLOW_TARGET = (39, 81, 97)
YELLOW_LOW = (30, 70, 90)
YELLOW_HIGH = (45, 90, 100)
ORANGE_RANGE = ((14, 178, 229), (16, 229, 255))
DEFAULT_COLORS = [
(web2cv(PINK_LOW), web2cv(PINK_HIGH)),
(web2cv(YELLOW_LOW), web2cv(YELLOW_HIGH)),
(web2cv(BLUE_LOW), web2cv(BLUE_HIGH)),
ORANGE_RANGE
]
# 参考图片:

View File

@ -6,7 +6,7 @@ from cv2.typing import MatLike
from kotonebot.primitives import Rect
from kotonebot import ocr, device, image, action
from kotonebot.backend.core import HintBox
from kotonebot.kaa.common import ProduceAction
from kotonebot.kaa.config import ProduceAction
from kotonebot.kaa.tasks import R
logger = logging.getLogger(__name__)

File diff suppressed because it is too large Load Diff

View File

@ -1,5 +1,6 @@
import io
import os
import sys
from typing import Any, Literal, cast
import zipfile
import logging
@ -18,7 +19,7 @@ from kotonebot import KotoneBot
from ..util.paths import get_ahk_path
from ..kaa_context import _set_instance
from .dmm_host import DmmHost, DmmInstance
from ..common import BaseConfig, upgrade_config
from ..config import BaseConfig, upgrade_config
from kotonebot.config.base_config import UserConfig
from kotonebot.client.host import (
Mumu12Host, LeidianHost, Mumu12Instance,
@ -30,36 +31,38 @@ from kotonebot.client.host.protocol import (
)
# 初始化日志
log_formatter = logging.Formatter('[%(asctime)s][%(levelname)s][%(name)s] %(message)s')
console_handler = logging.StreamHandler()
console_handler.setFormatter(log_formatter)
console_handler.setLevel(logging.CRITICAL)
format = '[%(asctime)s][%(levelname)s][%(name)s:%(lineno)d] %(message)s'
log_formatter = logging.Formatter(format)
logging.basicConfig(level=logging.INFO, format=format)
log_stream = io.StringIO()
stream_handler = logging.StreamHandler(log_stream)
stream_handler.setFormatter(logging.Formatter('[%(asctime)s] [%(levelname)s] [%(name)s] [%(filename)s:%(lineno)d] - %(message)s'))
memo_handler = logging.StreamHandler(log_stream)
memo_handler.setFormatter(log_formatter)
memo_handler.setLevel(logging.DEBUG)
root_logger = logging.getLogger()
root_logger.setLevel(logging.INFO)
root_logger.addHandler(console_handler)
root_logger.addHandler(memo_handler)
logging.getLogger("kotonebot").setLevel(logging.DEBUG)
logging.getLogger("httpx").setLevel(logging.WARNING)
logger = logging.getLogger(__name__)
# 升级配置
upgrade_msg = upgrade_config()
class Kaa(KotoneBot):
"""
琴音小助手 kaa 主类由其他 GUI/TUI 调用
"""
def __init__(self, config_path: str):
# 升级配置
upgrade_msg = upgrade_config()
super().__init__(module='kotonebot.kaa.tasks', config_path=config_path, config_type=BaseConfig)
self.upgrade_msg = upgrade_msg
self.version = importlib.metadata.version('ksaa')
logger.info('Version: %s', self.version)
logger.info('Python Version: %s', sys.version)
logger.info('Python Executable: %s', sys.executable)
def add_file_logger(self, log_path: str):
log_dir = os.path.abspath(os.path.dirname(log_path))
@ -70,7 +73,12 @@ class Kaa(KotoneBot):
root_logger.addHandler(file_handler)
def set_log_level(self, level: int):
console_handler.setLevel(level)
handlers = logging.getLogger().handlers
if len(handlers) == 0:
print('Warning: No default handler found.')
else:
# 第一个 handler 是默认的 StreamHandler
handlers[0].setLevel(level)
def dump_error_report(
self,
@ -128,7 +136,8 @@ class Kaa(KotoneBot):
config_path=self.config_path,
config_type=self.config_type,
target_device=d,
target_screenshot_interval=target_screenshot_interval
target_screenshot_interval=target_screenshot_interval,
force=True # 强制重新初始化,用于配置热重载
)
@override

View File

@ -3,7 +3,7 @@ import logging
from kotonebot.backend.loop import Loop
from kotonebot.kaa.tasks import R
from kotonebot.kaa.common import conf
from kotonebot.kaa.config import conf
from ..actions.scenes import at_home, goto_home
from kotonebot import task, device, image, color

View File

@ -2,7 +2,7 @@
import logging
from kotonebot.kaa.tasks import R
from kotonebot.kaa.common import conf
from kotonebot.kaa.config import conf
from ..actions.scenes import at_home, goto_home
from kotonebot import device, image, task, color, rect_expand, sleep

View File

@ -4,7 +4,7 @@ from typing import Literal
from datetime import timedelta
from kotonebot.kaa.tasks import R
from kotonebot.kaa.common import conf
from kotonebot.kaa.config import conf
from ..actions.scenes import at_home, goto_home
from kotonebot import task, device, image, action, ocr, contains, cropped, rect_expand, color, sleep, regex

View File

@ -2,7 +2,7 @@
import logging
from kotonebot.kaa.tasks import R
from kotonebot.kaa.common import conf
from kotonebot.kaa.config import conf
from kotonebot.kaa.game_ui.scrollable import Scrollable
from ..actions.scenes import at_home, goto_home
from kotonebot.backend.image import TemplateMatchResult

View File

@ -2,7 +2,7 @@
import logging
from kotonebot.kaa.tasks import R
from kotonebot.kaa.common import conf
from kotonebot.kaa.config import conf
from kotonebot.kaa.game_ui import toolbar_menu
from ..actions.scenes import at_home, goto_home
from kotonebot import task, device, image, sleep, ocr

View File

@ -2,12 +2,15 @@
import logging
from gettext import gettext as _
from kotonebot.errors import StopCurrentTask
from kotonebot.kaa.tasks import R
from kotonebot.kaa.common import conf
from kotonebot.kaa.game_ui import WhiteFilter
from kotonebot.kaa.config import conf
from kotonebot.kaa.game_ui import WhiteFilter, dialog
from ..actions.scenes import at_home, goto_home
from ..actions.loading import wait_loading_end
from kotonebot import device, image, ocr, color, action, task, user, rect_expand, sleep, contains, Interval
from kotonebot import device, image, ocr, color, action, task, rect_expand, sleep, contains, Interval
from kotonebot.backend.context.context import vars
from kotonebot.ui import user as ui_user
logger = logging.getLogger(__name__)
@ -70,11 +73,39 @@ def handle_challenge() -> bool:
# 记忆未编成 [screenshots/contest/no_memo.png]
if image.find(R.Daily.TextContestNoMemory):
logger.debug('Memory not set. Using auto-compilation.')
user.warning('竞赛未编成', _('记忆未编成。将使用自动编成。'), once=True)
if image.find(R.Daily.ButtonContestChallenge):
device.click()
return True
logger.debug('Memory not set.')
when_no_set = conf().contest.when_no_set
auto_compilation = False
match when_no_set:
case 'remind':
# 关闭编成提示弹窗
dialog.expect_no(msg='Closed memory not set dialog.')
ui_user.warning('竞赛未编成', '已跳过此次竞赛任务。')
logger.info('Contest skipped due to memory not set (remind mode).')
raise StopCurrentTask
case 'wait':
dialog.expect_no(msg='Closed memory not set dialog.')
ui_user.warning('竞赛未编成', '已自动暂停,请手动编成后返回至挑战开始页,并点击网页上「恢复」按钮或使用快捷键继续执行。')
vars.flow.request_pause(wait_resume=True)
logger.info('Contest paused due to memory not set (wait mode).')
return True
case 'auto_set' | 'auto_set_silent':
if when_no_set == 'auto_set':
ui_user.warning('竞赛未编成', '将使用自动编成。', once=True)
logger.debug('Using auto-compilation with notification.')
else: # auto_set_silent
logger.debug('Using auto-compilation silently.')
auto_compilation = True
case _:
logger.warning(f'Unknown value for contest.when_no_set: {when_no_set}, fallback to auto.')
logger.debug('Using auto-compilation silently.')
auto_compilation = True
if auto_compilation:
if image.find(R.Daily.ButtonContestChallenge):
device.click()
return True
# 勾选跳过所有
# [screenshots/contest/contest2.png]
@ -85,7 +116,7 @@ def handle_challenge() -> bool:
# 跳过所有
# [screenshots/contest/contest1.png]
if image.find(R.Daily.ButtonIconSkip, preprocessors=[WhiteFilter()]):
if image.find(R.Daily.ButtonIconSkip, preprocessors=[WhiteFilter()], threshold=0.7):
logger.debug('Skipping all.')
device.click()
return True

View File

@ -4,7 +4,7 @@ import logging
from kotonebot.kaa.tasks import R
from kotonebot.primitives import Rect
from kotonebot.kaa.common import conf, Priority
from kotonebot.kaa.config import conf, Priority
from ..actions.loading import wait_loading_end
from ..actions.scenes import at_home, goto_home
from kotonebot import device, image, color, task, action, rect_expand, sleep

View File

@ -4,11 +4,10 @@ from typing import Optional
from kotonebot.backend.loop import Loop
from kotonebot.kaa.tasks import R
from kotonebot.kaa.common import conf, DailyMoneyShopItems
from kotonebot.kaa.config import conf, DailyMoneyShopItems
from kotonebot.primitives.geometry import Point
from kotonebot.util import Countdown, cropped
from kotonebot import task, device, image, action, sleep
from kotonebot.backend.dispatch import SimpleDispatcher
from ..actions.scenes import goto_home, goto_shop, at_daily_shop
logger = logging.getLogger(__name__)
@ -75,8 +74,9 @@ def dispatch_recommended_items():
device.screenshot()
if rec := image.find(R.Daily.TextShopRecommended):
logger.info(f'Clicking on recommended item.') # TODO: 计数
device.click()
confirm_purchase(rec.position)
pos = rec.position.offset(dx=0, dy=80)
device.click(pos)
confirm_purchase(pos)
sleep(2.5) #
elif image.find(R.Daily.IconTitleDailyShop) and not image.find(R.Daily.TextShopRecommended):
logger.info(f'No recommended item found. Finished.')

View File

@ -2,7 +2,7 @@
import logging
from kotonebot.kaa.tasks import R
from kotonebot.kaa.common import conf
from kotonebot.kaa.config import conf
from kotonebot.kaa.game_ui.scrollable import Scrollable
from ..actions.scenes import at_home, goto_home
from kotonebot import task, device, image, sleep

View File

@ -5,9 +5,10 @@ import logging
import _thread
import threading
from kotonebot.backend.bot import PostTaskContext
from kotonebot.ui import user
from ..kaa_context import instance
from kotonebot.kaa.common import Priority, conf
from kotonebot.kaa.config import Priority, conf
from kotonebot import task, action, config, device
logger = logging.getLogger(__name__)
@ -35,8 +36,8 @@ def windows_close():
os.system('taskkill /f /im gakumas.exe')
logger.info("Game closed successfully")
@task('关闭游戏', priority=Priority.END_GAME)
def end_game():
@task('关闭游戏', priority=Priority.END_GAME, run_at='post')
def end_game(ctx: PostTaskContext):
"""
游戏结束时执行的任务
"""
@ -101,4 +102,4 @@ if __name__ == '__main__':
conf().end_game.kill_game = True
conf().end_game.kill_dmm = True
conf().end_game.kill_emulator = True
end_game()
end_game(PostTaskContext(False, None))

View File

@ -7,7 +7,7 @@ import numpy as np
from cv2.typing import MatLike
from kotonebot.kaa.tasks import R
from kotonebot.kaa.common import conf
from kotonebot.kaa.config import conf
from kotonebot.kaa.game_ui import dialog
from kotonebot.kaa.util.trace import trace
from kotonebot.primitives import RectTuple, Rect

View File

@ -9,11 +9,12 @@ 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
from kotonebot.util import measure_time
from kotonebot.kaa.common import conf
from kotonebot.kaa.config import conf
from kotonebot.kaa.tasks.actions.loading import loading
from kotonebot.kaa.game_ui import CommuEventButtonUI, dialog, badge
from kotonebot.kaa.tasks.actions.commu import handle_unread_commu
@ -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
@ -11,8 +12,8 @@ from .cards import do_cards, CardDetectResult
from ..actions.commu import handle_unread_commu
from kotonebot.errors import UnrecoverableError
from kotonebot.util import Countdown, Interval, cropped
from kotonebot.backend.dispatch import DispatcherContext
from kotonebot.kaa.common import ProduceAction, RecommendCardDetectionMode, conf
from kotonebot.backend.loop import Loop
from kotonebot.kaa.config import ProduceAction, RecommendCardDetectionMode
from ..produce.common import until_acquisition_clear, commu_event, fast_acquisitions
from kotonebot import ocr, device, contains, image, regex, action, sleep, wait
from ..produce.non_lesson_actions import (
@ -192,11 +193,11 @@ 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
and len(list(filter(lambda x: x >= 0.05, border_scores))) >= 3
result.score >= 0.043
and len(list(filter(lambda x: x >= 0.04, border_scores))) >= 3
)
else:
return result.score >= 0.03
@ -224,7 +225,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):
# 卡片数量小于三时无遮挡,以及最后一张卡片也总是无遮挡
@ -253,7 +254,7 @@ def exam(type: Literal['mid', 'final']):
if result.type == 10: # SKIP
return total(0.4) and borders(0.02)
else:
return total(0.2) and borders(0.02)
return total(0.15) and borders(0.02)
else:
return total(0.10) and borders(0.01)
@ -422,7 +423,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:
@ -506,12 +507,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):
@ -539,7 +540,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):
@ -703,8 +704,8 @@ ProduceStage = Literal[
'unknown', # 未知场景
]
@action('检测当前培育场景', dispatcher=True)
def detect_produce_scene(ctx: DispatcherContext) -> ProduceStage:
@action('检测当前培育场景')
def detect_produce_scene() -> ProduceStage:
"""
判断当前是培育的什么阶段并开始 Regular 培育
@ -713,31 +714,33 @@ def detect_produce_scene(ctx: DispatcherContext) -> ProduceStage:
"""
logger.info("Detecting current produce stage...")
# 行动场景
texts = ocr.ocr()
if (
image.find_multi([
R.InPurodyuusu.TextPDiary, # 普通周
R.InPurodyuusu.ButtonFinalPracticeDance # 离考试剩余一周
])
):
logger.info("Detection result: At action scene.")
ctx.finish()
return 'action'
elif texts.where(regex('CLEARまで|PERFECTまで')):
logger.info("Detection result: At practice ongoing.")
ctx.finish()
return 'practice-ongoing'
elif is_exam_scene():
logger.info("Detection result: At exam scene.")
ctx.finish()
return 'exam-ongoing'
else:
if fast_acquisitions():
return 'unknown'
if commu_event():
return 'unknown'
return 'unknown'
for _ in Loop():
# 行动场景
texts = ocr.ocr()
if (
image.find_multi([
R.InPurodyuusu.TextPDiary, # 普通周
R.InPurodyuusu.ButtonFinalPracticeDance # 离考试剩余一周
])
):
logger.info("Detection result: At action scene.")
return 'action'
elif texts.where(regex('CLEARまで|PERFECTまで')):
logger.info("Detection result: At practice ongoing.")
return 'practice-ongoing'
elif is_exam_scene():
logger.info("Detection result: At exam scene.")
return 'exam-ongoing'
else:
if fast_acquisitions():
# 继续循环检测
pass
elif commu_event():
# 继续循环检测
pass
# 如果没有返回,说明需要继续检测
sleep(0.5) # 等待一段时间再重新检测
return 'unknown'
@action('开始 Hajime 培育')
def hajime_from_stage(stage: ProduceStage, type: Literal['regular', 'pro', 'master'], week: int):

View File

@ -5,10 +5,11 @@
"""
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
from kotonebot.kaa.common import conf
from kotonebot.kaa.config import conf
from ..produce.common import fast_acquisitions
from kotonebot.kaa.game_ui.commu_event_buttons import CommuEventButtonUI
from kotonebot.util import Countdown, Interval
@ -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

@ -1,11 +1,11 @@
import logging
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.common import conf
from kotonebot.kaa.config import conf
from kotonebot.kaa.game_ui import dialog
from ..actions.scenes import at_home, goto_home
from kotonebot.backend.loop import Loop, StatedLoop
@ -15,6 +15,7 @@ from kotonebot.kaa.game_ui.idols_overview import locate_idol, match_idol
from ..produce.in_purodyuusu import hajime_pro, hajime_regular, hajime_master, resume_pro_produce, resume_regular_produce, \
resume_master_produce
from kotonebot import device, image, ocr, task, action, sleep, contains, regex
from kotonebot.kaa.errors import IdolCardNotFoundError
logger = logging.getLogger(__name__)
@ -58,7 +59,7 @@ def select_idol(skin_id: str):
# 选择偶像
pos = locate_idol(skin_id)
if pos is None:
raise ValueError(f"Idol {skin_id} not found.")
raise IdolCardNotFoundError(skin_id)
# 确认
it.reset()
while btn_confirm := image.find(R.Common.ButtonConfirmNoIcon):
@ -150,7 +151,7 @@ def resume_produce():
max_retries = 5
current_week = None
while retry_count < max_retries:
week_text = ocr.ocr(R.Produce.BoxResumeDialogWeeks).squash().regex(r'\d+/\d+')
week_text = ocr.ocr(R.Produce.BoxResumeDialogWeeks, lang='en').squash().regex(r'\d+/\d+')
if week_text:
weeks = week_text[0].split('/')
logger.info(f'Current week: {weeks[0]}/{weeks[1]}')
@ -191,7 +192,7 @@ def do_produce(
前置条件可导航至首页的任意页面\n
结束状态游戏首页\n
:param memory_set_index: 回忆编成编号
:param idol_skin_id: 要培育的偶像如果为 None则使用配置文件中的偶像
:param mode: 培育模式
@ -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)
@ -389,28 +390,33 @@ def produce():
return
import time
count = conf().produce.produce_count
idols = conf().produce.idols
memory_sets = conf().produce.memory_sets
mode = conf().produce.mode
idol = produce_solution().data.idol
memory_set = produce_solution().data.memory_set
support_card_set = produce_solution().data.support_card_set
mode = produce_solution().data.mode
# 数据验证
if count < 0:
user.warning('配置有误', '培育次数不能小于 0。将跳过本次培育。')
return
if idol is None:
user.warning('配置有误', '未设置要培育的偶像。将跳过本次培育。')
return
idol_iterator = cycle(idols)
memory_set_iterator = cycle(memory_sets)
for i in range(count):
start_time = time.time()
idol = next(idol_iterator)
if conf().produce.auto_set_memory:
memory_set = None
if produce_solution().data.auto_set_memory:
memory_set_to_use = None
else:
memory_set = next(memory_set_iterator, None)
memory_set_to_use = memory_set
if produce_solution().data.auto_set_support_card:
support_card_set_to_use = None
else:
support_card_set_to_use = support_card_set
logger.info(
f'Produce start with: '
f'idol: {idol}, mode: {mode}, memory_set: #{memory_set}'
f'idol: {idol}, mode: {mode}, memory_set: #{memory_set_to_use}, support_card_set: #{support_card_set_to_use}'
)
if not do_produce(idol, mode, memory_set):
if not do_produce(idol, mode, memory_set_to_use):
user.info('AP 不足', f'由于 AP 不足,跳过了 {count - i} 次培育。')
logger.info('%d produce(s) skipped because of insufficient AP.', count - i)
break
@ -427,11 +433,11 @@ if __name__ == '__main__':
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.mode = 'pro'
# produce_solution().data.idol = 'i_card-skin-hski-3-002'
produce_solution().data.memory_set = 1
produce_solution().data.auto_set_memory = False
# do_produce(PIdol.月村手毬_初声, 'pro', 5)
produce()
# a()

View File

@ -5,13 +5,14 @@ import ctypes
import logging
from kotonebot.kaa.tasks import R
from kotonebot.kaa.common import Priority, conf
from kotonebot.kaa.config import Priority, conf
from .actions.loading import loading
from kotonebot.util import Countdown, Interval
from .actions.scenes import at_home, goto_home
from .actions.commu import handle_unread_commu
from kotonebot.errors import GameUpdateNeededError
from kotonebot import task, action, sleep, device, image, ocr, config
from kotonebot.backend.context.context import vars
logger = logging.getLogger(__name__)
@ -170,6 +171,7 @@ def windows_launch():
# 等待游戏窗口出现
it = Interval()
while True:
vars.flow.check()
if ahk.find_window(title='gakumas', title_match_mode=3):
logger.debug('Game window found.')
break

View File

@ -4,6 +4,7 @@ import time
import cv2
from cv2.typing import MatLike
from win11toast import toast
from .pushkit import Wxpusher
from .. import logging
@ -25,17 +26,6 @@ def retry(func):
continue
return wrapper
def ask(
question: str,
options: list[str],
*,
timeout: float = -1,
) -> bool:
"""
询问用户
"""
raise NotImplementedError
def _save_local(
title: str,
message: str,
@ -74,6 +64,43 @@ def push(
logger.warning('push remote message failed: %s', e)
_save_local(title, message, images)
def _show_toast(title: str, message: str | None = None, buttons: list[str] | None = None):
"""
统一的 Toast 通知函数
:param title: 通知标题
:param message: 通知消息内容
:param buttons: 按钮列表如果提供则显示带按钮的通知
"""
try:
if buttons:
logger.verbose('showing toast notification with buttons: %s - %s', title, message or '')
toast(title, message or '', buttons=buttons)
else:
# 如果没有 message只显示 title
if message:
logger.verbose('showing toast notification: %s - %s', title, message)
toast(title, message)
else:
logger.verbose('showing toast notification: %s', title)
toast(title)
except Exception as e:
logger.warning('toast notification failed: %s', e)
def ask(
question: str,
options: list[tuple[str, str]],
*,
timeout: float = -1,
) -> str:
"""
询问用户
"""
# 将选项转换为按钮列表
buttons = [option[1] for option in options]
_show_toast("琴音小助手询问", question, buttons=buttons)
raise NotImplementedError
def info(
title: str,
message: str | None = None,
@ -83,6 +110,7 @@ def info(
):
logger.info('user.info: %s', message)
push('KAA' + title, message, images=images)
_show_toast('KAA' + title, message)
def warning(
title: str,
@ -98,7 +126,8 @@ def warning(
:param once: 每次运行是否只显示一次
"""
logger.warning('user.warning: %s', message)
push("KAA 警告:" + title, message, images=images)
push("琴音小助手警告:" + title, message, images=images)
_show_toast("琴音小助手警告:" + title, message)
def error(
title: str,
@ -111,4 +140,5 @@ def error(
错误信息
"""
logger.error('user.error: %s', message)
push("KAA 错误:" + title, message, images=images)
push("琴音小助手错误:" + title, message, images=images)
_show_toast("琴音小助手错误:" + title, message)

View File

@ -41,6 +41,7 @@ dependencies = [
# TODO: move these dependencies to optional-dependencies
"pywin32==310",
"ahk==1.8.3",
"win11toast==0.35", # For Windows Toast Notification
]

View File

@ -1,4 +1,5 @@
-r requirements.txt
pywin32==310
ahk==1.8.3
ahk==1.8.3
win11toast==0.35

View File

@ -0,0 +1,570 @@
import os
import json
import tempfile
import shutil
import uuid
from unittest import TestCase
from kotonebot.kaa.config.produce import (
ProduceData,
ProduceSolution,
ProduceSolutionManager
)
from kotonebot.kaa.config.const import ProduceAction, RecommendCardDetectionMode
from kotonebot.kaa.errors import ProduceSolutionNotFoundError
class TestProduceData(TestCase):
def test_produce_data_field_validation(self):
"""测试字段验证"""
# 测试有效的 mode 值
for mode in ['regular', 'pro', 'master']:
data = ProduceData(mode=mode) # type: ignore[arg-type]
self.assertEqual(data.mode, mode)
# 测试有效的 self_study_lesson 值
for lesson in ['dance', 'visual', 'vocal']:
data = ProduceData(self_study_lesson=lesson) # type: ignore[arg-type]
self.assertEqual(data.self_study_lesson, lesson)
def test_produce_data_serialization(self):
"""测试序列化和反序列化"""
# 创建测试数据
data = ProduceData(
mode='pro',
idol='test_idol_123',
memory_set=2,
support_card_set=3,
auto_set_memory=True,
auto_set_support_card=True,
use_pt_boost=True,
use_note_boost=True,
follow_producer=True,
self_study_lesson='vocal',
prefer_lesson_ap=True,
actions_order=[ProduceAction.DANCE, ProduceAction.VOCAL],
recommend_card_detection_mode=RecommendCardDetectionMode.STRICT,
use_ap_drink=True,
skip_commu=False
)
# 序列化
json_data = data.model_dump(mode='json')
# 反序列化
restored_data = ProduceData.model_validate(json_data)
# 验证数据一致性
self.assertEqual(restored_data.mode, 'pro')
self.assertEqual(restored_data.idol, 'test_idol_123')
self.assertEqual(restored_data.memory_set, 2)
self.assertEqual(restored_data.support_card_set, 3)
self.assertTrue(restored_data.auto_set_memory)
self.assertTrue(restored_data.auto_set_support_card)
self.assertTrue(restored_data.use_pt_boost)
self.assertTrue(restored_data.use_note_boost)
self.assertTrue(restored_data.follow_producer)
self.assertEqual(restored_data.self_study_lesson, 'vocal')
self.assertTrue(restored_data.prefer_lesson_ap)
self.assertEqual(restored_data.actions_order, [ProduceAction.DANCE, ProduceAction.VOCAL])
self.assertEqual(restored_data.recommend_card_detection_mode, RecommendCardDetectionMode.STRICT)
self.assertTrue(restored_data.use_ap_drink)
self.assertFalse(restored_data.skip_commu)
class TestProduceSolution(TestCase):
"""测试 ProduceSolution 类"""
def test_produce_solution_creation(self):
"""测试创建培育方案"""
data = ProduceData(mode='pro', idol='test_idol')
solution = ProduceSolution(
id='test_id_123',
name='测试方案',
description='这是一个测试方案',
data=data
)
self.assertEqual(solution.type, 'produce_solution')
self.assertEqual(solution.id, 'test_id_123')
self.assertEqual(solution.name, '测试方案')
self.assertEqual(solution.description, '这是一个测试方案')
self.assertEqual(solution.data.mode, 'pro')
self.assertEqual(solution.data.idol, 'test_idol')
def test_produce_solution_validation(self):
"""测试字段验证"""
data = ProduceData()
# 测试必需字段
solution = ProduceSolution(
id='test_id',
name='测试方案',
data=data
)
self.assertEqual(solution.id, 'test_id')
self.assertEqual(solution.name, '测试方案')
self.assertIsNone(solution.description)
def test_produce_solution_serialization(self):
"""测试序列化和反序列化"""
data = ProduceData(mode='master', idol='test_idol_456')
solution = ProduceSolution(
id='test_id_456',
name='高级测试方案',
description='这是一个高级测试方案',
data=data
)
# 序列化
json_data = solution.model_dump(mode='json')
# 反序列化
restored_solution = ProduceSolution.model_validate(json_data)
# 验证数据一致性
self.assertEqual(restored_solution.type, 'produce_solution')
self.assertEqual(restored_solution.id, 'test_id_456')
self.assertEqual(restored_solution.name, '高级测试方案')
self.assertEqual(restored_solution.description, '这是一个高级测试方案')
self.assertEqual(restored_solution.data.mode, 'master')
self.assertEqual(restored_solution.data.idol, 'test_idol_456')
class TestProduceSolutionManager(TestCase):
"""测试 ProduceSolutionManager 类"""
def setUp(self):
"""设置测试环境"""
# 创建临时目录
self.temp_dir = tempfile.mkdtemp()
self.original_solutions_dir = ProduceSolutionManager.SOLUTIONS_DIR
ProduceSolutionManager.SOLUTIONS_DIR = os.path.join(self.temp_dir, "test_solutions") # type: ignore[assignment]
self.manager = ProduceSolutionManager()
def tearDown(self):
"""清理测试环境"""
# 恢复原始目录设置
ProduceSolutionManager.SOLUTIONS_DIR = self.original_solutions_dir # type: ignore[assignment]
# 删除临时目录
shutil.rmtree(self.temp_dir)
def test_manager_init(self):
"""测试管理器初始化和目录创建"""
# 验证目录已创建
self.assertTrue(os.path.exists(self.manager.SOLUTIONS_DIR))
self.assertTrue(os.path.isdir(self.manager.SOLUTIONS_DIR))
def test_sanitize_filename(self):
"""测试文件名清理功能"""
test_cases = [
('正常文件名', '正常文件名'),
('包含\\斜杠/的:文件*名?', '包含_斜杠_的_文件_名_'),
('包含"引号"和<尖括号>', '包含_引号_和_尖括号_'),
('包含|管道符', '包含_管道符'),
('', ''),
]
for input_name, expected_output in test_cases:
with self.subTest(input_name=input_name):
result = self.manager._sanitize_filename(input_name)
self.assertEqual(result, expected_output)
def test_get_file_path(self):
"""测试根据名称获取文件路径"""
name = '测试方案'
expected_path = os.path.join(self.manager.SOLUTIONS_DIR, '测试方案.json')
result = self.manager._get_file_path(name)
self.assertEqual(result, expected_path)
# 测试特殊字符处理
name_with_special = '测试/方案:名称'
expected_path_special = os.path.join(self.manager.SOLUTIONS_DIR, '测试_方案_名称.json')
result_special = self.manager._get_file_path(name_with_special)
self.assertEqual(result_special, expected_path_special)
def test_find_file_path_by_id(self):
"""测试根据ID查找文件路径"""
# 创建测试文件
test_id = 'test_id_123'
solution = ProduceSolution(
id=test_id,
name='测试方案',
data=ProduceData()
)
# 保存文件
file_path = self.manager._get_file_path(solution.name)
with open(file_path, 'w', encoding='utf-8') as f:
json.dump(solution.model_dump(mode='json'), f, ensure_ascii=False, indent=4)
# 测试查找
found_path = self.manager._find_file_path_by_id(test_id)
self.assertEqual(found_path, file_path)
# 测试查找不存在的ID
not_found_path = self.manager._find_file_path_by_id('nonexistent_id')
self.assertIsNone(not_found_path)
def test_new_solution(self):
"""测试创建新方案"""
name = '新测试方案'
solution = self.manager.new(name)
self.assertEqual(solution.name, name)
self.assertEqual(solution.type, 'produce_solution')
self.assertIsNotNone(solution.id)
self.assertIsNone(solution.description)
self.assertIsInstance(solution.data, ProduceData)
# 验证ID是有效的UUID
try:
uuid.UUID(solution.id)
except ValueError:
self.fail("Generated ID is not a valid UUID")
def test_list_solutions_empty(self):
"""测试空目录时列出方案"""
solutions = self.manager.list()
self.assertEqual(solutions, [])
def test_list_solutions_with_files(self):
"""测试有文件时列出方案"""
# 创建测试方案
solution1 = ProduceSolution(
id='id1',
name='方案1',
data=ProduceData(mode='regular')
)
solution2 = ProduceSolution(
id='id2',
name='方案2',
data=ProduceData(mode='pro')
)
# 保存文件
for solution in [solution1, solution2]:
file_path = self.manager._get_file_path(solution.name)
with open(file_path, 'w', encoding='utf-8') as f:
json.dump(solution.model_dump(mode='json'), f, ensure_ascii=False, indent=4)
# 列出方案
solutions = self.manager.list()
self.assertEqual(len(solutions), 2)
# 验证方案内容(顺序可能不同)
solution_ids = {s.id for s in solutions}
self.assertEqual(solution_ids, {'id1', 'id2'})
def test_list_solutions_with_invalid_files(self):
"""测试包含无效文件时列出方案"""
# 创建有效方案文件
valid_solution = ProduceSolution(
id='valid_id',
name='有效方案',
data=ProduceData()
)
valid_file_path = self.manager._get_file_path(valid_solution.name)
with open(valid_file_path, 'w', encoding='utf-8') as f:
json.dump(valid_solution.model_dump(mode='json'), f, ensure_ascii=False, indent=4)
# 创建无效JSON文件
invalid_file_path = os.path.join(self.manager.SOLUTIONS_DIR, '无效文件.json')
with open(invalid_file_path, 'w', encoding='utf-8') as f:
f.write('invalid json content')
# 创建非JSON文件
non_json_file_path = os.path.join(self.manager.SOLUTIONS_DIR, '非JSON文件.txt')
with open(non_json_file_path, 'w', encoding='utf-8') as f:
f.write('not a json file')
# 列出方案,应该只返回有效的方案
solutions = self.manager.list()
self.assertEqual(len(solutions), 1)
self.assertEqual(solutions[0].id, 'valid_id')
def test_save_solution(self):
"""测试保存方案"""
solution = ProduceSolution(
id='save_test_id',
name='保存测试方案',
description='测试保存功能',
data=ProduceData(mode='master', idol='test_idol')
)
# 保存方案
self.manager.save(solution.id, solution)
# 验证文件已创建
expected_file_path = self.manager._get_file_path(solution.name)
self.assertTrue(os.path.exists(expected_file_path))
# 验证文件内容
with open(expected_file_path, 'r', encoding='utf-8') as f:
saved_data = json.load(f)
self.assertEqual(saved_data['id'], 'save_test_id')
self.assertEqual(saved_data['name'], '保存测试方案')
self.assertEqual(saved_data['description'], '测试保存功能')
self.assertEqual(saved_data['data']['mode'], 'master')
self.assertEqual(saved_data['data']['idol'], 'test_idol')
def test_save_solution_with_name_change(self):
"""测试保存时名称变更的处理"""
solution_id = 'name_change_test_id'
# 创建初始方案
original_solution = ProduceSolution(
id=solution_id,
name='原始名称',
data=ProduceData()
)
self.manager.save(solution_id, original_solution)
original_file_path = self.manager._get_file_path('原始名称')
self.assertTrue(os.path.exists(original_file_path))
# 修改名称并保存
updated_solution = ProduceSolution(
id=solution_id,
name='新名称',
data=ProduceData()
)
self.manager.save(solution_id, updated_solution)
# 验证旧文件已删除,新文件已创建
self.assertFalse(os.path.exists(original_file_path))
new_file_path = self.manager._get_file_path('新名称')
self.assertTrue(os.path.exists(new_file_path))
def test_read_solution(self):
"""测试读取方案"""
# 创建并保存方案
solution = ProduceSolution(
id='read_test_id',
name='读取测试方案',
description='测试读取功能',
data=ProduceData(mode='pro', memory_set=5)
)
self.manager.save(solution.id, solution)
# 读取方案
read_solution = self.manager.read(solution.id)
# 验证读取的数据
self.assertEqual(read_solution.id, 'read_test_id')
self.assertEqual(read_solution.name, '读取测试方案')
self.assertEqual(read_solution.description, '测试读取功能')
self.assertEqual(read_solution.data.mode, 'pro')
self.assertEqual(read_solution.data.memory_set, 5)
def test_read_nonexistent_solution(self):
"""测试读取不存在的方案"""
with self.assertRaises(ProduceSolutionNotFoundError) as context:
self.manager.read('nonexistent_id')
self.assertIn("Solution with id 'nonexistent_id' not found", str(context.exception))
def test_delete_solution(self):
"""测试删除方案"""
# 创建并保存方案
solution = ProduceSolution(
id='delete_test_id',
name='删除测试方案',
data=ProduceData()
)
self.manager.save(solution.id, solution)
# 验证文件存在
file_path = self.manager._get_file_path(solution.name)
self.assertTrue(os.path.exists(file_path))
# 删除方案
self.manager.delete(solution.id)
# 验证文件已删除
self.assertFalse(os.path.exists(file_path))
def test_delete_nonexistent_solution(self):
"""测试删除不存在的方案"""
# 删除不存在的方案不应该抛出异常
try:
self.manager.delete('nonexistent_id')
except Exception as e:
self.fail(f"Deleting nonexistent solution should not raise exception: {e}")
def test_duplicate_solution(self):
"""测试复制方案"""
# 创建原始方案
original_solution = ProduceSolution(
id='original_id',
name='原始方案',
description='原始描述',
data=ProduceData(mode='master', idol='test_idol', memory_set=3)
)
self.manager.save(original_solution.id, original_solution)
# 复制方案
duplicated_solution = self.manager.duplicate(original_solution.id)
# 验证复制的方案
self.assertNotEqual(duplicated_solution.id, original_solution.id)
self.assertEqual(duplicated_solution.name, '原始方案 - 副本')
self.assertEqual(duplicated_solution.description, '原始描述')
self.assertEqual(duplicated_solution.type, 'produce_solution')
# 验证数据深拷贝
self.assertEqual(duplicated_solution.data.mode, 'master')
self.assertEqual(duplicated_solution.data.idol, 'test_idol')
self.assertEqual(duplicated_solution.data.memory_set, 3)
# 验证是深拷贝而不是浅拷贝
self.assertIsNot(duplicated_solution.data, original_solution.data)
# 验证新ID是有效的UUID
try:
uuid.UUID(duplicated_solution.id)
except ValueError:
self.fail("Duplicated solution ID is not a valid UUID")
def test_duplicate_nonexistent_solution(self):
"""测试复制不存在的方案"""
with self.assertRaises(ProduceSolutionNotFoundError):
self.manager.duplicate('nonexistent_id')
def test_corrupted_json_handling(self):
"""测试处理损坏的JSON文件"""
# 创建损坏的JSON文件
corrupted_file_path = os.path.join(self.manager.SOLUTIONS_DIR, '损坏文件.json')
with open(corrupted_file_path, 'w', encoding='utf-8') as f:
f.write('{"id": "corrupted_id", "name": "corrupted", invalid json}')
# list() 方法应该跳过损坏的文件
solutions = self.manager.list()
self.assertEqual(len(solutions), 0)
# _find_file_path_by_id 应该跳过损坏的文件
found_path = self.manager._find_file_path_by_id('corrupted_id')
self.assertIsNone(found_path)
def test_special_characters_in_names(self):
"""测试名称中的特殊字符处理"""
special_names = [
'包含/斜杠的名称',
'包含:冒号的名称',
'包含*星号的名称',
'包含?问号的名称',
'包含"引号的名称',
'包含<尖括号>的名称',
'包含|管道符的名称',
]
for name in special_names:
with self.subTest(name=name):
# 创建方案
solution = ProduceSolution(
id=f'special_id_{hash(name)}',
name=name,
data=ProduceData()
)
# 保存方案
self.manager.save(solution.id, solution)
# 验证能够读取
read_solution = self.manager.read(solution.id)
self.assertEqual(read_solution.name, name)
# 验证能够列出
solutions = self.manager.list()
names = [s.name for s in solutions]
self.assertIn(name, names)
def test_full_workflow(self):
"""测试完整的工作流程(创建→保存→读取→修改→删除)"""
# 1. 创建新方案
solution = self.manager.new('工作流程测试')
original_id = solution.id
# 2. 修改方案数据
solution.description = '完整工作流程测试'
solution.data.mode = 'pro'
solution.data.idol = 'workflow_test_idol'
# 3. 保存方案
self.manager.save(solution.id, solution)
# 4. 读取方案
read_solution = self.manager.read(solution.id)
self.assertEqual(read_solution.id, original_id)
self.assertEqual(read_solution.name, '工作流程测试')
self.assertEqual(read_solution.description, '完整工作流程测试')
self.assertEqual(read_solution.data.mode, 'pro')
self.assertEqual(read_solution.data.idol, 'workflow_test_idol')
# 5. 修改方案名称
read_solution.name = '修改后的名称'
self.manager.save(read_solution.id, read_solution)
# 6. 验证修改
modified_solution = self.manager.read(read_solution.id)
self.assertEqual(modified_solution.name, '修改后的名称')
# 7. 复制方案
duplicated = self.manager.duplicate(modified_solution.id)
self.assertNotEqual(duplicated.id, modified_solution.id)
self.assertEqual(duplicated.name, '修改后的名称 - 副本')
# 8. 列出所有方案
all_solutions = self.manager.list()
self.assertEqual(len(all_solutions), 1) # 只有原始方案,复制的方案还没保存
# 9. 保存复制的方案
self.manager.save(duplicated.id, duplicated)
all_solutions = self.manager.list()
self.assertEqual(len(all_solutions), 2)
# 10. 删除原始方案
self.manager.delete(modified_solution.id)
remaining_solutions = self.manager.list()
self.assertEqual(len(remaining_solutions), 1)
self.assertEqual(remaining_solutions[0].id, duplicated.id)
# 11. 删除复制的方案
self.manager.delete(duplicated.id)
final_solutions = self.manager.list()
self.assertEqual(len(final_solutions), 0)
def test_concurrent_operations(self):
"""测试并发操作的安全性"""
# 这个测试主要验证基本的文件操作不会相互干扰
solutions = []
# 创建多个方案
for i in range(5):
solution = self.manager.new(f'并发测试方案{i}')
solution.data.mode = 'pro' if i % 2 == 0 else 'regular'
solutions.append(solution)
# 同时保存所有方案
for solution in solutions:
self.manager.save(solution.id, solution)
# 验证所有方案都已保存
saved_solutions = self.manager.list()
self.assertEqual(len(saved_solutions), 5)
# 验证每个方案的数据完整性
for original in solutions:
read_solution = self.manager.read(original.id)
self.assertEqual(read_solution.name, original.name)
self.assertEqual(read_solution.data.mode, original.data.mode)
# 同时删除所有方案
for solution in solutions:
self.manager.delete(solution.id)
# 验证所有方案都已删除
remaining_solutions = self.manager.list()
self.assertEqual(len(remaining_solutions), 0)

View File

View File

@ -0,0 +1,200 @@
"""测试 v5 到 v6 的配置迁移脚本"""
import unittest
import tempfile
import os
import json
import shutil
from typing import Any
from kotonebot.kaa.config.migrations._v5_to_v6 import migrate
class TestMigrationV5ToV6(unittest.TestCase):
"""测试 v5 到 v6 的配置迁移"""
def setUp(self):
"""设置测试环境"""
# 创建临时目录
self.temp_dir = tempfile.mkdtemp()
self.original_cwd = os.getcwd()
os.chdir(self.temp_dir)
def tearDown(self):
"""清理测试环境"""
os.chdir(self.original_cwd)
shutil.rmtree(self.temp_dir)
def test_migrate_empty_config(self):
"""测试空配置的迁移"""
user_config = {}
result = migrate(user_config)
self.assertIsNone(result)
def test_migrate_no_options(self):
"""测试没有 options 的配置"""
user_config = {"backend": {"type": "mumu12"}}
result = migrate(user_config)
self.assertIsNone(result)
def test_migrate_no_produce_config(self):
"""测试没有 produce 配置的情况"""
user_config = {"options": {"purchase": {"enabled": False}}}
result = migrate(user_config)
self.assertIsNone(result)
def test_migrate_already_v6_format(self):
"""测试已经是 v6 格式的配置"""
user_config = {
"options": {
"produce": {
"enabled": True,
"selected_solution_id": "test-id",
"produce_count": 1
}
}
}
result = migrate(user_config)
self.assertIsNone(result)
def test_migrate_v5_to_v6_basic(self):
"""测试基本的 v5 到 v6 迁移"""
# 创建 v5 格式的配置
old_produce_config = {
"enabled": True,
"mode": "pro",
"produce_count": 3,
"idols": ["i_card-skin-fktn-3-000"],
"memory_sets": [1],
"support_card_sets": [2],
"auto_set_memory": False,
"auto_set_support_card": True,
"use_pt_boost": True,
"use_note_boost": False,
"follow_producer": True,
"self_study_lesson": "vocal",
"prefer_lesson_ap": True,
"actions_order": ["recommended", "visual", "vocal"],
"recommend_card_detection_mode": "strict",
"use_ap_drink": True,
"skip_commu": False
}
user_config = {"options": {"produce": old_produce_config}}
# 执行迁移
result = migrate(user_config)
# 验证结果
self.assertIsNotNone(result)
assert result is not None # make pylance happy
self.assertIn("已将培育配置迁移到新的方案系统", result)
# 验证新配置格式
new_produce_config = user_config["options"]["produce"]
self.assertEqual(new_produce_config["enabled"], True)
self.assertEqual(new_produce_config["produce_count"], 3)
self.assertIsNotNone(new_produce_config["selected_solution_id"])
# 验证方案文件是否创建
solutions_dir = "conf/produce"
self.assertTrue(os.path.exists(solutions_dir))
# 查找创建的方案文件
solution_files = [f for f in os.listdir(solutions_dir) if f.endswith('.json')]
self.assertEqual(len(solution_files), 1)
# 验证方案文件内容
solution_file = os.path.join(solutions_dir, solution_files[0])
with open(solution_file, 'r', encoding='utf-8') as f:
solution_data = json.load(f)
self.assertEqual(solution_data["type"], "produce_solution")
self.assertEqual(solution_data["name"], "默认方案")
self.assertEqual(solution_data["description"], "从旧配置迁移的默认培育方案")
# 验证培育数据
produce_data = solution_data["data"]
self.assertEqual(produce_data["mode"], "pro")
self.assertEqual(produce_data["idol"], "i_card-skin-fktn-3-000")
self.assertEqual(produce_data["memory_set"], 1)
self.assertEqual(produce_data["support_card_set"], 2)
self.assertEqual(produce_data["auto_set_memory"], False)
self.assertEqual(produce_data["auto_set_support_card"], True)
self.assertEqual(produce_data["use_pt_boost"], True)
self.assertEqual(produce_data["use_note_boost"], False)
self.assertEqual(produce_data["follow_producer"], True)
self.assertEqual(produce_data["self_study_lesson"], "vocal")
self.assertEqual(produce_data["prefer_lesson_ap"], True)
self.assertEqual(produce_data["actions_order"], ["recommended", "visual", "vocal"])
self.assertEqual(produce_data["recommend_card_detection_mode"], "strict")
self.assertEqual(produce_data["use_ap_drink"], True)
self.assertEqual(produce_data["skip_commu"], False)
def test_migrate_v5_to_v6_with_defaults(self):
"""测试使用默认值的 v5 到 v6 迁移"""
# 创建最小的 v5 格式配置
old_produce_config = {"enabled": False}
user_config = {"options": {"produce": old_produce_config}}
# 执行迁移
result = migrate(user_config)
# 验证结果
self.assertIsNotNone(result)
# 验证新配置格式
new_produce_config = user_config["options"]["produce"]
self.assertEqual(new_produce_config["enabled"], False)
self.assertEqual(new_produce_config["produce_count"], 1)
self.assertIsNotNone(new_produce_config["selected_solution_id"])
# 验证方案文件内容使用了默认值
solutions_dir = "conf/produce"
solution_files = [f for f in os.listdir(solutions_dir) if f.endswith('.json')]
solution_file = os.path.join(solutions_dir, solution_files[0])
with open(solution_file, 'r', encoding='utf-8') as f:
solution_data = json.load(f)
produce_data = solution_data["data"]
self.assertEqual(produce_data["mode"], "regular")
self.assertIsNone(produce_data["idol"])
self.assertIsNone(produce_data["memory_set"])
self.assertIsNone(produce_data["support_card_set"])
self.assertEqual(produce_data["auto_set_memory"], False)
self.assertEqual(produce_data["auto_set_support_card"], False)
self.assertEqual(produce_data["self_study_lesson"], "dance")
self.assertEqual(produce_data["skip_commu"], True)
def test_migrate_v5_to_v6_multiple_idols_memory_support(self):
"""测试多个偶像、回忆、支援卡的迁移(只取第一个)"""
old_produce_config = {
"enabled": True,
"idols": ["idol1", "idol2", "idol3"],
"memory_sets": [1, 2, 3],
"support_card_sets": [4, 5, 6]
}
user_config = {"options": {"produce": old_produce_config}}
# 执行迁移
result = migrate(user_config)
# 验证结果
self.assertIsNotNone(result)
# 验证方案文件内容只使用了第一个值
solutions_dir = "conf/produce"
solution_files = [f for f in os.listdir(solutions_dir) if f.endswith('.json')]
solution_file = os.path.join(solutions_dir, solution_files[0])
with open(solution_file, 'r', encoding='utf-8') as f:
solution_data = json.load(f)
produce_data = solution_data["data"]
self.assertEqual(produce_data["idol"], "idol1")
self.assertEqual(produce_data["memory_set"], 1)
self.assertEqual(produce_data["support_card_set"], 4)
if __name__ == '__main__':
unittest.main()