Compare commits

...

95 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
XcantloadX 7ce4b17fb2 docs: v2025.7.7.0 更新日志 2025-07-07 10:08:58 +08:00
XcantloadX c6b52a599f Merge branch 'dev' 2025-07-07 10:02:47 +08:00
XcantloadX b8b56bbf4c fix(task): 修复 AP 商店购买时点击坐标偏移问题 2025-07-07 09:21:05 +08:00
XcantloadX 353fa3fcb2 fix(task): 修复商店购买时无法识别部分偶像碎片的问题 2025-07-06 20:51:33 +08:00
XcantloadX 03aa2b508c chore: 删除一些无用 Sprite 资源 2025-07-06 20:30:32 +08:00
XcantloadX 9b37bcf541 feat(ui): UI 支持向局域网开放 2025-07-06 20:28:42 +08:00
XcantloadX 68dbc487e8 feat(task): 金币商店可选使用每日刷新次数 2025-07-06 19:38:10 +08:00
XcantloadX f5a4e50611 fix(task): 修复 DMM 版购买时无法进入 AP 商店的问题
原因是分辨率缩放导致识别结果 confidence 下降,导致没有识别出来。
Fixed #40
2025-07-06 17:33:14 +08:00
XcantloadX cf1605d913 fix(task): 修复商店购买有几率卡在确认购买对话框上 2025-07-06 17:30:10 +08:00
XcantloadX 3b3aac65dc fix(task): 修复某些情况下无法自动关闭领取活动费后的弹窗 2025-07-06 16:34:27 +08:00
XcantloadX c8fbf80640 feat(core): 新增目标截图间隔功能
可以通过设置目标截图间隔来限制截图速度,间接限制脚本运行速度。
2025-07-05 22:14:13 +08:00
XcantloadX 0e183b0ca6 docs: v2025.7.5.0 更新日志 2025-07-05 17:34:21 +08:00
XcantloadX 8e5fcaf4fc Merge branch 'dev' 2025-07-05 17:29:08 +08:00
XcantloadX 50d1403825 fix(task): 修复有几率无法识别到进行中培育的问题
原因是 OCR 没有识别到“中”字。现在换成了检测再开与新开各自 UI 上独有的元素。

Fixed #52
2025-07-03 18:24:58 +08:00
XcantloadX f2eadad7eb fix(task): 修复分辨率缩放导致无法识别到菜单按钮
Fixed #53
2025-07-03 17:40:58 +08:00
XcantloadX 5306f5c875 refactor(core): 移除 Device.pinned 方法 2025-07-03 17:25:03 +08:00
XcantloadX d9077e74e2 fix(task): 修复分辨率缩放时无法检测到工作完成状态的问题 2025-07-03 17:23:40 +08:00
XcantloadX a6bf0330cd docs: v2025.7.3.0 更新日志 2025-07-03 09:16:04 +08:00
XcantloadX 66ea531ef3 chore: Git 记录提取工具支持 bootstrap scope 2025-07-03 09:15:32 +08:00
XcantloadX b325a20b60 Merge branch 'feat/launcher' 2025-07-03 09:09:58 +08:00
XcantloadX 456019b5b5 feat(bootstrap): 新启动器现在支持安装指定版本与指定补丁 2025-07-02 22:47:03 +08:00
XcantloadX 3f88c3a6c4 feat(bootstrap): 自动更新可禁用 2025-07-02 22:21:08 +08:00
XcantloadX b377b8445e feat(bootstrap): 启动器 C++ EXE 跳板程序 2025-07-02 22:21:08 +08:00
XcantloadX c4b93f40d6 feat(bootstrap): 新启动器 2025-06-30 21:47:36 +08:00
XcantloadX 02860b6014 docs: v2025.6.28.0 更新日志 2025-06-28 17:48:26 +08:00
XcantloadX e8851a683d fix(core): 修复 Device 中缩放与截图 Hook 的处理顺序不正确问题 2025-06-28 17:47:54 +08:00
XcantloadX 9935087753 Merge branch 'dev' 2025-06-28 14:41:29 +08:00
XcantloadX b53a0555e2 fix(task): 修正 debug_entry 脚本路径处理逻辑 2025-06-28 14:40:31 +08:00
XcantloadX 1397587415 chore: HsvRangeTool 支持从剪贴板粘贴图片 2025-06-28 14:36:45 +08:00
XcantloadX ad5c7b700b fix(task): 修复清理日志功能失效的问题 2025-06-27 14:26:24 +08:00
XcantloadX 9574d2073a feat(ui): 优化日志导出功能
新增了标题、描述字段,导出报告支持上传与保存本地
2025-06-27 14:25:56 +08:00
XcantloadX 08a7e71881 feat(ui): 为 UI 增加部分配置有效性验证 2025-06-27 14:01:57 +08:00
XcantloadX a01c37d0fc feat(ui): 加入调试模式警告提示 2025-06-27 13:39:40 +08:00
XcantloadX 2469fc09df feat(ui): 画面 Tab 刷新机制由自动改为手动 2025-06-27 13:31:54 +08:00
XcantloadX 71170e5c0c feat(ui): MuMu12 保活模式可选开启 2025-06-27 11:22:46 +08:00
XcantloadX 87da282530 fix(core): 修复 NemuIpc 在多显示器下的坐标系问题 2025-06-27 11:22:46 +08:00
XcantloadX 0c94acff1b feat(core): NemuIpcImpl 获取显示器 ID 支持自动重试 2025-06-27 11:22:07 +08:00
XcantloadX e2328264a7 feat(core): 支持 MuMu12 后台保活模式 2025-06-27 11:21:38 +08:00
XcantloadX e62e65da4c fix(core): 修复当目标分辨率与实际分辨率旋转不同时截图会强制拉伸的问题 2025-06-25 22:43:09 +08:00
XcantloadX bcd8cf2874 chore(core): 调整部分日志输出格式 2025-06-25 20:37:30 +08:00
XcantloadX d10a098383 Merge branch 'feat/screenshot-nemu-ipc' into dev 2025-06-25 20:24:36 +08:00
XcantloadX 6f31bab85b Merge branch 'feat/scaler' into dev 2025-06-25 20:24:24 +08:00
XcantloadX 677932acb7 fix(core): 缩放处理支持自动识别旋转 2025-06-25 20:21:50 +08:00
XcantloadX 105a894a5c fix(core): 修复 NemuIpc 截图无法响应屏幕旋转导致的分辨率变化 2025-06-25 18:51:06 +08:00
XcantloadX f01e0224cb refactor(core): 组装 Device 改用 recipe 方案
原来组装 Device 的代码放在每个 Impl 文件下实现,通过
@register_impl 装饰器注册组装函数,然后通过统一接口
组装。现在将所有组装代码移动到了 Host 实现下,Impl 实现
只需要实现自身。
2025-06-25 18:49:08 +08:00
XcantloadX 07186787b4 chore: VSCode 新增不初始化 context 的启动配置 2025-06-23 16:26:31 +08:00
XcantloadX 784b8ed291 refactor(core): 将传递的 MuMu 路径从 shell 目录改为 MuMu 根目录 2025-06-23 16:26:31 +08:00
XcantloadX f0b91814f7 feat(core): 引入 Nemu 截图与控制方式 2025-06-23 16:26:31 +08:00
XcantloadX 415a8dfc7d feat(core): 移除 WindowsImpl 中的分辨率缩放
因为目前在 Device 类中已有缩放处理。
2025-06-23 00:32:34 +08:00
XcantloadX 3ceae4c359 fix(task): 修复 debug_entry 执行的脚本没有正确输出日志的问题 2025-06-14 20:14:38 +08:00
XcantloadX bb7f6038a2 feat(core): 支持等比例分辨率缩放 2025-06-14 20:14:37 +08:00
131 changed files with 8508 additions and 2006 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/

8
.vscode/launch.json vendored
View File

@ -24,6 +24,14 @@
],
// "module": "${command:extension.commandvariable.file.relativeDirDots}.${fileBasenameNoExtension}",
},
{
"name": "Python: Current Module(Without context)",
"type": "python",
"request": "launch",
"console": "integratedTerminal",
"module": "${command:extension.commandvariable.file.relativeDirDots}.${fileBasenameNoExtension}",
},
{
"name": "KotonebotDebug: Current Module",

View File

@ -20,5 +20,5 @@
"venv",
"**/node_modules"
],
"python.analysis.diagnosticMode": "workspace"
// "python.analysis.diagnosticMode": "workspace"
}

View File

@ -76,6 +76,9 @@ kaa 的开发主要用到了以下开源项目:
](https://github.com/AllenHeartcore/GkmasObjectManager):用于提取游戏图像资源,以 GPLv3 协议开源。
* [gakumasu-diff](https://github.com/vertesan/gakumasu-diff):游戏数据。
kaa 的开发还参考了以下开源项目:
* [EmulatorExtras](https://github.com/MaaXYZ/EmulatorExtras)MuMu 与雷电模拟器的截图与控制接口定义。
* [blue_archive_auto_script](https://github.com/pur1fying/blue_archive_auto_script)MuMu 与雷电模拟器的截图与控制接口的 Python 实现,以及各模拟器的控制实现。
## 免责声明
**请在使用本项目前仔细阅读以下内容。使用本脚本将带来包括但不限于账号被封禁的风险。**

View File

@ -1,5 +1,149 @@
# 更新日志
## 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
* [修复] 修复商店购买时无法识别部分偶像碎片的问题(#353fa3f
* [修复] 修复 DMM 版购买时无法进入 AP 商店的问题(#f5a4e50
* [修复] 修复商店购买有几率卡在确认购买对话框上(#cf1605d
* [修复] 修复某些情况下无法自动关闭领取活动费后的弹窗(#3b3aac6
* [新增] 金币商店可选使用每日刷新次数(#68dbc48
界面:
* [新增] UI 支持向局域网开放(#9b37bcf
框架:
* [新增] 新增目标截图间隔功能(#c8fbf80
其他:
* [其他] 删除一些无用 Sprite 资源(#03aa2b5
### v2025.7.5.0
脚本:
* [修复] 修复有几率无法识别到进行中培育的问题(#50d1403
* [修复] 修复分辨率缩放导致无法识别到菜单按钮(#f2eadad
* [修复] 修复分辨率缩放时无法检测到工作完成状态的问题(#d9077e7
框架:
* [重构] 移除 Device.pinned 方法(#5306f5c
### v2025.7.3.0
启动器:
* [新增] 新启动器现在支持安装指定版本与指定补丁(#456019b
* [新增] 自动更新可禁用(#3f88c3a
* [新增] 启动器 C++ EXE 跳板程序(#b377b84
* [新增] 新启动器(#c4b93f4
其他:
* [其他] Git 记录提取工具支持 bootstrap scope#66ea531
### v2025.6.28.0
脚本:
* [修复] 修正 debug_entry 脚本路径处理逻辑(#b53a055
* [修复] 修复清理日志功能失效的问题(#ad5c7b7
* [修复] 修复 debug_entry 执行的脚本没有正确输出日志的问题(#3ceae4c
界面:
* [新增] 优化日志导出功能(#9574d20
* [新增] 为 UI 增加部分配置有效性验证(#08a7e71
* [新增] 加入调试模式警告提示(#a01c37d
* [新增] 画面 Tab 刷新机制由自动改为手动(#2469fc0
* [新增] MuMu12 保活模式可选开启(#71170e5
框架:
* [修复] 修复 Device 中缩放与截图 Hook 的处理顺序不正确问题
* [修复] 修复 NemuIpc 在多显示器下的坐标系问题(#87da282
* [修复] 修复当目标分辨率与实际分辨率旋转不同时截图会强制拉伸的问题(#e62e65d
* [修复] 缩放处理支持自动识别旋转(#677932a
* [修复] 修复 NemuIpc 截图无法响应屏幕旋转导致的分辨率变化(#105a894
* [新增] NemuIpcImpl 获取显示器 ID 支持自动重试(#0c94acf
* [新增] 支持 MuMu12 后台保活模式(#e232826
* [新增] 引入 Nemu 截图与控制方式(#f0b9181
* [新增] 移除 WindowsImpl 中的分辨率缩放(#415a8df
* [新增] 支持等比例分辨率缩放(#bb7f603
* [重构] 组装 Device 改用 recipe 方案(#f01e022
* [重构] 将传递的 MuMu 路径从 shell 目录改为 MuMu 根目录(#784b8ed
其他:
* [其他] HsvRangeTool 支持从剪贴板粘贴图片(#1397587
* [其他] VSCode 新增不初始化 context 的启动配置(#0718678
* [其他] 调整部分日志输出格式(#bcd8cf2
### v2025.6.23.0
脚本:
* [新增] 优化了培育开始的逻辑,修复若干 bug#86313ec

16
bootstrap/README.md Normal file
View File

@ -0,0 +1,16 @@
# bootstrap
此文件夹下存放的是为了简化分发而编写的启动器代码。
## bootstrap/kaa-bootstrap
启动器本体,由 Python 编写。负责:
1. 寻找最快的 PyPI 镜像源
2. 安装与更新 pip 和 kaa 本体
3. 读入配置文件,检查是否需要管理员权限
4. 启动 kaa
打包产物bootstrap.pyz
## bootstrap/kaa-wrapper
启动器包装器,由 C++ 编写,用于调用 Python 启动 kaa-bootstrap。
打包产物kaa.exe

View File

@ -0,0 +1,7 @@
from terminal import print_status
from launcher import main_launch
try:
main_launch()
except KeyboardInterrupt:
print_status("运行结束", status='info')

View File

@ -0,0 +1,754 @@
import os
import sys
import json
import ctypes
import codecs
import locale
import logging
import subprocess
import importlib.metadata
import argparse
import tempfile
import zipfile
import shutil
from pathlib import Path
from collections import deque
from datetime import datetime
from time import sleep
from typing import Optional, Dict, Any, TypedDict, Literal, List
from request import head, HTTPError, NetworkError
from terminal import (
Color, print_header, print_status, clear_screen,
get_terminal_width, get_display_width, truncate_string,
hide_cursor, show_cursor, move_cursor_up, wait_key, get_terminal_height
)
from repo import Version
# 配置文件的类型定义
class BackendConfig(TypedDict, total=False):
type: Literal['custom', 'mumu12', 'leidian', 'dmm']
screenshot_impl: Literal['adb', 'adb_raw', 'uiautomator2', 'windows', 'remote_windows', 'nemu_ipc']
class MiscConfig(TypedDict, total=False):
check_update: Literal['never', 'startup']
auto_install_update: bool
class UserConfig(TypedDict, total=False):
name: str
id: str
category: str
description: str
backend: BackendConfig
keep_screenshots: bool
options: Dict[str, Any] # 这里包含 misc 等配置
class Config(TypedDict, total=False):
version: int
user_configs: List[UserConfig]
# 获取当前Python解释器路径
PYTHON_EXECUTABLE = sys.executable
TRUSTED_HOSTS = "pypi.org files.pythonhosted.org pypi.python.org mirrors.aliyun.com mirrors.cloud.tencent.com mirrors.tuna.tsinghua.edu.cn"
def setup_logging():
"""
配置日志记录
"""
log_dir = Path("logs")
log_dir.mkdir(exist_ok=True)
timestamp = datetime.now().strftime("%y-%m-%d-%H-%M-%S")
log_file = log_dir / f"bootstrap-{timestamp}.log"
logging.basicConfig(
level=logging.DEBUG,
format='[%(asctime)s][%(levelname)s][%(filename)s:%(lineno)d] %(message)s',
filename=log_file,
filemode='w',
encoding='utf-8'
)
# 记录未捕获的异常
def handle_exception(exc_type, exc_value, exc_traceback):
if issubclass(exc_type, KeyboardInterrupt):
sys.__excepthook__(exc_type, exc_value, exc_traceback)
return
logging.error("未捕获的异常", exc_info=(exc_type, exc_value, exc_traceback))
sys.excepthook = handle_exception
logging.info("日志记录器已初始化。")
PIP_SERVERS = [
"https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple",
"https://mirrors.aliyun.com/pypi/simple",
"https://mirrors.cloud.tencent.com/pypi/simple",
"https://pypi.org/simple",
]
def is_admin() -> bool:
"""
检查当前进程是否具有管理员权限
:return: 如果具有管理员权限返回True否则返回False
:rtype: bool
"""
try:
return ctypes.windll.shell32.IsUserAnAdmin()
except:
return False
def test_url_availability(url: str) -> bool:
"""
测试URL是否可访问返回200状态码
:param url: 要测试的URL
:type url: str
:return: 如果URL可访问返回True否则返回False
:rtype: bool
"""
try:
with head(url, timeout=10) as response:
return response.status_code == 200
except (HTTPError, NetworkError):
return False
except Exception:
return False
def get_working_pip_server() -> Optional[str]:
"""
获取可用的pip服务器
:return: 第一个可用的pip服务器URL如果都不可用返回None
:rtype: Optional[str]
"""
for server in PIP_SERVERS:
msg = f"正在测试: {server}"
print_status(msg, status='info', indent=1)
logging.info(msg)
if test_url_availability(server):
msg = f"找到可用的pip服务器: {server}"
print_status(msg, status='success', indent=1)
logging.info(msg)
return server
msg = "所有pip服务器都不可用"
print_status(msg, status='error')
logging.error(msg)
return None
def package_version(package_name: str) -> Optional[str]:
"""
获取指定包的版本信息
:param package_name: 包名称
:type package_name: str
:return: 包版本字符串如果包不存在则返回 None
:rtype: Optional[str]
:Example:
.. code-block:: python
>>> package_version("requests")
'2.31.0'
>>> package_version("nonexistent_package")
None
:raises: 无异常抛出包不存在时返回 None
"""
try:
return importlib.metadata.version(package_name)
except importlib.metadata.PackageNotFoundError:
return None
def run_command(command: str, check: bool = True, verbatim: bool = False, scroll_region_size: int = -1, log_output: bool = True) -> bool:
"""
运行命令并实时输出返回是否成功
:param command: 要运行的命令
:param check: 是否检查返回码
:param verbatim: 是否原样输出不使用滚动UI
:param scroll_region_size: 滚动区域的大小, -1 表示动态计算
:param log_output: 是否将命令输出记录到日志中
:return: 命令是否成功执行
"""
logging.info(f"执行命令: {command}")
# 设置环境变量以确保正确的编码处理
env = os.environ.copy()
env["PYTHONIOENCODING"] = "utf-8"
# 获取系统默认编码
system_encoding = locale.getpreferredencoding()
# 创建解码器
def decode_output(line: bytes) -> str:
try:
# 首先尝试UTF-8解码
return line.decode('utf-8')
except UnicodeDecodeError:
try:
# 如果UTF-8失败尝试系统默认编码
return line.decode(system_encoding)
except UnicodeDecodeError:
# 如果都失败了,使用'replace'策略
return line.decode('utf-8', errors='replace')
if verbatim:
print(f"▶ 执行命令: {command}")
try:
process = subprocess.Popen(
command, shell=True,
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
env=env
)
if process.stdout:
for line in iter(process.stdout.readline, b''):
clean_line = decode_output(line).strip('\n')
print(clean_line)
if log_output:
logging.info(clean_line)
returncode = process.wait()
logging.info(f"命令执行完毕,返回码: {returncode}")
return returncode == 0
except FileNotFoundError:
msg = f"命令未找到: {command.split()[0]}"
print_status(msg, status='error', indent=1)
logging.error(msg)
return False
except Exception as e:
msg = f"命令执行时发生错误: {e}"
print_status(msg, status='error', indent=1)
logging.error(msg, exc_info=True)
return False
# --- 滚动UI模式 ---
if scroll_region_size == -1:
# Heuristic: leave some lines for context above and below.
# Use at least 5 lines.
SCROLL_REGION_SIZE = max(5, get_terminal_height() - 8)
else:
SCROLL_REGION_SIZE = scroll_region_size
terminal_width = get_terminal_width()
# 打印初始状态行
prefix = ""
prefix_width = 2 # "▶ "
available_width = terminal_width - prefix_width
command_text = f"执行命令: {command}"
truncated_command = truncate_string(command_text, available_width)
padding_len = available_width - get_display_width(truncated_command)
padding = ' ' * max(0, padding_len)
print(f"{Color.GRAY}{prefix}{truncated_command}{padding}{Color.RESET}")
process = subprocess.Popen(
command, shell=True,
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
env=env
)
output_buffer = deque(maxlen=SCROLL_REGION_SIZE)
lines_rendered = 0
hide_cursor()
try:
if process.stdout:
for line in iter(process.stdout.readline, b''):
stripped_line = decode_output(line).strip()
output_buffer.append(stripped_line)
if log_output:
logging.info(stripped_line)
# 移动光标到绘制区域顶部
if lines_rendered > 0:
move_cursor_up(lines_rendered)
lines_rendered = min(len(output_buffer), SCROLL_REGION_SIZE)
# 重新绘制滚动区域
lines_to_render = list(output_buffer)
for i in range(lines_rendered):
line_to_print = lines_to_render[i] if i < len(lines_to_render) else ""
prefix = f"{Color.GRAY}|{Color.RESET} "
prefix_width = 2
available_width = terminal_width - prefix_width
truncated = truncate_string(line_to_print, available_width)
padding_len = available_width - get_display_width(truncated)
padding = ' ' * max(0, padding_len)
# 使用 \r 和 \n 刷新行
print(f"\r{prefix}{truncated}{padding}")
returncode = process.wait()
logging.info(f"命令执行完毕,返回码: {returncode}")
finally:
show_cursor()
# 清理滚动区域
if lines_rendered > 0:
move_cursor_up(lines_rendered)
for _ in range(lines_rendered):
print(' ' * terminal_width)
move_cursor_up(lines_rendered)
# 更新最终状态行
move_cursor_up(1)
if returncode == 0:
final_symbol = f"{Color.GREEN}"
success = True
else:
final_symbol = f"{Color.RED}"
success = False
# 重新计算填充以确保行被完全覆盖
final_prefix = f"{final_symbol} "
final_prefix_width = 2 # "✓ " or "✗ "
available_width = terminal_width - final_prefix_width
final_line_text = f"执行命令: {command}"
truncated_final_line = truncate_string(final_line_text, available_width)
padding_len = available_width - get_display_width(truncated_final_line)
padding = ' ' * max(0, padding_len)
print(f"\r{final_prefix}{truncated_final_line}{Color.RESET}{padding}")
if check and not success:
msg = f"命令执行失败,返回码: {returncode}"
print_status(msg, status='error', indent=1)
logging.error(msg)
return False
return success
def check_ksaa_update_available(pip_server: str, current_version: Version) -> tuple[bool, Version | None, Version | None]:
"""
检查ksaa包是否有新版本可用
:param pip_server: pip服务器URL
:type pip_server: str
:param current_version: 当前版本
:type current_version: Version
:return: (是否有更新, 当前版本, 最新版本)
:rtype: tuple[bool, Optional[Version], Optional[Version]]
"""
try:
# 使用repo.py中的list_versions函数和Version类获取最新版本信息
from repo import list_versions, Version
try:
versions = list_versions("ksaa", server_url=pip_server)
if versions and len(versions) > 0:
latest_version = versions[0].version
# 使用Version类的比较功能
if latest_version > current_version:
return True, current_version, latest_version
except Exception as e:
logging.warning(f"从服务器 {pip_server} 获取版本信息失败: {e}")
print_status(f"从服务器 {pip_server} 获取版本信息失败: {e}", status='error')
# 如果指定服务器失败尝试使用默认PyPI服务器
try:
versions = list_versions("ksaa")
if versions and len(versions) > 0:
latest_version = versions[0].version
# 使用Version类的比较功能
if latest_version > current_version:
return True, current_version, latest_version
except Exception as e2:
logging.warning(f"从PyPI获取版本信息也失败: {e2}")
return False, current_version, latest_version if 'latest_version' in locals() else None
except Exception as e:
logging.warning(f"检查ksaa更新时发生错误: {e}")
return False, None, None
def print_update_notice(current_version: str, latest_version: str):
"""
打印更新提示信息
:param current_version: 当前版本
:type current_version: str
:param latest_version: 最新版本
:type latest_version: str
"""
clear_screen()
print()
print(f"{Color.YELLOW}{Color.BOLD}" + "=" * 60)
print(f"{Color.YELLOW}{Color.BOLD}⚠️ 发现新版本可用!")
print(f"{Color.YELLOW}{Color.BOLD}" + "=" * 60)
print(f"{Color.YELLOW}当前版本: {current_version}")
print(f"{Color.YELLOW}最新版本: {latest_version}")
print(f"{Color.YELLOW}建议开启自动更新或在设置中手动安装新版本。")
print(f"{Color.YELLOW}5s 后继续启动")
print(f"{Color.YELLOW}{Color.BOLD}" + "=" * 60 + f"{Color.RESET}")
print()
sleep(5)
def install_ksaa_version(pip_server: str, trusted_hosts: str, version: str) -> bool:
"""
安装指定版本的ksaa包
:param pip_server: pip服务器URL
:type pip_server: str
:param trusted_hosts: 信任的主机列表
:type trusted_hosts: str
:param version: 要安装的版本号
:type version: str
:return: 安装是否成功
:rtype: bool
"""
print_status(f"安装琴音小助手 v{version}", status='info')
install_command = f'"{PYTHON_EXECUTABLE}" -m pip install --index-url {pip_server} --trusted-host "{trusted_hosts}" --no-warn-script-location ksaa=={version}'
return run_command(install_command)
def install_ksaa_from_zip(zip_path: str) -> bool:
"""
从zip文件安装ksaa包
:param zip_path: zip文件路径
:type zip_path: str
:return: 安装是否成功
:rtype: bool
"""
zip_file = Path(zip_path)
if not zip_file.exists():
msg = f"zip文件不存在: {zip_path}"
print_status(msg, status='error')
logging.error(msg)
return False
if not zip_file.suffix.lower() == '.zip':
msg = f"文件不是zip格式: {zip_path}"
print_status(msg, status='error')
logging.error(msg)
return False
print_status(f"从zip文件安装琴音小助手: {zip_path}", status='info')
# 创建临时目录
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir)
try:
# 解压zip文件
print_status("解压zip文件...", status='info', indent=1)
with zipfile.ZipFile(zip_file, 'r') as zip_ref:
zip_ref.extractall(temp_path)
# 使用pip install --find-links安装
print_status("安装ksaa包...", status='info', indent=1)
install_command = f'"{PYTHON_EXECUTABLE}" -m pip install --no-warn-script-location --no-cache-dir --upgrade --no-deps --force-reinstall --no-index --find-links "{temp_path.absolute()}" ksaa'
return run_command(install_command)
except zipfile.BadZipFile:
msg = f"无效的zip文件: {zip_path}"
print_status(msg, status='error')
logging.error(msg)
return False
except Exception as e:
msg = f"从zip文件安装失败: {e}"
print_status(msg, status='error')
logging.error(msg, exc_info=True)
return False
def install_pip_and_ksaa(pip_server: str, check_update: bool = True, install_update: bool = True) -> bool:
"""
安装和更新pip以及ksaa包
:param pip_server: pip服务器URL
:type pip_server: str
:param check_update: 是否检查更新
:type check_update: bool
:param install_update: 是否安装更新
:type install_update: bool
:return: 安装是否成功
:rtype: bool
"""
print_header("安装与更新小助手", color=Color.BLUE)
# 升级pip
print_status("更新 pip", status='info')
upgrade_pip_command = f'"{PYTHON_EXECUTABLE}" -m pip install -i {pip_server} --trusted-host "{TRUSTED_HOSTS}" --upgrade pip'
if not run_command(upgrade_pip_command):
return False
# 默认安装逻辑
install_command = f'"{PYTHON_EXECUTABLE}" -m pip install --upgrade --index-url {pip_server} --trusted-host "{TRUSTED_HOSTS}" --no-warn-script-location ksaa'
ksaa_version_str = package_version("ksaa")
# 未安装
if not ksaa_version_str:
print_status("安装琴音小助手", status='info')
return run_command(install_command)
# 已安装,检查更新
else:
ksaa_version = Version(ksaa_version_str)
if check_update:
has_update, current_version, latest_version = check_ksaa_update_available(pip_server, ksaa_version)
if has_update:
if install_update:
print_status("更新琴音小助手", status='info')
return run_command(install_command)
else:
print_update_notice(str(current_version), str(latest_version))
else:
print_status("已是最新版本", status='success')
return True
def load_config() -> Optional[Config]:
"""
加载config.json配置文件
:return: 配置字典如果加载失败返回None
:rtype: Optional[Config]
"""
config_path = Path("./config.json")
if not config_path.exists():
msg = "配置文件 config.json 不存在,跳过配置加载"
print_status(msg, status='warning')
logging.warning(msg)
return None
try:
with open(config_path, 'r', encoding='utf-8') as f:
config = json.load(f)
msg = "成功加载配置文件"
print_status(msg, status='success')
logging.info(msg)
return config
except Exception as e:
msg = f"加载配置文件失败: {e}"
print_status(msg, status='error')
logging.error(msg, exc_info=True)
return None
def get_update_settings(config: Config) -> tuple[bool, bool]:
"""
从配置中获取更新设置
:param config: 配置字典
:type config: Config
:return: (是否检查更新, 是否自动安装更新)
:rtype: tuple[bool, bool]
"""
# 默认值
check_update = True
auto_install_update = True
# 检查是否有用户配置
user_configs = config.get("user_configs", [])
if user_configs:
first_config = user_configs[0]
options = first_config.get("options", {})
misc = options.get("misc", {})
# 获取检查更新设置
check_update_setting = misc.get("check_update", "startup")
check_update = check_update_setting == "startup"
# 获取自动安装更新设置
auto_install_update = misc.get("auto_install_update", True)
msg = f"更新设置: 检查更新={check_update}, 自动安装={auto_install_update}"
logging.info(msg)
return check_update, auto_install_update
def restart_as_admin() -> None:
"""
以管理员身份重启程序
"""
if is_admin():
return
script = os.path.abspath(sys.argv[0])
params = ' '.join([f'"{item}"' for item in sys.argv[1:]])
try:
# 使用 ShellExecute 以管理员身份启动程序
ret = ctypes.windll.shell32.ShellExecuteW(
None, "runas", PYTHON_EXECUTABLE, f'"{script}" {params}', None, 1
)
if ret > 32: # 返回值大于32表示成功
msg = "正在以管理员身份重启程序..."
print_status(msg, status='info')
logging.info(msg)
os._exit(0)
else:
msg = f"以管理员身份重启失败,错误码: {ret}"
print_status(msg, status='error')
logging.error(msg)
return
except Exception as e:
msg = f"以管理员身份重启时发生错误: {e}"
print_status(msg, status='error')
logging.error(msg, exc_info=True)
return
def check_admin(config: Config) -> bool:
"""
检查Windows截图权限管理员权限
:param config: 配置字典
:type config: Config
:return: 权限检查是否通过
:rtype: bool
"""
# 检查是否有用户配置
user_configs = config.get("user_configs", [])
if not user_configs:
msg = "配置文件中没有用户配置"
print_status(msg, status='warning')
logging.warning(msg)
return True # Not a fatal error, allow to continue
# 检查第一个用户配置的截图方式
first_config = user_configs[0]
backend = first_config.get("backend", {})
screenshot_impl = backend.get("screenshot_impl")
if screenshot_impl == "windows":
msg = "检测到Windows截图模式检查管理员权限..."
print_status(msg, status='info')
logging.info(msg)
if not is_admin():
msg1 = "需要管理员权限才能使用Windows截图模式"
print_status(msg1, status='error')
logging.error(msg1)
# 尝试以管理员身份重启
msg2 = "正在尝试以管理员身份重启..."
print_status(msg2, status='info', indent=1)
logging.info(msg2)
restart_as_admin()
return False
else:
msg = "管理员权限检查通过"
print_status(msg, status='success')
logging.info(msg)
return True
def run_kaa() -> bool:
"""
运行琴音小助手
:return: 运行是否成功
:rtype: bool
"""
print_header("运行琴音小助手", color=Color.GREEN)
clear_screen()
# 设置环境变量
os.environ["no_proxy"] = "localhost, 127.0.0.1, ::1"
# 运行kaa命令
if not run_command(f'"{PYTHON_EXECUTABLE}" -m kotonebot.kaa.main.cli', verbatim=True, log_output=False):
return False
print_header("运行结束", color=Color.GREEN)
return True
def parse_arguments():
"""
解析命令行参数
:return: 解析后的参数
:rtype: argparse.Namespace
"""
parser = argparse.ArgumentParser(description='琴音小助手启动器')
parser.add_argument('zip_file', nargs='?', help='要安装的 zip 文件路径(与--install-from-zip等价')
parser.add_argument('--install-version', type=str, help='安装指定版本的 ksaa (例如: --install-version=1.2.3)')
parser.add_argument('--install-from-zip', type=str, help='从 zip 文件安装 ksaa (例如: --install-from-zip=/path/to/file.zip)')
return parser.parse_args()
def main_launch():
"""
主启动函数执行完整的安装和启动流程
"""
# 解析命令行参数
args = parse_arguments()
# 处理位置参数如果提供了zip_file位置参数将其设置为install_from_zip
if args.zip_file and not args.install_from_zip:
args.install_from_zip = args.zip_file
setup_logging()
run_command("title 琴音小助手(运行时请勿关闭此窗口)", verbatim=True, log_output=False)
clear_screen()
print_header("琴音小助手启动器")
logging.info("启动器已启动。")
try:
# 1. 加载配置文件(提前加载以获取更新设置)
print_header("加载配置", color=Color.BLUE)
logging.info("加载配置。")
config = load_config()
# 2. 获取更新设置
check_update, auto_install_update = get_update_settings(config if config else {"version": 5, "user_configs": []})
# 3. 如果指定了特殊安装参数,跳过更新检查
if args.install_version or args.install_from_zip:
check_update = False
auto_install_update = False
# 4. 根据配置决定是否检查更新
print_status("正在寻找最快的 PyPI 镜像源...", status='info')
logging.info("正在寻找最快的 PyPI 镜像源...")
pip_server = get_working_pip_server()
if not pip_server:
raise RuntimeError("没有找到可用的pip服务器请检查网络连接。")
# 5. 处理特殊安装情况
if args.install_from_zip:
# 从zip文件安装
print_header("安装补丁", color=Color.BLUE)
if not install_ksaa_from_zip(args.install_from_zip):
raise RuntimeError("从zip文件安装失败请检查上面的错误日志。")
elif args.install_version:
# 安装指定版本
print_header("安装指定版本", color=Color.BLUE)
if not install_ksaa_version(pip_server, TRUSTED_HOSTS, args.install_version):
raise RuntimeError("安装指定版本失败,请检查上面的错误日志。")
else:
# 默认安装和更新逻辑
if not install_pip_and_ksaa(pip_server, check_update, auto_install_update):
raise RuntimeError("依赖安装失败,请检查上面的错误日志。")
# 6. 检查Windows截图权限
if config:
if not check_admin(config):
raise RuntimeError("权限检查失败。")
# 7. 运行琴音小助手
if not run_kaa():
raise RuntimeError("琴音小助手主程序运行失败。")
msg = "琴音小助手已退出。"
print_status(msg, status='success')
logging.info(msg)
except Exception as e:
msg = f"发生致命错误: {e}"
print_status(msg, status='error')
print_status("压缩 kaa 目录下的 logs 文件夹并给此窗口截图后一并发送给开发者", status='error')
logging.critical(msg, exc_info=True)
finally:
logging.info("启动器运行结束。")
wait_key("\n按任意键退出...")
if __name__ == "__main__":
try:
main_launch()
except KeyboardInterrupt:
print_status("运行结束。现在可以安全关闭此窗口。", status='info')

View File

@ -0,0 +1,235 @@
import re
import html.parser
import urllib.parse
from typing import List
from dataclasses import dataclass
from request import get, HTTPError, NetworkError
@dataclass
class Version:
"""版本信息"""
version_str: str
major: int = 0
minor: int = 0
patch: int = 0
prerelease: str = ""
prerelease_num: int = 0
def __post_init__(self):
"""初始化后解析版本号"""
self._parse_version()
def _parse_version(self):
"""解析版本号字符串"""
version_str = self.version_str.lower()
# 基本版本号匹配 (如 1.2.3, 1.2, 1)
version_match = re.match(r'^(\d+)(?:\.(\d+))?(?:\.(\d+))?', version_str)
if version_match:
self.major = int(version_match.group(1))
self.minor = int(version_match.group(2)) if version_match.group(2) else 0
self.patch = int(version_match.group(3)) if version_match.group(3) else 0
# 预发布版本匹配 (如 alpha1, beta2, rc3)
prerelease_match = re.search(r'(alpha|beta|rc|dev|pre|post)(\d*)', version_str)
if prerelease_match:
self.prerelease = prerelease_match.group(1)
self.prerelease_num = int(prerelease_match.group(2)) if prerelease_match.group(2) else 0
def __lt__(self, other):
"""版本比较"""
if not isinstance(other, Version):
return NotImplemented
# 比较主版本号
if self.major != other.major:
return self.major < other.major
if self.minor != other.minor:
return self.minor < other.minor
if self.patch != other.patch:
return self.patch < other.patch
# 比较预发布版本
prerelease_order = {'': 4, 'rc': 3, 'beta': 2, 'alpha': 1, 'dev': 0, 'pre': 0, 'post': 5}
self_order = prerelease_order.get(self.prerelease, 0)
other_order = prerelease_order.get(other.prerelease, 0)
if self_order != other_order:
return self_order < other_order
# 同类型预发布版本比较数字
if self.prerelease == other.prerelease:
return self.prerelease_num < other.prerelease_num
return False
def __eq__(self, other):
"""版本相等比较"""
if not isinstance(other, Version):
return NotImplemented
return (self.major == other.major and
self.minor == other.minor and
self.patch == other.patch and
self.prerelease == other.prerelease and
self.prerelease_num == other.prerelease_num)
def __repr__(self):
return f"Version('{self.version_str}')"
def __str__(self):
return self.version_str
@dataclass
class PackageVersion:
"""包版本信息"""
version: Version
url: str
class PyPIHTMLParser(html.parser.HTMLParser):
"""解析PyPI HTML响应的解析器"""
def __init__(self):
super().__init__()
self.links = []
self.current_href = None
self.current_text = None
def handle_starttag(self, tag, attrs):
if tag == 'a':
# 提取href属性
for attr_name, attr_value in attrs:
if attr_name == 'href':
self.current_href = attr_value
break
def handle_data(self, data):
if self.current_href:
self.current_text = data.strip()
def handle_endtag(self, tag):
if tag == 'a' and self.current_href and self.current_text:
self.links.append((self.current_text, self.current_href))
self.current_href = None
self.current_text = None
def normalize_package_name(package_name: str) -> str:
"""
标准化包名 _, -, . 字符视为相等
"""
return re.sub(r'[_.-]', '-', package_name.lower())
def extract_version_from_filename(filename: str) -> str:
"""
从文件名中提取版本号
例如: beautifulsoup4-4.13.0b2-py3-none-any.whl -> 4.13.0b2
"""
# 匹配版本号模式
version_pattern = r'^[^-]+-([^-]+?)(?:-py\d+)?(?:-none-any)?\.(?:whl|tar\.gz|zip)$'
match = re.match(version_pattern, filename)
if match:
return match.group(1)
# 备用模式:查找版本号
version_match = re.search(r'-(\d+\.\d+(?:\.\d+)?(?:[a-zA-Z0-9]*))', filename)
if version_match:
return version_match.group(1)
return "unknown"
def list_versions(package_name: str, *, server_url: str | None = None) -> List[PackageVersion]:
"""
获取指定包的所有可用版本按版本号降序排列
:param package_name: 包名
:type package_name: str
:param server_url: 可选的服务器URL默认为None时使用PyPI官方服务器https://pypi.org/simple
:type server_url: str | None
:return: 包含版本信息的列表按版本号降序排列
:rtype: List[PackageVersion]
:raises HTTPError: 当包不存在或网络错误时
:raises NetworkError: 当网络连接错误时
"""
# 标准化包名
normalized_name = normalize_package_name(package_name)
# 构建API URL
if server_url is None:
base_url = "https://pypi.org/simple"
else:
base_url = server_url.rstrip('/')
url = f"{base_url}/{urllib.parse.quote(normalized_name)}/"
# 设置请求头
headers = {
'Accept': 'application/vnd.pypi.simple.v1+html'
}
try:
# 发送请求
html_content = get(url, headers=headers).decode('utf-8')
# 解析HTML
parser = PyPIHTMLParser()
parser.feed(html_content)
# 处理链接并提取版本信息
versions = []
for filename, href in parser.links:
# 提取版本号
version_str = extract_version_from_filename(filename)
# 创建Version对象
version = Version(version_str)
# 创建PackageVersion对象
package_version = PackageVersion(
version=version,
url=href
)
versions.append(package_version)
# 按版本号降序排列
versions.sort(key=lambda x: x.version, reverse=True)
return versions
except HTTPError as e:
if e.code == 404:
raise ValueError(f"'{package_name}' 不存在") from e
else:
raise
def main():
"""测试函数"""
try:
# 测试获取beautifulsoup4的版本
print("获取 beautifulsoup4 的版本信息...")
versions = list_versions("beautifulsoup4")
print(f"找到 {len(versions)} 个版本:")
for i, pkg_version in enumerate(versions[:10], 1): # 只显示前10个
print(f"{i}. 版本: {pkg_version.version.version_str}")
print(f" 主版本: {pkg_version.version.major}.{pkg_version.version.minor}.{pkg_version.version.patch}")
if pkg_version.version.prerelease:
print(f" 预发布: {pkg_version.version.prerelease}{pkg_version.version.prerelease_num}")
print(f" URL: {pkg_version.url}")
print()
if len(versions) > 10:
print(f"... 还有 {len(versions) - 10} 个版本")
except Exception as e:
print(f"错误: {e}")
if __name__ == "__main__":
main()

View File

@ -0,0 +1,248 @@
import os
import urllib.parse
import urllib.error
import urllib.request
from typing import Dict, Any, Optional, Callable, TYPE_CHECKING
if TYPE_CHECKING:
from http.client import HTTPResponse
class HTTPError(Exception):
"""HTTP请求错误"""
def __init__(self, code: int, message: str):
self.code = code
self.message = message
super().__init__(f"HTTP {code}: {message}")
class NetworkError(Exception):
"""网络连接错误"""
pass
class Response:
"""HTTP响应封装"""
def __init__(self, http_response: "HTTPResponse"):
self._response = http_response
self._content: Optional[bytes] = None
def __enter__(self):
return self
def __exit__(self, exc_type, exc_val, exc_tb):
self.close()
def close(self):
"""关闭响应和底层连接。"""
self._response.close()
@property
def status_code(self) -> int:
"""响应状态码"""
return self._response.getcode()
@property
def reason(self) -> str:
"""响应原因短语"""
return self._response.reason
@property
def headers(self) -> Dict[str, Any]:
"""响应头"""
return dict(self._response.headers)
def read(self) -> bytes:
"""读取响应内容 (bytes)"""
if self._content is None:
self._content = self._response.read()
return self._content
def json(self) -> Any:
"""将响应内容解析为JSON"""
import json
return json.loads(self.read())
def request(
url: str,
method: str = "GET",
headers: Optional[Dict[str, str]] = None,
data: Optional[bytes] = None,
timeout: Optional[float] = None
) -> Response:
"""
发送HTTP请求
:param url: 请求URL
:type url: str
:param method: HTTP方法默认为GET
:type method: str
:param headers: 请求头
:type headers: Optional[Dict[str, str]]
:param data: 请求数据
:type data: Optional[bytes]
:param timeout: 超时时间
:type timeout: Optional[float]
:return: 响应对象
:rtype: Response
:raises HTTPError: HTTP错误
:raises NetworkError: 网络连接错误
"""
# 设置默认请求头
default_headers = {
'User-Agent': 'Python-urllib/3.10'
}
if headers:
default_headers.update(headers)
# 创建请求
req = urllib.request.Request(url, data=data, headers=default_headers, method=method)
try:
# 发送请求
response = urllib.request.urlopen(req, timeout=timeout)
return Response(response)
except urllib.error.HTTPError as e:
raise HTTPError(e.code, e.reason) from e
except urllib.error.URLError as e:
raise NetworkError(f"网络连接错误: {e.reason}") from e
def get(url: str, headers: Optional[Dict[str, str]] = None, timeout: Optional[float] = None) -> bytes:
"""
发送GET请求
:param url: 请求URL
:type url: str
:param headers: 请求头
:type headers: Optional[Dict[str, str]]
:param timeout: 超时时间
:type timeout: Optional[float]
:return: 响应内容
:rtype: bytes
"""
with request(url, method="GET", headers=headers, timeout=timeout) as response:
return response.read()
def head(url: str, headers: Optional[Dict[str, str]] = None, timeout: Optional[float] = None) -> Response:
"""
发送HEAD请求
:param url: 请求URL
:type url: str
:param headers: 请求头
:type headers: Optional[Dict[str, str]]
:param timeout: 超时时间
:type timeout: Optional[float]
:return: 响应对象
:rtype: Response
:raises HTTPError: HTTP错误
:raises NetworkError: 网络连接错误
"""
return request(url, method="HEAD", headers=headers, timeout=timeout)
def download_file(
url: str,
dst_path: str,
*,
callback: Optional[Callable[[int, int], None]] = None,
timeout: Optional[float] = None
) -> None:
"""
下载文件
:param url: 文件URL
:type url: str
:param dst_path: 目标路径
:type dst_path: str
:param callback: 进度回调函数参数为(已下载字节数, 总字节数)
:type callback: Optional[Callable[[int, int], None]]
:param timeout: 超时时间
:type timeout: Optional[float]
:raises HTTPError: HTTP错误
:raises NetworkError: 网络连接错误
"""
# 设置请求头
headers = {
'User-Agent': 'Python-urllib/3.10'
}
# 创建请求
req = urllib.request.Request(url, headers=headers)
try:
# 发送请求
with urllib.request.urlopen(req, timeout=timeout) as response:
# 获取文件大小
content_length = response.headers.get('Content-Length')
total_size = int(content_length) if content_length else None
# 确保目标目录存在
os.makedirs(os.path.dirname(dst_path), exist_ok=True)
# 下载文件
downloaded_size = 0
with open(dst_path, 'wb') as f:
while True:
chunk = response.read(8192) # 8KB chunks
if not chunk:
break
f.write(chunk)
downloaded_size += len(chunk)
# 调用进度回调
if callback and total_size:
callback(downloaded_size, total_size)
elif callback and not total_size:
callback(downloaded_size, -1) # 未知总大小
except urllib.error.HTTPError as e:
raise HTTPError(e.code, e.reason) from e
except urllib.error.URLError as e:
raise NetworkError(f"网络连接错误: {e.reason}") from e
except OSError as e:
raise NetworkError(f"文件写入错误: {e}") from e
def main():
"""测试函数"""
try:
# 测试GET请求
print("测试GET请求...")
response_bytes = get("https://httpbin.org/get")
print(f"响应长度: {len(response_bytes)} 字节")
# 测试文件下载
print("\n测试文件下载...")
def progress_callback(downloaded: int, total: int):
if total > 0:
percentage = (downloaded / total) * 100
print(f"下载进度: {downloaded}/{total} 字节 ({percentage:.1f}%)")
else:
print(f"已下载: {downloaded} 字节")
# 下载一个小文件进行测试
download_file(
"https://httpbin.org/bytes/1024",
"test_download.bin",
callback=progress_callback
)
print("下载完成!")
# 清理测试文件
if os.path.exists("test_download.bin"):
os.remove("test_download.bin")
except Exception as e:
print(f"错误: {e}")
if __name__ == "__main__":
main()

View File

@ -0,0 +1,178 @@
import os
import ctypes
import shutil
import unicodedata
from typing import Optional
def _enable_windows_ansi():
"""
On Windows, attempts to enable ANSI escape sequence processing.
"""
if os.name == 'nt':
try:
kernel32 = ctypes.windll.kernel32
handle = kernel32.GetStdHandle(-11) # STD_OUTPUT_HANDLE
mode = ctypes.c_ulong()
if kernel32.GetConsoleMode(handle, ctypes.byref(mode)) == 0:
return # Failed to get console mode
ENABLE_VIRTUAL_TERMINAL_PROCESSING = 0x0004
if (mode.value & ENABLE_VIRTUAL_TERMINAL_PROCESSING) == 0:
mode.value |= ENABLE_VIRTUAL_TERMINAL_PROCESSING
if kernel32.SetConsoleMode(handle, mode) == 0:
# Fallback for older systems
os.system('')
except Exception:
# Fallback for environments where ctypes fails (e.g., some IDE terminals)
os.system('')
_enable_windows_ansi()
class Color:
"""ANSI color codes"""
RESET = '\033[0m'
BOLD = '\033[1m'
BLACK = '\033[30m'
RED = '\033[31m'
GREEN = '\033[32m'
YELLOW = '\033[33m'
BLUE = '\033[34m'
MAGENTA = '\033[35m'
CYAN = '\033[36m'
WHITE = '\033[37m'
GRAY = '\033[90m'
def get_terminal_width(default=80):
"""Gets the width of the terminal."""
# shutil.get_terminal_size is a high-level and more robust way
return shutil.get_terminal_size((default, 24)).columns
def get_terminal_height(default=24):
"""Gets the height of the terminal."""
return shutil.get_terminal_size((default, 24)).lines
def get_display_width(s: str) -> int:
"""Calculates the display width of a string, accounting for wide characters."""
width = 0
for char in s:
# 'F' (Fullwidth), 'W' (Wide) characters take up 2 columns.
if unicodedata.east_asian_width(char) in ('F', 'W'):
width += 2
else:
width += 1
return width
def truncate_string(s: str, max_width: int) -> str:
"""Truncates a string to a maximum display width, handling wide characters."""
if not s or max_width <= 0:
return ""
width = 0
end_pos = 0
for i, char in enumerate(s):
# 'F' (Fullwidth), 'W' (Wide) characters take up 2 columns.
char_width = 2 if unicodedata.east_asian_width(char) in ('F', 'W') else 1
if width + char_width > max_width:
break
width += char_width
end_pos = i + 1
return s[:end_pos]
def hide_cursor():
"""Hides the terminal cursor."""
print('\033[?25l', end='')
def show_cursor():
"""Shows the terminal cursor."""
print('\033[?25h', end='')
def move_cursor_up(lines: int):
"""Moves the cursor up by a number of lines."""
if lines > 0:
print(f'\033[{lines}A', end='')
def clear_screen():
"""Clears the terminal screen."""
os.system('cls' if os.name == 'nt' else 'clear')
def print_header(text: str, color: str = Color.CYAN):
"""
Prints a centered header with separators that fill the terminal width.
Accounts for CJK character widths.
:param text: The text to display in the header.
:param color: ANSI color code for the header text.
"""
width = get_terminal_width()
padded_text = f" {text} "
text_display_width = get_display_width(padded_text)
# Handle cases where the text is wider than the terminal
if text_display_width >= width:
print(f"\n{Color.BOLD}{color}{text}{Color.RESET}")
return
separator_total_len = width - text_display_width
l_separator_len = separator_total_len // 2
r_separator_len = separator_total_len - l_separator_len
l_separator = "" * l_separator_len
r_separator = "" * r_separator_len
print(f"\n{Color.BOLD}{color}{l_separator}{padded_text}{r_separator}{Color.RESET}")
def print_status(message: str, success: Optional[bool] = None, status: str = 'info', indent: int = 0):
"""
Prints a status message with a symbol and color.
:param message: The status message to print.
:param success: (Deprecated) If True, sets status to 'success'; if False, 'error'.
:param status: 'success', 'error', 'warning', or 'info'.
:param indent: Number of spaces to indent.
"""
prefix = " " * indent
# Backward compatibility
if success is not None:
status = 'success' if success else 'error'
if status == 'success':
symbol = f"{Color.GREEN}"
elif status == 'error':
symbol = f"{Color.RED}"
elif status == 'warning':
symbol = f"{Color.YELLOW}"
else: # 'info'
symbol = f"{Color.BLUE}{Color.RESET}"
print(f"{prefix}[{symbol}] {message}{Color.RESET}")
def wait_key(message: str = ""):
"""
Prints a message and waits for a single key press from the user.
This is a cross-platform function.
:param message: The message to display before waiting.
"""
print(message, end="", flush=True)
if os.name == 'nt':
import msvcrt
msvcrt.getch()
else:
import sys
import tty
import termios
fd = sys.stdin.fileno()
old_settings = termios.tcgetattr(fd)
try:
tty.setraw(sys.stdin.fileno())
sys.stdin.read(1)
finally:
termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
# Add a newline after the key is pressed for cleaner output
print()

418
bootstrap/kaa-wrapper/.gitignore vendored Normal file
View File

@ -0,0 +1,418 @@
## Ignore Visual Studio temporary files, build results, and
## files generated by popular Visual Studio add-ons.
##
## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore
# User-specific files
*.rsuser
*.suo
*.user
*.userosscache
*.sln.docstates
*.env
# User-specific files (MonoDevelop/Xamarin Studio)
*.userprefs
# Mono auto generated files
mono_crash.*
# Build results
[Dd]ebug/
[Dd]ebugPublic/
[Rr]elease/
[Rr]eleases/
x64/
x86/
[Ww][Ii][Nn]32/
[Aa][Rr][Mm]/
[Aa][Rr][Mm]64/
[Aa][Rr][Mm]64[Ee][Cc]/
bld/
[Oo]bj/
[Oo]ut/
[Ll]og/
[Ll]ogs/
# Build results on 'Bin' directories
**/[Bb]in/*
# Uncomment if you have tasks that rely on *.refresh files to move binaries
# (https://github.com/github/gitignore/pull/3736)
#!**/[Bb]in/*.refresh
# Visual Studio 2015/2017 cache/options directory
.vs/
# Uncomment if you have tasks that create the project's static files in wwwroot
#wwwroot/
# Visual Studio 2017 auto generated files
Generated\ Files/
# MSTest test Results
[Tt]est[Rr]esult*/
[Bb]uild[Ll]og.*
*.trx
# NUnit
*.VisualState.xml
TestResult.xml
nunit-*.xml
# Approval Tests result files
*.received.*
# Build Results of an ATL Project
[Dd]ebugPS/
[Rr]eleasePS/
dlldata.c
# Benchmark Results
BenchmarkDotNet.Artifacts/
# .NET Core
project.lock.json
project.fragment.lock.json
artifacts/
# ASP.NET Scaffolding
ScaffoldingReadMe.txt
# StyleCop
StyleCopReport.xml
# Files built by Visual Studio
*_i.c
*_p.c
*_h.h
*.ilk
*.meta
*.obj
*.idb
*.iobj
*.pch
*.pdb
*.ipdb
*.pgc
*.pgd
*.rsp
# but not Directory.Build.rsp, as it configures directory-level build defaults
!Directory.Build.rsp
*.sbr
*.tlb
*.tli
*.tlh
*.tmp
*.tmp_proj
*_wpftmp.csproj
*.log
*.tlog
*.vspscc
*.vssscc
.builds
*.pidb
*.svclog
*.scc
# Chutzpah Test files
_Chutzpah*
# Visual C++ cache files
ipch/
*.aps
*.ncb
*.opendb
*.opensdf
*.sdf
*.cachefile
*.VC.db
*.VC.VC.opendb
# Visual Studio profiler
*.psess
*.vsp
*.vspx
*.sap
# Visual Studio Trace Files
*.e2e
# TFS 2012 Local Workspace
$tf/
# Guidance Automation Toolkit
*.gpState
# ReSharper is a .NET coding add-in
_ReSharper*/
*.[Rr]e[Ss]harper
*.DotSettings.user
# TeamCity is a build add-in
_TeamCity*
# DotCover is a Code Coverage Tool
*.dotCover
# AxoCover is a Code Coverage Tool
.axoCover/*
!.axoCover/settings.json
# Coverlet is a free, cross platform Code Coverage Tool
coverage*.json
coverage*.xml
coverage*.info
# Visual Studio code coverage results
*.coverage
*.coveragexml
# NCrunch
_NCrunch_*
.NCrunch_*
.*crunch*.local.xml
nCrunchTemp_*
# MightyMoose
*.mm.*
AutoTest.Net/
# Web workbench (sass)
.sass-cache/
# Installshield output folder
[Ee]xpress/
# DocProject is a documentation generator add-in
DocProject/buildhelp/
DocProject/Help/*.HxT
DocProject/Help/*.HxC
DocProject/Help/*.hhc
DocProject/Help/*.hhk
DocProject/Help/*.hhp
DocProject/Help/Html2
DocProject/Help/html
# Click-Once directory
publish/
# Publish Web Output
*.[Pp]ublish.xml
*.azurePubxml
# Note: Comment the next line if you want to checkin your web deploy settings,
# but database connection strings (with potential passwords) will be unencrypted
*.pubxml
*.publishproj
# Microsoft Azure Web App publish settings. Comment the next line if you want to
# checkin your Azure Web App publish settings, but sensitive information contained
# in these scripts will be unencrypted
PublishScripts/
# NuGet Packages
*.nupkg
# NuGet Symbol Packages
*.snupkg
# The packages folder can be ignored because of Package Restore
**/[Pp]ackages/*
# except build/, which is used as an MSBuild target.
!**/[Pp]ackages/build/
# Uncomment if necessary however generally it will be regenerated when needed
#!**/[Pp]ackages/repositories.config
# NuGet v3's project.json files produces more ignorable files
*.nuget.props
*.nuget.targets
# Microsoft Azure Build Output
csx/
*.build.csdef
# Microsoft Azure Emulator
ecf/
rcf/
# Windows Store app package directories and files
AppPackages/
BundleArtifacts/
Package.StoreAssociation.xml
_pkginfo.txt
*.appx
*.appxbundle
*.appxupload
# Visual Studio cache files
# files ending in .cache can be ignored
*.[Cc]ache
# but keep track of directories ending in .cache
!?*.[Cc]ache/
# Others
ClientBin/
~$*
*~
*.dbmdl
*.dbproj.schemaview
*.jfm
*.pfx
*.publishsettings
orleans.codegen.cs
# Including strong name files can present a security risk
# (https://github.com/github/gitignore/pull/2483#issue-259490424)
#*.snk
# Since there are multiple workflows, uncomment next line to ignore bower_components
# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
#bower_components/
# RIA/Silverlight projects
Generated_Code/
# Backup & report files from converting an old project file
# to a newer Visual Studio version. Backup files are not needed,
# because we have git ;-)
_UpgradeReport_Files/
Backup*/
UpgradeLog*.XML
UpgradeLog*.htm
ServiceFabricBackup/
*.rptproj.bak
# SQL Server files
*.mdf
*.ldf
*.ndf
# Business Intelligence projects
*.rdl.data
*.bim.layout
*.bim_*.settings
*.rptproj.rsuser
*- [Bb]ackup.rdl
*- [Bb]ackup ([0-9]).rdl
*- [Bb]ackup ([0-9][0-9]).rdl
# Microsoft Fakes
FakesAssemblies/
# GhostDoc plugin setting file
*.GhostDoc.xml
# Node.js Tools for Visual Studio
.ntvs_analysis.dat
node_modules/
# Visual Studio 6 build log
*.plg
# Visual Studio 6 workspace options file
*.opt
# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
*.vbw
# Visual Studio 6 auto-generated project file (contains which files were open etc.)
*.vbp
# Visual Studio 6 workspace and project file (working project files containing files to include in project)
*.dsw
*.dsp
# Visual Studio 6 technical files
*.ncb
*.aps
# Visual Studio LightSwitch build output
**/*.HTMLClient/GeneratedArtifacts
**/*.DesktopClient/GeneratedArtifacts
**/*.DesktopClient/ModelManifest.xml
**/*.Server/GeneratedArtifacts
**/*.Server/ModelManifest.xml
_Pvt_Extensions
# Paket dependency manager
**/.paket/paket.exe
paket-files/
# FAKE - F# Make
**/.fake/
# CodeRush personal settings
**/.cr/personal
# Python Tools for Visual Studio (PTVS)
**/__pycache__/
*.pyc
# Cake - Uncomment if you are using it
#tools/**
#!tools/packages.config
# Tabs Studio
*.tss
# Telerik's JustMock configuration file
*.jmconfig
# BizTalk build output
*.btp.cs
*.btm.cs
*.odx.cs
*.xsd.cs
# OpenCover UI analysis results
OpenCover/
# Azure Stream Analytics local run output
ASALocalRun/
# MSBuild Binary and Structured Log
*.binlog
MSBuild_Logs/
# AWS SAM Build and Temporary Artifacts folder
.aws-sam
# NVidia Nsight GPU debugger configuration file
*.nvuser
# MFractors (Xamarin productivity tool) working folder
**/.mfractor/
# Local History for Visual Studio
**/.localhistory/
# Visual Studio History (VSHistory) files
.vshistory/
# BeatPulse healthcheck temp database
healthchecksdb
# Backup folder for Package Reference Convert tool in Visual Studio 2017
MigrationBackup/
# Ionide (cross platform F# VS Code tools) working folder
**/.ionide/
# Fody - auto-generated XML schema
FodyWeavers.xsd
# VS Code files for those working on multiple tools
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
!.vscode/*.code-snippets
# Local History for Visual Studio Code
.history/
# Built Visual Studio Code Extensions
*.vsix
# Windows Installer files from build outputs
*.cab
*.msi
*.msix
*.msm
*.msp

View File

@ -0,0 +1,18 @@
//{{NO_DEPENDENCIES}}
// Microsoft Visual C++ 生成的包含文件。
// 使用者 kaa-wrapper.rc
#define IDI_KAAWRAPPER 107
#define IDI_SMALL 108
// 新对象的下一组默认值
//
#ifdef APSTUDIO_INVOKED
#ifndef APSTUDIO_READONLY_SYMBOLS
#define _APS_NO_MFC 130
#define _APS_NEXT_RESOURCE_VALUE 129
#define _APS_NEXT_COMMAND_VALUE 32771
#define _APS_NEXT_CONTROL_VALUE 1000
#define _APS_NEXT_SYMED_VALUE 110
#endif
#endif

View File

@ -0,0 +1,10 @@
// header.h: 标准系统包含文件的包含文件,
// 或特定于项目的包含文件
//
#pragma once
#include "targetver.h"
#define WIN32_LEAN_AND_MEAN
#include <windows.h>
#include <tchar.h>

View File

@ -0,0 +1,88 @@
// kaa-wrapper.cpp : 定义应用程序的入口点。
//
#include "framework.h"
#include "kaa-wrapper.h"
#include <string>
int APIENTRY wWinMain(_In_ HINSTANCE hInstance,
_In_opt_ HINSTANCE hPrevInstance,
_In_ LPWSTR lpCmdLine,
_In_ int nCmdShow)
{
UNREFERENCED_PARAMETER(hInstance);
UNREFERENCED_PARAMETER(hPrevInstance);
UNREFERENCED_PARAMETER(nCmdShow);
// 设置当前目录为程序所在目录
WCHAR szPath[MAX_PATH];
if (GetModuleFileNameW(NULL, szPath, MAX_PATH) == 0) {
MessageBoxW(NULL, L"无法获取程序所在目录", L"错误", MB_OK | MB_ICONERROR);
return 1;
}
std::wstring path(szPath);
size_t pos = path.find_last_of(L"\\");
if (pos == std::wstring::npos) {
MessageBoxW(NULL, L"程序路径格式错误", L"错误", MB_OK | MB_ICONERROR);
return 1;
}
path = path.substr(0, pos);
if (!SetCurrentDirectoryW(path.c_str())) {
MessageBoxW(NULL, L"无法设置工作目录", L"错误", MB_OK | MB_ICONERROR);
return 1;
}
// 检查 Python 解释器是否存在
std::wstring pythonPath = path + L"\\WPy64-310111\\python-3.10.11.amd64\\python.exe";
if (GetFileAttributesW(pythonPath.c_str()) == INVALID_FILE_ATTRIBUTES) {
MessageBoxW(NULL, L"找不到 Python 解释器", L"错误", MB_OK | MB_ICONERROR);
return 1;
}
// 检查 bootstrap.pyz 是否存在
std::wstring bootstrapPath = path + L"\\bootstrap.pyz";
if (GetFileAttributesW(bootstrapPath.c_str()) == INVALID_FILE_ATTRIBUTES) {
MessageBoxW(NULL, L"找不到 bootstrap.pyz 文件", L"错误", MB_OK | MB_ICONERROR);
return 1;
}
// 构建命令行
std::wstring cmd = L"\"" + pythonPath + L"\" \"" + bootstrapPath + L"\"";
// 如果有命令行参数,将其传递给 bootstrap
if (lpCmdLine && wcslen(lpCmdLine) > 0) {
cmd += L" ";
cmd += lpCmdLine;
}
// 启动信息
STARTUPINFOW si = { sizeof(si) };
PROCESS_INFORMATION pi;
// 创建进程,使用当前目录作为工作目录
if (!CreateProcessW(NULL,
const_cast<LPWSTR>(cmd.c_str()),
NULL,
NULL,
FALSE,
0,
NULL,
path.c_str(), // 设置工作目录为当前目录
&si,
&pi))
{
DWORD error = GetLastError();
WCHAR errorMsg[256];
swprintf_s(errorMsg, L"无法启动程序 (错误代码: %d)", error);
MessageBoxW(NULL, errorMsg, L"错误", MB_OK | MB_ICONERROR);
return 1;
}
// 关闭进程和线程句柄
CloseHandle(pi.hProcess);
CloseHandle(pi.hThread);
return 0;
}

View File

@ -0,0 +1,3 @@
#pragma once
#include "resource.h"

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

View File

@ -0,0 +1,31 @@

Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.10.35013.160
MinimumVisualStudioVersion = 10.0.40219.1
Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "kaa-wrapper", "kaa-wrapper.vcxproj", "{F4F29940-A4DD-40C0-A433-1CF3C7B6F55C}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|x64 = Debug|x64
Debug|x86 = Debug|x86
Release|x64 = Release|x64
Release|x86 = Release|x86
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{F4F29940-A4DD-40C0-A433-1CF3C7B6F55C}.Debug|x64.ActiveCfg = Debug|x64
{F4F29940-A4DD-40C0-A433-1CF3C7B6F55C}.Debug|x64.Build.0 = Debug|x64
{F4F29940-A4DD-40C0-A433-1CF3C7B6F55C}.Debug|x86.ActiveCfg = Debug|Win32
{F4F29940-A4DD-40C0-A433-1CF3C7B6F55C}.Debug|x86.Build.0 = Debug|Win32
{F4F29940-A4DD-40C0-A433-1CF3C7B6F55C}.Release|x64.ActiveCfg = Release|x64
{F4F29940-A4DD-40C0-A433-1CF3C7B6F55C}.Release|x64.Build.0 = Release|x64
{F4F29940-A4DD-40C0-A433-1CF3C7B6F55C}.Release|x86.ActiveCfg = Release|Win32
{F4F29940-A4DD-40C0-A433-1CF3C7B6F55C}.Release|x86.Build.0 = Release|Win32
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {304B08B9-3494-48C3-94A5-9486F9D16B10}
EndGlobalSection
EndGlobal

View File

@ -0,0 +1,148 @@
<?xml version="1.0" encoding="utf-8"?>
<Project DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<ItemGroup Label="ProjectConfigurations">
<ProjectConfiguration Include="Debug|Win32">
<Configuration>Debug</Configuration>
<Platform>Win32</Platform>
</ProjectConfiguration>
<ProjectConfiguration Include="Release|Win32">
<Configuration>Release</Configuration>
<Platform>Win32</Platform>
</ProjectConfiguration>
<ProjectConfiguration Include="Debug|x64">
<Configuration>Debug</Configuration>
<Platform>x64</Platform>
</ProjectConfiguration>
<ProjectConfiguration Include="Release|x64">
<Configuration>Release</Configuration>
<Platform>x64</Platform>
</ProjectConfiguration>
</ItemGroup>
<PropertyGroup Label="Globals">
<VCProjectVersion>17.0</VCProjectVersion>
<Keyword>Win32Proj</Keyword>
<ProjectGuid>{f4f29940-a4dd-40c0-a433-1cf3c7b6f55c}</ProjectGuid>
<RootNamespace>kaawrapper</RootNamespace>
<WindowsTargetPlatformVersion>10.0</WindowsTargetPlatformVersion>
</PropertyGroup>
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" />
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|Win32'" Label="Configuration">
<ConfigurationType>Application</ConfigurationType>
<UseDebugLibraries>true</UseDebugLibraries>
<PlatformToolset>v143</PlatformToolset>
<CharacterSet>Unicode</CharacterSet>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|Win32'" Label="Configuration">
<ConfigurationType>Application</ConfigurationType>
<UseDebugLibraries>false</UseDebugLibraries>
<PlatformToolset>v143</PlatformToolset>
<WholeProgramOptimization>true</WholeProgramOptimization>
<CharacterSet>Unicode</CharacterSet>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|x64'" Label="Configuration">
<ConfigurationType>Application</ConfigurationType>
<UseDebugLibraries>true</UseDebugLibraries>
<PlatformToolset>v143</PlatformToolset>
<CharacterSet>Unicode</CharacterSet>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|x64'" Label="Configuration">
<ConfigurationType>Application</ConfigurationType>
<UseDebugLibraries>false</UseDebugLibraries>
<PlatformToolset>v143</PlatformToolset>
<WholeProgramOptimization>true</WholeProgramOptimization>
<CharacterSet>Unicode</CharacterSet>
</PropertyGroup>
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.props" />
<ImportGroup Label="ExtensionSettings">
</ImportGroup>
<ImportGroup Label="Shared">
</ImportGroup>
<ImportGroup Label="PropertySheets" Condition="'$(Configuration)|$(Platform)'=='Debug|Win32'">
<Import Project="$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props" Condition="exists('$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props')" Label="LocalAppDataPlatform" />
</ImportGroup>
<ImportGroup Label="PropertySheets" Condition="'$(Configuration)|$(Platform)'=='Release|Win32'">
<Import Project="$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props" Condition="exists('$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props')" Label="LocalAppDataPlatform" />
</ImportGroup>
<ImportGroup Label="PropertySheets" Condition="'$(Configuration)|$(Platform)'=='Debug|x64'">
<Import Project="$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props" Condition="exists('$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props')" Label="LocalAppDataPlatform" />
</ImportGroup>
<ImportGroup Label="PropertySheets" Condition="'$(Configuration)|$(Platform)'=='Release|x64'">
<Import Project="$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props" Condition="exists('$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props')" Label="LocalAppDataPlatform" />
</ImportGroup>
<PropertyGroup Label="UserMacros" />
<ItemDefinitionGroup Condition="'$(Configuration)|$(Platform)'=='Debug|Win32'">
<ClCompile>
<WarningLevel>Level3</WarningLevel>
<SDLCheck>true</SDLCheck>
<PreprocessorDefinitions>WIN32;_DEBUG;_WINDOWS;%(PreprocessorDefinitions)</PreprocessorDefinitions>
<ConformanceMode>true</ConformanceMode>
</ClCompile>
<Link>
<SubSystem>Windows</SubSystem>
<GenerateDebugInformation>true</GenerateDebugInformation>
</Link>
</ItemDefinitionGroup>
<ItemDefinitionGroup Condition="'$(Configuration)|$(Platform)'=='Release|Win32'">
<ClCompile>
<WarningLevel>Level3</WarningLevel>
<FunctionLevelLinking>true</FunctionLevelLinking>
<IntrinsicFunctions>true</IntrinsicFunctions>
<SDLCheck>true</SDLCheck>
<PreprocessorDefinitions>WIN32;NDEBUG;_WINDOWS;%(PreprocessorDefinitions)</PreprocessorDefinitions>
<ConformanceMode>true</ConformanceMode>
</ClCompile>
<Link>
<SubSystem>Windows</SubSystem>
<EnableCOMDATFolding>true</EnableCOMDATFolding>
<OptimizeReferences>true</OptimizeReferences>
<GenerateDebugInformation>true</GenerateDebugInformation>
</Link>
</ItemDefinitionGroup>
<ItemDefinitionGroup Condition="'$(Configuration)|$(Platform)'=='Debug|x64'">
<ClCompile>
<WarningLevel>Level3</WarningLevel>
<SDLCheck>true</SDLCheck>
<PreprocessorDefinitions>_DEBUG;_WINDOWS;%(PreprocessorDefinitions)</PreprocessorDefinitions>
<ConformanceMode>true</ConformanceMode>
</ClCompile>
<Link>
<SubSystem>Windows</SubSystem>
<GenerateDebugInformation>true</GenerateDebugInformation>
</Link>
</ItemDefinitionGroup>
<ItemDefinitionGroup Condition="'$(Configuration)|$(Platform)'=='Release|x64'">
<ClCompile>
<WarningLevel>Level3</WarningLevel>
<FunctionLevelLinking>true</FunctionLevelLinking>
<IntrinsicFunctions>true</IntrinsicFunctions>
<SDLCheck>true</SDLCheck>
<PreprocessorDefinitions>NDEBUG;_WINDOWS;%(PreprocessorDefinitions)</PreprocessorDefinitions>
<ConformanceMode>true</ConformanceMode>
</ClCompile>
<Link>
<SubSystem>Windows</SubSystem>
<EnableCOMDATFolding>true</EnableCOMDATFolding>
<OptimizeReferences>true</OptimizeReferences>
<GenerateDebugInformation>true</GenerateDebugInformation>
</Link>
</ItemDefinitionGroup>
<ItemGroup>
<ClInclude Include="framework.h" />
<ClInclude Include="kaa-wrapper.h" />
<ClInclude Include="Resource.h" />
<ClInclude Include="targetver.h" />
</ItemGroup>
<ItemGroup>
<ClCompile Include="kaa-wrapper.cpp" />
</ItemGroup>
<ItemGroup>
<ResourceCompile Include="kaa-wrapper.rc" />
</ItemGroup>
<ItemGroup>
<Image Include="kaa-wrapper.ico" />
<Image Include="small.ico" />
</ItemGroup>
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" />
<ImportGroup Label="ExtensionTargets">
</ImportGroup>
</Project>

View File

@ -0,0 +1,49 @@
<?xml version="1.0" encoding="utf-8"?>
<Project ToolsVersion="4.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<ItemGroup>
<Filter Include="源文件">
<UniqueIdentifier>{4FC737F1-C7A5-4376-A066-2A32D752A2FF}</UniqueIdentifier>
<Extensions>cpp;c;cc;cxx;c++;cppm;ixx;def;odl;idl;hpj;bat;asm;asmx</Extensions>
</Filter>
<Filter Include="头文件">
<UniqueIdentifier>{93995380-89BD-4b04-88EB-625FBE52EBFB}</UniqueIdentifier>
<Extensions>h;hh;hpp;hxx;h++;hm;inl;inc;ipp;xsd</Extensions>
</Filter>
<Filter Include="资源文件">
<UniqueIdentifier>{67DA6AB6-F800-4c08-8B7A-83BB121AAD01}</UniqueIdentifier>
<Extensions>rc;ico;cur;bmp;dlg;rc2;rct;bin;rgs;gif;jpg;jpeg;jpe;resx;tiff;tif;png;wav;mfcribbon-ms</Extensions>
</Filter>
</ItemGroup>
<ItemGroup>
<ClInclude Include="framework.h">
<Filter>头文件</Filter>
</ClInclude>
<ClInclude Include="targetver.h">
<Filter>头文件</Filter>
</ClInclude>
<ClInclude Include="Resource.h">
<Filter>头文件</Filter>
</ClInclude>
<ClInclude Include="kaa-wrapper.h">
<Filter>头文件</Filter>
</ClInclude>
</ItemGroup>
<ItemGroup>
<ClCompile Include="kaa-wrapper.cpp">
<Filter>源文件</Filter>
</ClCompile>
</ItemGroup>
<ItemGroup>
<ResourceCompile Include="kaa-wrapper.rc">
<Filter>资源文件</Filter>
</ResourceCompile>
</ItemGroup>
<ItemGroup>
<Image Include="small.ico">
<Filter>资源文件</Filter>
</Image>
<Image Include="kaa-wrapper.ico">
<Filter>资源文件</Filter>
</Image>
</ItemGroup>
</Project>

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

View File

@ -0,0 +1,6 @@
#pragma once
// // 包含 SDKDDKVer.h 可定义可用的最高版本的 Windows 平台。
// 如果希望为之前的 Windows 平台构建应用程序,在包含 SDKDDKVer.h 之前请先包含 WinSDKVer.h 并
// 将 _WIN32_WINNT 宏设置为想要支持的平台。
#include <SDKDDKVer.h>

View File

@ -1,41 +0,0 @@
@echo off
call WPy64-310111\scripts\env.bat
if errorlevel 1 (
goto ERROR
)
set PIP_EXTRA_INDEX_URL=https://mirrors.cloud.tencent.com/pypi/simple/ http://mirrors.aliyun.com/pypi/simple/
echo =========== 安装与更新 KAA ===========
:INSTALL
echo 检查 pip
python -m pip install -i https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple --upgrade pip
if errorlevel 1 (
goto ERROR
)
pip config set global.trusted-host "pypi.org files.pythonhosted.org pypi.python.org mirrors.aliyun.com mirrors.cloud.tencent.com mirrors.tuna.tsinghua.edu.cn"
pip config set global.index-url https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple
echo 安装 ksaa
pip install --upgrade ksaa
if errorlevel 1 (
goto ERROR
)
echo =========== 当前版本 ===========
pip show ksaa
echo =========== 运行 KAA ===========
:RUN
set no_proxy=localhost, 127.0.0.1, ::1
kaa
if errorlevel 1 (
goto ERROR
)
echo =========== 运行结束 ===========
pause
exit /b 0
:ERROR
echo 发生错误,程序退出
pause
exit /b 1

View File

@ -1,54 +0,0 @@
@echo off
cd /D %~dp0
REM https://superuser.com/questions/788924/is-it-possible-to-automatically-run-a-batch-file-as-administrator
REM --> Check for permissions
>nul 2>&1 "%SYSTEMROOT%\system32\cacls.exe" "%SYSTEMROOT%\system32\config\system"
REM --> If error flag set, we do not have admin.
if '%errorlevel%' NEQ '0' (
echo 需要以管理员身份运行。右键此脚本,选择“以管理员身份运行”。
pause
exit /b 1
)
call WPy64-310111\scripts\env.bat
if errorlevel 1 (
goto ERROR
)
set PIP_EXTRA_INDEX_URL=https://mirrors.cloud.tencent.com/pypi/simple/ http://mirrors.aliyun.com/pypi/simple/
echo =========== 安装与更新 KAA ===========
:INSTALL
echo 检查 pip
python -m pip install -i https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple --upgrade pip
if errorlevel 1 (
goto ERROR
)
pip config set global.trusted-host "pypi.org files.pythonhosted.org pypi.python.org mirrors.aliyun.com mirrors.cloud.tencent.com mirrors.tuna.tsinghua.edu.cn"
pip config set global.index-url https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple
echo 安装 ksaa
pip install --upgrade ksaa
if errorlevel 1 (
goto ERROR
)
echo =========== 当前版本 ===========
pip show ksaa
echo =========== 运行 KAA ===========
:RUN
set no_proxy=localhost, 127.0.0.1, ::1
kaa
if errorlevel 1 (
goto ERROR
)
echo =========== 运行结束 ===========
pause
exit /b 0
:ERROR
echo 发生错误,程序退出
pause
exit /b 1

View File

@ -1,40 +0,0 @@
@echo off
call WPy64-310111\scripts\env.bat
if errorlevel 1 (
goto ERROR
)
if "%~1"=="" (
echo 请将 Python 包文件拖到此脚本上
pause
exit /b 1
)
echo =========== 卸载原有包 ===========
pip uninstall -y ksaa
pip uninstall -y ksaa_res
if errorlevel 1 (
goto ERROR
)
:INSTALL_LOOP
if "%~1"=="" goto INSTALL_DONE
echo =========== 安装 %~1 ===========
pip install "%~1"
if errorlevel 1 (
goto ERROR
)
shift
goto INSTALL_LOOP
:INSTALL_DONE
echo =========== 安装完成 ===========
pause
exit /b 0
:ERROR
echo 发生错误,程序退出
pause
exit /b 1

View File

@ -41,10 +41,6 @@ env: fetch-submodule
}
python tools/make_resources.py
# Build the project using pyinstaller
build: env
pyinstaller -y kotonebot-gr.spec
generate-metadata: env
#!{{shebang_python}}
# 更新日志
@ -124,3 +120,21 @@ publish-test: package
#
build-bootstrap:
#!{{shebang_pwsh}}
echo "Building bootstrap..."
# 构建 Python
cd bootstrap
python -m zipapp kaa-bootstrap
mv kaa-bootstrap.pyz ../dist/bootstrap.pyz -fo
# 构建 C++
$msbuild = &"${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" -latest -prerelease -products * -requires Microsoft.Component.MSBuild -find MSBuild\**\Bin\MSBuild.exe
if ($msbuild) {
& $msbuild kaa-wrapper/kaa-wrapper.sln /p:Configuration=Release
mv kaa-wrapper/x64/Release/kaa-wrapper.exe ../dist/kaa.exe -fo
} else {
Write-Host "MSBuild not found. Please install Visual Studio or build kaa-wrapper manually."
}
# Build kaa and bootstrap
build: package build-bootstrap

View File

@ -1 +1 @@
{"definitions":{"0949c622-9067-4f0d-bac2-3f938a1d2ed2":{"name":"Shop.ItemLessonNote","displayName":"レッスンノート","type":"template","annotationId":"0949c622-9067-4f0d-bac2-3f938a1d2ed2","useHintRect":false},"b2af59e9-60e3-4d97-8c72-c7ba092113a3":{"name":"Shop.ItemVeteranNote","displayName":"ベテランノート","type":"template","annotationId":"b2af59e9-60e3-4d97-8c72-c7ba092113a3","useHintRect":false},"835489e2-b29b-426c-b4c9-3bb9f8eb6195":{"name":"Shop.ItemSupportEnhancementPt","displayName":"サポート強化Pt 支援强化Pt","type":"template","annotationId":"835489e2-b29b-426c-b4c9-3bb9f8eb6195","useHintRect":false},"c5b7d67e-7260-4f08-a0e9-4d31ce9bbecf":{"name":"Shop.ItemSenseNoteVocal","displayName":"センスノート(ボーカル)感性笔记(声乐)","type":"template","annotationId":"c5b7d67e-7260-4f08-a0e9-4d31ce9bbecf","useHintRect":false},"0f7d581d-cea3-4039-9205-732e4cd29293":{"name":"Shop.ItemSenseNoteDance","displayName":"センスノート(ダンス)感性笔记(舞蹈)","type":"template","annotationId":"0f7d581d-cea3-4039-9205-732e4cd29293","useHintRect":false},"d3cc3323-51af-4882-ae12-49e7384b746d":{"name":"Shop.ItemSenseNoteVisual","displayName":"センスノート(ビジュアル)感性笔记(形象)","type":"template","annotationId":"d3cc3323-51af-4882-ae12-49e7384b746d","useHintRect":false},"a1df3af1-a3e7-4521-a085-e4dc3cd1cc57":{"name":"Shop.ItemLogicNoteVocal","displayName":"ロジックノート(ボーカル)理性笔记(声乐)","type":"template","annotationId":"a1df3af1-a3e7-4521-a085-e4dc3cd1cc57","useHintRect":false},"a9fcaf04-0c1f-4b0f-bb5b-ede9da96180f":{"name":"Shop.ItemLogicNoteDance","displayName":"ロジックノート(ダンス)理性笔记(舞蹈)","type":"template","annotationId":"a9fcaf04-0c1f-4b0f-bb5b-ede9da96180f","useHintRect":false},"c3f536d6-a04a-4651-b3f9-dd2c22593f7f":{"name":"Shop.ItemLogicNoteVisual","displayName":"ロジックノート(ビジュアル)理性笔记(形象)","type":"template","annotationId":"c3f536d6-a04a-4651-b3f9-dd2c22593f7f","useHintRect":false},"eef25cf9-afd0-43b1-b9c5-fbd997bd5fe0":{"name":"Shop.ItemAnomalyNoteVocal","displayName":"アノマリーノート(ボーカル)非凡笔记(声乐)","type":"template","annotationId":"eef25cf9-afd0-43b1-b9c5-fbd997bd5fe0","useHintRect":false},"df991b42-ed8e-4f2c-bf0c-aa7522f147b6":{"name":"Shop.ItemAnomalyNoteDance","displayName":"アノマリーノート(ダンス)非凡笔记(舞蹈)","type":"template","annotationId":"df991b42-ed8e-4f2c-bf0c-aa7522f147b6","useHintRect":false}},"annotations":[{"id":"0949c622-9067-4f0d-bac2-3f938a1d2ed2","type":"rect","data":{"x1":243,"y1":355,"x2":313,"y2":441}},{"id":"b2af59e9-60e3-4d97-8c72-c7ba092113a3","type":"rect","data":{"x1":414,"y1":355,"x2":484,"y2":441}},{"id":"835489e2-b29b-426c-b4c9-3bb9f8eb6195","type":"rect","data":{"x1":574,"y1":363,"x2":662,"y2":438}},{"id":"c5b7d67e-7260-4f08-a0e9-4d31ce9bbecf","type":"rect","data":{"x1":71,"y1":594,"x2":142,"y2":667}},{"id":"0f7d581d-cea3-4039-9205-732e4cd29293","type":"rect","data":{"x1":241,"y1":593,"x2":309,"y2":667}},{"id":"d3cc3323-51af-4882-ae12-49e7384b746d","type":"rect","data":{"x1":417,"y1":586,"x2":481,"y2":668}},{"id":"a1df3af1-a3e7-4521-a085-e4dc3cd1cc57","type":"rect","data":{"x1":585,"y1":591,"x2":651,"y2":669}},{"id":"a9fcaf04-0c1f-4b0f-bb5b-ede9da96180f","type":"rect","data":{"x1":69,"y1":825,"x2":138,"y2":899}},{"id":"c3f536d6-a04a-4651-b3f9-dd2c22593f7f","type":"rect","data":{"x1":242,"y1":820,"x2":310,"y2":898}},{"id":"eef25cf9-afd0-43b1-b9c5-fbd997bd5fe0","type":"rect","data":{"x1":413,"y1":821,"x2":481,"y2":897}},{"id":"df991b42-ed8e-4f2c-bf0c-aa7522f147b6","type":"rect","data":{"x1":583,"y1":823,"x2":649,"y2":900}}]}
{"definitions":{"0949c622-9067-4f0d-bac2-3f938a1d2ed2":{"name":"Shop.ItemLessonNote","displayName":"レッスンノート","type":"template","annotationId":"0949c622-9067-4f0d-bac2-3f938a1d2ed2","useHintRect":false},"b2af59e9-60e3-4d97-8c72-c7ba092113a3":{"name":"Shop.ItemVeteranNote","displayName":"ベテランノート","type":"template","annotationId":"b2af59e9-60e3-4d97-8c72-c7ba092113a3","useHintRect":false},"835489e2-b29b-426c-b4c9-3bb9f8eb6195":{"name":"Shop.ItemSupportEnhancementPt","displayName":"サポート強化Pt 支援强化Pt","type":"template","annotationId":"835489e2-b29b-426c-b4c9-3bb9f8eb6195","useHintRect":false},"c5b7d67e-7260-4f08-a0e9-4d31ce9bbecf":{"name":"Shop.ItemSenseNoteVocal","displayName":"センスノート(ボーカル)感性笔记(声乐)","type":"template","annotationId":"c5b7d67e-7260-4f08-a0e9-4d31ce9bbecf","useHintRect":false},"0f7d581d-cea3-4039-9205-732e4cd29293":{"name":"Shop.ItemSenseNoteDance","displayName":"センスノート(ダンス)感性笔记(舞蹈)","type":"template","annotationId":"0f7d581d-cea3-4039-9205-732e4cd29293","useHintRect":false},"d3cc3323-51af-4882-ae12-49e7384b746d":{"name":"Shop.ItemSenseNoteVisual","displayName":"センスノート(ビジュアル)感性笔记(形象)","type":"template","annotationId":"d3cc3323-51af-4882-ae12-49e7384b746d","useHintRect":false},"a1df3af1-a3e7-4521-a085-e4dc3cd1cc57":{"name":"Shop.ItemLogicNoteVocal","displayName":"ロジックノート(ボーカル)理性笔记(声乐)","type":"template","annotationId":"a1df3af1-a3e7-4521-a085-e4dc3cd1cc57","useHintRect":false},"a9fcaf04-0c1f-4b0f-bb5b-ede9da96180f":{"name":"Shop.ItemLogicNoteDance","displayName":"ロジックノート(ダンス)理性笔记(舞蹈)","type":"template","annotationId":"a9fcaf04-0c1f-4b0f-bb5b-ede9da96180f","useHintRect":false},"c3f536d6-a04a-4651-b3f9-dd2c22593f7f":{"name":"Shop.ItemLogicNoteVisual","displayName":"ロジックノート(ビジュアル)理性笔记(形象)","type":"template","annotationId":"c3f536d6-a04a-4651-b3f9-dd2c22593f7f","useHintRect":false},"eef25cf9-afd0-43b1-b9c5-fbd997bd5fe0":{"name":"Shop.ItemAnomalyNoteVocal","displayName":"アノマリーノート(ボーカル)非凡笔记(声乐)","type":"template","annotationId":"eef25cf9-afd0-43b1-b9c5-fbd997bd5fe0","useHintRect":false},"df991b42-ed8e-4f2c-bf0c-aa7522f147b6":{"name":"Shop.ItemAnomalyNoteDance","displayName":"アノマリーノート(ダンス)非凡笔记(舞蹈)","type":"template","annotationId":"df991b42-ed8e-4f2c-bf0c-aa7522f147b6","useHintRect":false},"81c97cd3-df53-44d3-bf3d-1eb4dc67b62a":{"name":"Daily.ButtonRefreshMoneyShop","displayName":"リスト更新1回無料","type":"template","annotationId":"81c97cd3-df53-44d3-bf3d-1eb4dc67b62a","useHintRect":false}},"annotations":[{"id":"0949c622-9067-4f0d-bac2-3f938a1d2ed2","type":"rect","data":{"x1":243,"y1":355,"x2":313,"y2":441}},{"id":"b2af59e9-60e3-4d97-8c72-c7ba092113a3","type":"rect","data":{"x1":414,"y1":355,"x2":484,"y2":441}},{"id":"835489e2-b29b-426c-b4c9-3bb9f8eb6195","type":"rect","data":{"x1":574,"y1":363,"x2":662,"y2":438}},{"id":"c5b7d67e-7260-4f08-a0e9-4d31ce9bbecf","type":"rect","data":{"x1":71,"y1":594,"x2":142,"y2":667}},{"id":"0f7d581d-cea3-4039-9205-732e4cd29293","type":"rect","data":{"x1":241,"y1":593,"x2":309,"y2":667}},{"id":"d3cc3323-51af-4882-ae12-49e7384b746d","type":"rect","data":{"x1":417,"y1":586,"x2":481,"y2":668}},{"id":"a1df3af1-a3e7-4521-a085-e4dc3cd1cc57","type":"rect","data":{"x1":585,"y1":591,"x2":651,"y2":669}},{"id":"a9fcaf04-0c1f-4b0f-bb5b-ede9da96180f","type":"rect","data":{"x1":69,"y1":825,"x2":138,"y2":899}},{"id":"c3f536d6-a04a-4651-b3f9-dd2c22593f7f","type":"rect","data":{"x1":242,"y1":820,"x2":310,"y2":898}},{"id":"eef25cf9-afd0-43b1-b9c5-fbd997bd5fe0","type":"rect","data":{"x1":413,"y1":821,"x2":481,"y2":897}},{"id":"df991b42-ed8e-4f2c-bf0c-aa7522f147b6","type":"rect","data":{"x1":583,"y1":823,"x2":649,"y2":900}},{"id":"81c97cd3-df53-44d3-bf3d-1eb4dc67b62a","type":"rect","data":{"x1":440,"y1":149,"x2":679,"y2":179}}]}

View File

@ -1 +1 @@
{"definitions":{"9340b854-025c-40da-9387-385d38433bef":{"name":"Shop.ItemAnomalyNoteVisual","displayName":"アノマリーノート(ビジュアル)非凡笔记(形象)","type":"template","annotationId":"9340b854-025c-40da-9387-385d38433bef","useHintRect":false},"ea1ba124-9cb3-4427-969a-bacd47e7d920":{"name":"Shop.ItemRechallengeTicket","displayName":"再挑戦チケット 重新挑战券","type":"template","annotationId":"ea1ba124-9cb3-4427-969a-bacd47e7d920","useHintRect":false},"1926f2f9-4bd7-48eb-9eba-28ec4efb0606":{"name":"Shop.ItemRecordKey","displayName":"記録の鍵 解锁交流的物品","type":"template","annotationId":"1926f2f9-4bd7-48eb-9eba-28ec4efb0606","useHintRect":false},"6720b6e8-ae80-4cc0-a885-518efe12b707":{"name":"Shop.IdolPiece.倉本千奈_WonderScale","displayName":"倉本千奈 WonderScale 碎片","type":"template","annotationId":"6720b6e8-ae80-4cc0-a885-518efe12b707","useHintRect":false},"afa06fdc-a345-4384-b25d-b16540830256":{"name":"Shop.IdolPiece.篠泽广_光景","displayName":"篠泽广 光景 碎片","type":"template","annotationId":"afa06fdc-a345-4384-b25d-b16540830256","useHintRect":false},"278b7d9c-707e-4392-9677-74574b5cdf42":{"name":"Shop.IdolPiece.紫云清夏_TameLieOneStep","displayName":"紫云清夏 Tame-Lie-One-Step 碎片","type":"template","annotationId":"278b7d9c-707e-4392-9677-74574b5cdf42","useHintRect":false},"e9ee330d-dfca-440e-8b8c-0a3b4e8c8730":{"name":"Daily.IconTitleDailyShop","displayName":"日常商店标题图标","type":"template","annotationId":"e9ee330d-dfca-440e-8b8c-0a3b4e8c8730","useHintRect":false}},"annotations":[{"id":"9340b854-025c-40da-9387-385d38433bef","type":"rect","data":{"x1":72,"y1":611,"x2":138,"y2":693}},{"id":"ea1ba124-9cb3-4427-969a-bacd47e7d920","type":"rect","data":{"x1":227,"y1":639,"x2":316,"y2":674}},{"id":"1926f2f9-4bd7-48eb-9eba-28ec4efb0606","type":"rect","data":{"x1":385,"y1":591,"x2":508,"y2":694}},{"id":"6720b6e8-ae80-4cc0-a885-518efe12b707","type":"rect","data":{"x1":589,"y1":633,"x2":638,"y2":678}},{"id":"afa06fdc-a345-4384-b25d-b16540830256","type":"rect","data":{"x1":83,"y1":867,"x2":134,"y2":912}},{"id":"278b7d9c-707e-4392-9677-74574b5cdf42","type":"rect","data":{"x1":247,"y1":864,"x2":301,"y2":907}},{"id":"e9ee330d-dfca-440e-8b8c-0a3b4e8c8730","type":"rect","data":{"x1":17,"y1":35,"x2":59,"y2":76}}]}
{"definitions":{"9340b854-025c-40da-9387-385d38433bef":{"name":"Shop.ItemAnomalyNoteVisual","displayName":"アノマリーノート(ビジュアル)非凡笔记(形象)","type":"template","annotationId":"9340b854-025c-40da-9387-385d38433bef","useHintRect":false},"ea1ba124-9cb3-4427-969a-bacd47e7d920":{"name":"Shop.ItemRechallengeTicket","displayName":"再挑戦チケット 重新挑战券","type":"template","annotationId":"ea1ba124-9cb3-4427-969a-bacd47e7d920","useHintRect":false},"1926f2f9-4bd7-48eb-9eba-28ec4efb0606":{"name":"Shop.ItemRecordKey","displayName":"記録の鍵 解锁交流的物品","type":"template","annotationId":"1926f2f9-4bd7-48eb-9eba-28ec4efb0606","useHintRect":false},"e9ee330d-dfca-440e-8b8c-0a3b4e8c8730":{"name":"Daily.IconTitleDailyShop","displayName":"日常商店标题图标","type":"template","annotationId":"e9ee330d-dfca-440e-8b8c-0a3b4e8c8730","useHintRect":false}},"annotations":[{"id":"9340b854-025c-40da-9387-385d38433bef","type":"rect","data":{"x1":72,"y1":611,"x2":138,"y2":693}},{"id":"ea1ba124-9cb3-4427-969a-bacd47e7d920","type":"rect","data":{"x1":227,"y1":639,"x2":316,"y2":674}},{"id":"1926f2f9-4bd7-48eb-9eba-28ec4efb0606","type":"rect","data":{"x1":385,"y1":591,"x2":508,"y2":694}},{"id":"e9ee330d-dfca-440e-8b8c-0a3b4e8c8730","type":"rect","data":{"x1":17,"y1":35,"x2":59,"y2":76}}]}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 300 KiB

View File

@ -1 +0,0 @@
{"definitions":{"74ff07b3-d91c-4579-80cd-379ed7020622":{"name":"Shop.IdolPiece.葛城リーリヤ_白線","displayName":"葛城リーリヤ 白線 碎片","type":"template","annotationId":"74ff07b3-d91c-4579-80cd-379ed7020622","useHintRect":false}},"annotations":[{"id":"74ff07b3-d91c-4579-80cd-379ed7020622","type":"rect","data":{"x1":101,"y1":630,"x2":135,"y2":664}}]}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 307 KiB

View File

@ -1 +0,0 @@
{"definitions":{"a7f5abf1-982f-4a55-8d41-3ad6f56798e0":{"name":"Shop.IdolPiece.姫崎薪波_cIclumsy_trick ","displayName":"姫崎薪波 cIclumsy trick 碎片","type":"template","annotationId":"a7f5abf1-982f-4a55-8d41-3ad6f56798e0","useHintRect":false}},"annotations":[{"id":"a7f5abf1-982f-4a55-8d41-3ad6f56798e0","type":"rect","data":{"x1":113,"y1":628,"x2":148,"y2":656}}]}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 295 KiB

View File

@ -1 +0,0 @@
{"definitions":{"2bc00520-0afe-40e5-8743-d33fc6b2945a":{"name":"Shop.IdolPiece.花海咲季_FightingMyWay","displayName":"花海咲季 FightingMyWay 碎片","type":"template","annotationId":"2bc00520-0afe-40e5-8743-d33fc6b2945a","useHintRect":false}},"annotations":[{"id":"2bc00520-0afe-40e5-8743-d33fc6b2945a","type":"rect","data":{"x1":112,"y1":601,"x2":148,"y2":640}}]}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 297 KiB

View File

@ -1 +0,0 @@
{"definitions":{"135ee57a-d30d-4ba8-83f0-9f1681a49ff7":{"name":"Shop.IdolPiece.藤田ことね_世界一可愛い私","displayName":"藤田ことね 世界一可愛い私 碎片","type":"template","annotationId":"135ee57a-d30d-4ba8-83f0-9f1681a49ff7","useHintRect":false}},"annotations":[{"id":"135ee57a-d30d-4ba8-83f0-9f1681a49ff7","type":"rect","data":{"x1":113,"y1":602,"x2":146,"y2":635}}]}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 294 KiB

View File

@ -1 +0,0 @@
{"definitions":{"d15959bf-d07b-4f07-948a-c0aeaf17756a":{"name":"Shop.IdolPiece.花海佑芽_TheRollingRiceball","displayName":"花海佑芽 The Rolling Riceball 碎片","type":"template","annotationId":"d15959bf-d07b-4f07-948a-c0aeaf17756a","useHintRect":false}},"annotations":[{"id":"d15959bf-d07b-4f07-948a-c0aeaf17756a","type":"rect","data":{"x1":103,"y1":605,"x2":137,"y2":635}}]}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 291 KiB

View File

@ -1 +0,0 @@
{"definitions":{"868b97a9-492e-4712-b47f-82b97495b019":{"name":"Shop.IdolPiece.月村手毬_LunaSayMaybe","displayName":"月村手毬 Luna say maybe 碎片","type":"template","annotationId":"868b97a9-492e-4712-b47f-82b97495b019","useHintRect":false}},"annotations":[{"id":"868b97a9-492e-4712-b47f-82b97495b019","type":"rect","data":{"x1":106,"y1":601,"x2":145,"y2":633}}]}

View File

@ -1 +1 @@
{"definitions":{"85c24a02-dac3-4e6c-978e-b11963e0e92d":{"name":"Produce.BoxProduceOngoing","displayName":"首页开始培育按钮(当前有培育)","type":"hint-box","annotationId":"85c24a02-dac3-4e6c-978e-b11963e0e92d","useHintRect":false},"4fe748c8-a535-4824-aefc-244e3ad34bd4":{"name":"Daily.BoxHomeAssignment","displayName":"首页工作按钮","type":"hint-box","annotationId":"4fe748c8-a535-4824-aefc-244e3ad34bd4","useHintRect":false},"469f7f21-067c-476c-9bfc-c3ec83b935ea":{"name":"Daily.BoxHomeAP","displayName":"首页体力","type":"hint-box","annotationId":"469f7f21-067c-476c-9bfc-c3ec83b935ea","useHintRect":false},"565df1a0-d494-41a4-a3bf-603857bd8dec":{"name":"Daily.BoxHomeJewel","displayName":"首页珠宝数量","type":"hint-box","annotationId":"565df1a0-d494-41a4-a3bf-603857bd8dec","useHintRect":false},"76c92bd0-496b-403e-b545-92eb1f2f941f":{"name":"Daily.BoxHomeActivelyFunds","displayName":"首页活动费按钮","type":"hint-box","annotationId":"76c92bd0-496b-403e-b545-92eb1f2f941f","useHintRect":false}},"annotations":[{"id":"85c24a02-dac3-4e6c-978e-b11963e0e92d","type":"rect","data":{"x1":179,"y1":937,"x2":551,"y2":1091}},{"id":"4fe748c8-a535-4824-aefc-244e3ad34bd4","type":"rect","data":{"x1":16,"y1":642,"x2":127,"y2":752}},{"id":"469f7f21-067c-476c-9bfc-c3ec83b935ea","type":"rect","data":{"x1":291,"y1":4,"x2":500,"y2":82}},{"id":"565df1a0-d494-41a4-a3bf-603857bd8dec","type":"rect","data":{"x1":500,"y1":7,"x2":703,"y2":82}},{"id":"76c92bd0-496b-403e-b545-92eb1f2f941f","type":"rect","data":{"x1":11,"y1":517,"x2":137,"y2":637}}]}
{"definitions":{"85c24a02-dac3-4e6c-978e-b11963e0e92d":{"name":"Produce.BoxProduceOngoing","displayName":"首页开始培育按钮(当前有培育)","type":"hint-box","annotationId":"85c24a02-dac3-4e6c-978e-b11963e0e92d","useHintRect":false},"4fe748c8-a535-4824-aefc-244e3ad34bd4":{"name":"Daily.BoxHomeAssignment","displayName":"首页工作按钮","type":"hint-box","annotationId":"4fe748c8-a535-4824-aefc-244e3ad34bd4","useHintRect":false},"469f7f21-067c-476c-9bfc-c3ec83b935ea":{"name":"Daily.BoxHomeAP","displayName":"首页体力","type":"hint-box","annotationId":"469f7f21-067c-476c-9bfc-c3ec83b935ea","useHintRect":false},"565df1a0-d494-41a4-a3bf-603857bd8dec":{"name":"Daily.BoxHomeJewel","displayName":"首页珠宝数量","type":"hint-box","annotationId":"565df1a0-d494-41a4-a3bf-603857bd8dec","useHintRect":false},"76c92bd0-496b-403e-b545-92eb1f2f941f":{"name":"Daily.BoxHomeActivelyFunds","displayName":"首页活动费按钮","type":"hint-box","annotationId":"76c92bd0-496b-403e-b545-92eb1f2f941f","useHintRect":false}},"annotations":[{"id":"85c24a02-dac3-4e6c-978e-b11963e0e92d","type":"rect","data":{"x1":179,"y1":937,"x2":551,"y2":1091}},{"id":"4fe748c8-a535-4824-aefc-244e3ad34bd4","type":"rect","data":{"x1":33,"y1":650,"x2":107,"y2":746}},{"id":"469f7f21-067c-476c-9bfc-c3ec83b935ea","type":"rect","data":{"x1":291,"y1":4,"x2":500,"y2":82}},{"id":"565df1a0-d494-41a4-a3bf-603857bd8dec","type":"rect","data":{"x1":500,"y1":7,"x2":703,"y2":82}},{"id":"76c92bd0-496b-403e-b545-92eb1f2f941f","type":"rect","data":{"x1":29,"y1":530,"x2":109,"y2":633}}]}

Binary file not shown.

After

Width:  |  Height:  |  Size: 549 KiB

View File

@ -0,0 +1 @@
{"definitions":{"3942ae40-7f22-412c-aebe-4b064f68db9b":{"name":"Shop.IdolPiece.花海咲季_FightingMyWay","displayName":"","type":"template","annotationId":"3942ae40-7f22-412c-aebe-4b064f68db9b","useHintRect":false},"185f7838-92a7-460b-9340-f60858948ce9":{"name":"Shop.IdolPiece.月村手毬_LunaSayMaybe","displayName":"","type":"template","annotationId":"185f7838-92a7-460b-9340-f60858948ce9","useHintRect":false},"cb3d0ca7-8d14-408a-a2f5-2e25f7b86d6c":{"name":"Shop.IdolPiece.藤田ことね_世界一可愛い私 ","displayName":"","type":"template","annotationId":"cb3d0ca7-8d14-408a-a2f5-2e25f7b86d6c","useHintRect":false},"213016c2-c3a2-43d8-86a3-ab4d27666ced":{"name":"Shop.IdolPiece.花海佑芽_TheRollingRiceball","displayName":"","type":"template","annotationId":"213016c2-c3a2-43d8-86a3-ab4d27666ced","useHintRect":false},"cc60b509-2ed5-493d-bb9f-333c6d2a6006":{"name":"Shop.IdolPiece.葛城リーリヤ_白線","displayName":"","type":"template","annotationId":"cc60b509-2ed5-493d-bb9f-333c6d2a6006","useHintRect":false},"5031808b-5525-4118-92b4-317ec8bda985":{"name":"Shop.IdolPiece.紫云清夏_TameLieOneStep","displayName":"","type":"template","annotationId":"5031808b-5525-4118-92b4-317ec8bda985","useHintRect":false},"ae9fe233-9acc-4e96-ba8e-1fb1d9bc2ea5":{"name":"Shop.IdolPiece.篠泽广_光景","displayName":"","type":"template","annotationId":"ae9fe233-9acc-4e96-ba8e-1fb1d9bc2ea5","useHintRect":false},"8f8b7b46-53bb-42ab-907a-4ea87eb09ab4":{"name":"Shop.IdolPiece.倉本千奈_WonderScale","displayName":"","type":"template","annotationId":"8f8b7b46-53bb-42ab-907a-4ea87eb09ab4","useHintRect":false},"0d9ac648-eefa-4869-ac99-1b0c83649681":{"name":"Shop.IdolPiece.有村麻央_Fluorite","displayName":"","type":"template","annotationId":"0d9ac648-eefa-4869-ac99-1b0c83649681","useHintRect":false}},"annotations":[{"id":"3942ae40-7f22-412c-aebe-4b064f68db9b","type":"rect","data":{"x1":409,"y1":342,"x2":477,"y2":413}},{"id":"185f7838-92a7-460b-9340-f60858948ce9","type":"rect","data":{"x1":71,"y1":512,"x2":140,"y2":585}},{"id":"cb3d0ca7-8d14-408a-a2f5-2e25f7b86d6c","type":"rect","data":{"x1":410,"y1":513,"x2":475,"y2":581}},{"id":"213016c2-c3a2-43d8-86a3-ab4d27666ced","type":"rect","data":{"x1":585,"y1":858,"x2":640,"y2":913}},{"id":"cc60b509-2ed5-493d-bb9f-333c6d2a6006","type":"rect","data":{"x1":247,"y1":690,"x2":303,"y2":743}},{"id":"5031808b-5525-4118-92b4-317ec8bda985","type":"rect","data":{"x1":80,"y1":860,"x2":133,"y2":908}},{"id":"ae9fe233-9acc-4e96-ba8e-1fb1d9bc2ea5","type":"rect","data":{"x1":418,"y1":852,"x2":471,"y2":912}},{"id":"8f8b7b46-53bb-42ab-907a-4ea87eb09ab4","type":"rect","data":{"x1":589,"y1":679,"x2":639,"y2":742}},{"id":"0d9ac648-eefa-4869-ac99-1b0c83649681","type":"rect","data":{"x1":83,"y1":690,"x2":136,"y2":744}}]}

Binary file not shown.

After

Width:  |  Height:  |  Size: 539 KiB

View File

@ -0,0 +1 @@
{"definitions":{"921eefeb-730e-46fc-9924-d338fb286592":{"name":"Shop.IdolPiece.姬崎莉波_clumsy_trick","displayName":"","type":"template","annotationId":"921eefeb-730e-46fc-9924-d338fb286592","useHintRect":false}},"annotations":[{"id":"921eefeb-730e-46fc-9924-d338fb286592","type":"rect","data":{"x1":88,"y1":914,"x2":141,"y2":963}}]}

Binary file not shown.

After

Width:  |  Height:  |  Size: 506 KiB

View File

@ -0,0 +1 @@
{"definitions":{"5d36b880-7b3f-49b1-a018-7de59867d376":{"name":"Daily.TextShopItemPurchased","displayName":"交換しました","type":"template","annotationId":"5d36b880-7b3f-49b1-a018-7de59867d376","useHintRect":false}},"annotations":[{"id":"5d36b880-7b3f-49b1-a018-7de59867d376","type":"rect","data":{"x1":275,"y1":626,"x2":432,"y2":655}}]}

Binary file not shown.

After

Width:  |  Height:  |  Size: 522 KiB

View File

@ -0,0 +1 @@
{"definitions":{"24dc7158-036c-4a66-9280-e934f470be53":{"name":"Daily.TextShopItemSoldOut","displayName":"交換済みです","type":"template","annotationId":"24dc7158-036c-4a66-9280-e934f470be53","useHintRect":false}},"annotations":[{"id":"24dc7158-036c-4a66-9280-e934f470be53","type":"rect","data":{"x1":287,"y1":625,"x2":434,"y2":655}}]}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 405 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 973 B

View File

@ -1 +1 @@
{"definitions":{"6cd80be8-c9b3-4ba5-bf17-3ffc9b000743":{"name":"Produce.ButtonHajime0Regular","displayName":"","type":"template","annotationId":"6cd80be8-c9b3-4ba5-bf17-3ffc9b000743","useHintRect":false},"55f7db71-0a18-4b3d-b847-57959b8d2e32":{"name":"Produce.ButtonHajime0Pro","displayName":"","type":"template","annotationId":"55f7db71-0a18-4b3d-b847-57959b8d2e32","useHintRect":false}},"annotations":[{"id":"6cd80be8-c9b3-4ba5-bf17-3ffc9b000743","type":"rect","data":{"x1":145,"y1":859,"x2":314,"y2":960}},{"id":"55f7db71-0a18-4b3d-b847-57959b8d2e32","type":"rect","data":{"x1":434,"y1":857,"x2":545,"y2":961}}]}
{"definitions":{"6cd80be8-c9b3-4ba5-bf17-3ffc9b000743":{"name":"Produce.ButtonHajime0Regular","displayName":"","type":"template","annotationId":"6cd80be8-c9b3-4ba5-bf17-3ffc9b000743","useHintRect":false},"55f7db71-0a18-4b3d-b847-57959b8d2e32":{"name":"Produce.ButtonHajime0Pro","displayName":"","type":"template","annotationId":"55f7db71-0a18-4b3d-b847-57959b8d2e32","useHintRect":false},"0bf5e34e-afc6-4447-bbac-67026ce2ad26":{"name":"Produce.TitleIconProudce","displayName":"培育页面左上角标题图标","type":"template","annotationId":"0bf5e34e-afc6-4447-bbac-67026ce2ad26","useHintRect":false}},"annotations":[{"id":"6cd80be8-c9b3-4ba5-bf17-3ffc9b000743","type":"rect","data":{"x1":145,"y1":859,"x2":314,"y2":960}},{"id":"55f7db71-0a18-4b3d-b847-57959b8d2e32","type":"rect","data":{"x1":434,"y1":857,"x2":545,"y2":961}},{"id":"0bf5e34e-afc6-4447-bbac-67026ce2ad26","type":"rect","data":{"x1":12,"y1":33,"x2":63,"y2":82}}]}

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
@ -145,6 +155,18 @@ class KotoneBot:
"""
raise NotImplementedError('Implement `_create_device` before using Kotonebot.')
def _on_init_context(self) -> None:
"""
初始化 Context 的钩子方法子类可以重写此方法来自定义初始化逻辑
默认实现调用 init_context 而不传入 target_screenshot_interval
"""
d = self._on_create_device()
init_context(
config_path=self.config_path,
config_type=self.config_type,
target_device=d
)
def _on_after_init_context(self):
"""
抽象方法 init_context() 被调用后立即执行
@ -155,47 +177,84 @@ class KotoneBot:
"""
按优先级顺序运行所有任务
"""
d = self._on_create_device()
init_context(config_path=self.config_path, config_type=self.config_type, target_device=d)
self._on_init_context()
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:
@ -217,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

@ -27,6 +27,7 @@ from cv2.typing import MatLike
from kotonebot.client.device import Device, AndroidDevice, WindowsDevice
from kotonebot.backend.flow_controller import FlowController
from kotonebot.util import Interval
import kotonebot.backend.image as raw_image
from kotonebot.backend.image import (
TemplateMatchResult,
@ -46,12 +47,10 @@ from kotonebot.backend.color import (
from kotonebot.backend.ocr import (
Ocr, OcrResult, OcrResultList, jp, en, StringMatchFunction
)
from kotonebot.client.registration import AdbBasedImpl, create_device
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.client import DeviceImpl
from kotonebot.errors import ContextNotInitializedError, KotonebotWarning
from kotonebot.backend.preprocessor import PreprocessorProtocol
from kotonebot.primitives import Rect
@ -286,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()
@ -302,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,
@ -312,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,
@ -329,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,
@ -344,6 +354,7 @@ class ContextOcr:
*,
rect: Rect | None = None,
hint: HintBox | None = None,
lang: OcrLanguage | None = None,
) -> OcrResult:
"""
@ -351,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
@ -707,21 +719,33 @@ 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)
T_Device = TypeVar('T_Device', bound=Device)
class ContextDevice(Generic[T_Device], Device):
def __init__(self, device: T_Device):
def __init__(self, device: T_Device, target_screenshot_interval: float | None = None):
"""
:param device: 目标设备
:param target_screenshot_interval: `ContextDevice.target_screenshot_interval`
"""
self._device = device
self.target_screenshot_interval: float | None = target_screenshot_interval
"""
目标截图间隔可用于限制截图速度若两次截图实际间隔小于该值则会自动等待
None 时不限制截图速度
"""
self._screenshot_interval: Interval | None = None
if self.target_screenshot_interval is not None:
self._screenshot_interval = Interval(self.target_screenshot_interval)
def screenshot(self, *, force: bool = False):
"""
@ -736,6 +760,9 @@ class ContextDevice(Generic[T_Device], Device):
img = current._inherit_screenshot
current._inherit_screenshot = None
else:
if self._screenshot_interval is not None:
self._screenshot_interval.wait()
if next_wait == 'screenshot':
delta = time.time() - last_screenshot_time
if delta < next_wait_time:
@ -782,7 +809,8 @@ class Context(Generic[T]):
self,
config_path: str,
config_type: Type[T],
device: Device
device: Device,
target_screenshot_interval: float | None = None
):
self.__ocr = ContextOcr(self)
self.__image = ContextImage(self)
@ -790,7 +818,7 @@ class Context(Generic[T]):
self.__vars = ContextGlobalVars()
self.__debug = ContextDebug(self)
self.__config = ContextConfig[T](self, config_path, config_type)
self.__device = ContextDevice(device)
self.__device = ContextDevice(device, target_screenshot_interval)
def inject(
self,
@ -902,6 +930,7 @@ def init_context(
config_type: Type[T] = dict[str, Any],
force: bool = False,
target_device: Device,
target_screenshot_interval: float | None = None,
):
"""
初始化 Context 模块
@ -913,6 +942,7 @@ def init_context(
:param force: 是否强制重新初始化
若为 `True`则忽略已存在的 Context 实例并重新创建一个新的实例
:param target_device: 目标设备
:param target_screenshot_interval: `ContextDevice.target_screenshot_interval`
"""
global _c, device, ocr, image, color, vars, debug, config
if _c is not None and not force:
@ -921,6 +951,7 @@ def init_context(
config_path=config_path,
config_type=config_type,
device=target_device,
target_screenshot_interval=target_screenshot_interval,
)
device._FORWARD_getter = lambda: _c.device # type: ignore
ocr._FORWARD_getter = lambda: _c.ocr # type: ignore
@ -943,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,11 +1,10 @@
from .device import Device
from .registration import create_device, DeviceImpl
from .registration import DeviceImpl
# 确保所有实现都被注册
from . import implements # noqa: F401
__all__ = [
'Device',
'create_device',
'DeviceImpl',
]

View File

@ -9,6 +9,7 @@ from cv2.typing import MatLike
from adbutils._device import AdbDevice as AdbUtilsDevice
from ..backend.debug import result
from ..errors import UnscalableResolutionError
from kotonebot.backend.core import HintBox
from kotonebot.primitives import Rect, Point, is_point
from .protocol import ClickableObjectProtocol, Commandable, Touchable, Screenshotable, AndroidCommandable, WindowsCommandable
@ -28,31 +29,6 @@ class HookContextManager:
def __exit__(self, exc_type, exc_value, traceback):
self.device.screenshot_hook_after = self.old_func
class PinContextManager:
def __init__(self, device: 'Device'):
self.device = device
self.old_hook = device.screenshot_hook_before
self.memo = None
def __hook(self) -> MatLike:
if self.memo is None:
self.memo = self.device.screenshot_raw()
return self.memo
def __enter__(self):
self.device.screenshot_hook_before = self.__hook
return self
def __exit__(self, exc_type, exc_value, traceback):
self.device.screenshot_hook_before = self.old_hook
def update(self) -> None:
"""
更新记住的截图
"""
self.memo = self.device.screenshot_raw()
class Device:
def __init__(self, platform: str = 'unknown') -> None:
self.screenshot_hook_after: Callable[[MatLike], MatLike] | None = None
@ -78,6 +54,31 @@ class Device:
"""
设备平台名称
"""
self.target_resolution: tuple[int, int] | None = None
"""
目标分辨率
若设置则在截图点击滑动等时会缩放到目标分辨率
仅支持等比例缩放若无法等比例缩放则会抛出异常 `UnscalableResolutionError`
"""
self.match_rotation: bool = True
"""
分辨率缩放是否自动匹配旋转
当目标与真实分辨率的宽高比不一致时是否允许通过旋转交换宽高后再进行匹配
True 则忽略方向差异只要宽高比一致就视为可缩放False 则必须匹配旋转
例如当目标分辨率为 1920x1080而真实分辨率为 1080x1920
``match_rotation`` True 则认为可以缩放 False 则会抛出异常
"""
self.aspect_ratio_tolerance: float = 0.1
"""
宽高比容差阈值
判断两分辨率宽高比差异是否接受的阈值
该值越小对比例一致性的要求越严格
默认为 0.1 10% 容差
"""
@property
def adb(self) -> AdbUtilsDevice:
@ -89,6 +90,50 @@ class Device:
def adb(self, value: AdbUtilsDevice) -> None:
self._adb = value
def _scale_pos_real_to_target(self, real_x: int, real_y: int) -> tuple[int, int]:
"""将真实屏幕坐标缩放到目标逻辑坐标"""
if self.target_resolution is None:
return real_x, real_y
real_w, real_h = self.screen_size
target_w, target_h = self.target_resolution
# 校验分辨率是否可缩放并获取调整后的目标分辨率
adjusted_target_w, adjusted_target_h = self.__assert_scalable((real_w, real_h), (target_w, target_h))
scale_w = adjusted_target_w / real_w
scale_h = adjusted_target_h / real_h
return int(real_x * scale_w), int(real_y * scale_h)
def _scale_pos_target_to_real(self, target_x: int, target_y: int) -> tuple[int, int]:
"""将目标逻辑坐标缩放到真实屏幕坐标"""
if self.target_resolution is None:
return target_x, target_y # 输入坐标已是真实坐标
real_w, real_h = self.screen_size
target_w, target_h = self.target_resolution
# 校验分辨率是否可缩放并获取调整后的目标分辨率
adjusted_target_w, adjusted_target_h = self.__assert_scalable((real_w, real_h), (target_w, target_h))
scale_to_real_w = real_w / adjusted_target_w
scale_to_real_h = real_h / adjusted_target_h
return int(target_x * scale_to_real_w), int(target_y * scale_to_real_h)
def __scale_image (self, img: MatLike) -> MatLike:
if self.target_resolution is None:
return img
target_w, target_h = self.target_resolution
h, w = img.shape[:2]
# 校验分辨率是否可缩放并获取调整后的目标分辨率
adjusted_target = self.__assert_scalable((w, h), (target_w, target_h))
return cv2.resize(img, adjusted_target)
@overload
def click(self) -> None:
"""
@ -161,7 +206,12 @@ class Device:
logger.debug(f"Executing click hook before: ({x}, {y})")
x, y = hook(x, y)
logger.debug(f"Click hook before result: ({x}, {y})")
logger.debug(f"Click: {x}, {y}")
if self.target_resolution is not None:
# 输入坐标为逻辑坐标,需要转换为真实坐标
real_x, real_y = self._scale_pos_target_to_real(x, y)
else:
real_x, real_y = x, y
logger.debug(f"Click: {x}, {y}%s", f"(Physical: {real_x}, {real_y})" if self.target_resolution is not None else "")
from ..backend.context import ContextStackVars
if ContextStackVars.current() is not None:
image = ContextStackVars.ensure_current()._screenshot
@ -169,9 +219,11 @@ class Device:
image = np.array([])
if image is not None and image.size > 0:
cv2.circle(image, (x, y), 10, (0, 0, 255), -1)
message = f"point: ({x}, {y})"
message = f"Point: ({x}, {y})"
if self.target_resolution is not None:
message += f" physical: ({real_x}, {real_y})"
result("device.click", image, message)
self._touch.click(x, y)
self._touch.click(real_x, real_y)
def __click_point_tuple(self, point: Point) -> None:
self.click(point[0], point[1])
@ -232,6 +284,10 @@ class Device:
"""
滑动屏幕
"""
if self.target_resolution is not None:
# 输入坐标为逻辑坐标,需要转换为真实坐标
x1, y1 = self._scale_pos_target_to_real(x1, y1)
x2, y2 = self._scale_pos_target_to_real(x2, y2)
self._touch.swipe(x1, y1, x2, y2, duration)
def swipe_scaled(self, x1: float, y1: float, x2: float, y2: float, duration: float|None = None) -> None:
@ -258,6 +314,7 @@ class Device:
logger.debug("screenshot hook before returned image")
return img
img = self.screenshot_raw()
img = self.__scale_image(img)
if self.screenshot_hook_after is not None:
img = self.screenshot_hook_after(img)
return img
@ -274,16 +331,6 @@ class Device:
"""
return HookContextManager(self, func)
@deprecated('改用 @task/@action 装饰器中的 screenshot_mode 参数')
def pinned(self) -> PinContextManager:
"""
记住下次截图结果并将截图调整为手动挡
之后截图都会返回记住的数据节省重复截图时间
调用返回对象中的 PinContextManager.update() 可以立刻更新记住的截图
"""
return PinContextManager(self)
@property
def screen_size(self) -> tuple[int, int]:
"""
@ -296,8 +343,15 @@ class Device:
`self.orientation` 属性默认为竖屏如果需要自动检测
调用 `self.detect_orientation()` 方法
如果已知方向也可以直接设置 `self.orientation` 属性
即使设置了 `self.target_resolution`返回的分辨率仍然是真实分辨率
"""
return self._screenshot.screen_size
size = self._screenshot.screen_size
if self.orientation == 'landscape':
size = sorted(size, reverse=True)
else:
size = sorted(size, reverse=False)
return size[0], size[1]
def detect_orientation(self) -> Literal['portrait', 'landscape'] | None:
"""
@ -307,6 +361,68 @@ class Device:
"""
return self._screenshot.detect_orientation()
def __aspect_ratio_compatible(self, src_size: tuple[int, int], tgt_size: tuple[int, int]) -> bool:
"""
判断两个尺寸在宽高比意义上是否兼容
``self.match_rotation`` True忽略方向长边/短边进行比较
判断标准由 ``self.aspect_ratio_tolerance`` 决定默认 0.1
"""
src_w, src_h = src_size
tgt_w, tgt_h = tgt_size
# 尺寸必须为正
if src_w <= 0 or src_h <= 0:
raise ValueError(f"Source size dimensions must be positive for scaling: {src_size}")
if tgt_w <= 0 or tgt_h <= 0:
raise ValueError(f"Target size dimensions must be positive for scaling: {tgt_size}")
tolerant = self.aspect_ratio_tolerance
# 直接比较宽高比
if abs((tgt_w / src_w) - (tgt_h / src_h)) <= tolerant:
return True
# 尝试忽略方向差异
if self.match_rotation:
ratio_src = max(src_w, src_h) / min(src_w, src_h)
ratio_tgt = max(tgt_w, tgt_h) / min(tgt_w, tgt_h)
return abs(ratio_src - ratio_tgt) <= tolerant
return False
def __assert_scalable(self, source: tuple[int, int], target: tuple[int, int]) -> tuple[int, int]:
"""
校验分辨率是否可缩放并返回调整后的目标分辨率
match_rotation True 且源分辨率与目标分辨率的旋转方向不一致时
自动交换目标分辨率的宽高使其与源分辨率的方向保持一致
:param src_size: 源分辨率 (width, height)
:param tgt_size: 目标分辨率 (width, height)
:return: 调整后的目标分辨率 (width, height)
:raises UnscalableResolutionError: 若宽高比不兼容
"""
# 智能调整目标分辨率方向
adjusted_tgt_size = target
if self.match_rotation:
src_w, src_h = source
tgt_w, tgt_h = target
# 判断源分辨率和目标分辨率的方向
src_is_landscape = src_w > src_h
tgt_is_landscape = tgt_w > tgt_h
# 如果方向不一致,交换目标分辨率的宽高
if src_is_landscape != tgt_is_landscape:
adjusted_tgt_size = (tgt_h, tgt_w)
# 校验调整后的分辨率是否兼容
if not self.__aspect_ratio_compatible(source, adjusted_tgt_size):
raise UnscalableResolutionError(target, source)
return adjusted_tgt_size
class AndroidDevice(Device):
def __init__(self, adb_connection: AdbUtilsDevice | None = None) -> None:

View File

@ -0,0 +1,94 @@
from abc import ABC
from typing import Any, Literal, TypeGuard, TypeVar, get_args
from typing_extensions import assert_never
from adbutils import adb
from adbutils._device import AdbDevice
from kotonebot import logging
from kotonebot.client.device import AndroidDevice
from .protocol import Instance, AdbHostConfig, Device
logger = logging.getLogger(__name__)
AdbRecipes = Literal['adb', 'adb_raw', 'uiautomator2']
def is_adb_recipe(recipe: Any) -> TypeGuard[AdbRecipes]:
return recipe in get_args(AdbRecipes)
def connect_adb(
ip: str,
port: int,
connect: bool = True,
disconnect: bool = True,
timeout: float = 180,
device_serial: str | None = None
) -> AdbDevice:
"""
创建 ADB 连接
"""
if disconnect:
logger.debug('adb disconnect %s:%d', ip, port)
adb.disconnect(f'{ip}:{port}')
if connect:
logger.debug('adb connect %s:%d', ip, port)
result = adb.connect(f'{ip}:{port}')
if 'cannot connect to' in result:
raise ValueError(result)
serial = device_serial or f'{ip}:{port}'
logger.debug('adb wait for %s', serial)
adb.wait_for(serial, timeout=timeout)
devices = adb.device_list()
logger.debug('adb device_list: %s', devices)
d = [d for d in devices if d.serial == serial]
if len(d) == 0:
raise ValueError(f"Device {serial} not found")
d = d[0]
return d
class CommonAdbCreateDeviceMixin(ABC):
"""
通用 ADB 创建设备的 Mixin
Mixin 定义了创建 ADB 设备的通用接口
"""
def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **kwargs)
# 下面的属性只是为了让类型检查通过,无实际实现
self.adb_ip: str
self.adb_port: int
self.adb_name: str
def create_device(self, recipe: AdbRecipes, config: AdbHostConfig) -> Device:
"""
创建 ADB 设备
"""
connection = connect_adb(
self.adb_ip,
self.adb_port,
connect=True,
disconnect=True,
timeout=config.timeout,
device_serial=self.adb_name
)
d = AndroidDevice(connection)
match recipe:
case 'adb':
from kotonebot.client.implements.adb import AdbImpl
impl = AdbImpl(connection)
d._screenshot = impl
d._touch = impl
d.commands = impl
case 'adb_raw':
from kotonebot.client.implements.adb_raw import AdbRawImpl
impl = AdbRawImpl(connection)
d._screenshot = impl
d._touch = impl
d.commands = impl
case 'uiautomator2':
from kotonebot.client.implements.uiautomator2 import UiAutomator2Impl
from kotonebot.client.implements.adb import AdbImpl
impl = UiAutomator2Impl(connection)
d._screenshot = impl
d._touch = impl
d.commands = AdbImpl(connection)
case _:
assert_never(f'Unsupported ADB recipe: {recipe}')
return d

View File

@ -1,22 +1,21 @@
import os
import subprocess
from psutil import process_iter
from .protocol import Instance, AdbHostConfig
from typing import ParamSpec, TypeVar, cast
from .protocol import Instance, AdbHostConfig, HostProtocol
from typing import ParamSpec, TypeVar
from typing_extensions import override
from kotonebot import logging
from kotonebot.client import DeviceImpl
from kotonebot.client.device import Device
from kotonebot.client.registration import AdbBasedImpl, create_device
from kotonebot.client.implements.adb import AdbImplConfig
from kotonebot.client import Device
from .adb_common import AdbRecipes, CommonAdbCreateDeviceMixin
logger = logging.getLogger(__name__)
CustomRecipes = AdbRecipes
P = ParamSpec('P')
T = TypeVar('T')
class CustomInstance(Instance[AdbHostConfig]):
class CustomInstance(CommonAdbCreateDeviceMixin, Instance[AdbHostConfig]):
def __init__(self, exe_path: str | None, emulator_args: str = "", *args, **kwargs):
super().__init__(*args, **kwargs)
self.exe_path: str | None = exe_path
@ -69,24 +68,12 @@ class CustomInstance(Instance[AdbHostConfig]):
pass
@override
def create_device(self, impl: DeviceImpl, host_config: AdbHostConfig) -> Device:
def create_device(self, impl: CustomRecipes, host_config: AdbHostConfig) -> Device:
"""为自定义实例创建 Device。"""
if self.adb_port is None:
raise ValueError("ADB port is not set and is required.")
# 为 ADB 相关的实现创建配置
if impl in ['adb', 'adb_raw', 'uiautomator2']:
config = AdbImplConfig(
addr=f'{self.adb_ip}:{self.adb_port}',
connect=True,
disconnect=True,
device_serial=self.adb_name,
timeout=host_config.timeout
)
impl = cast(AdbBasedImpl, impl) # make pylance happy
return create_device(impl, config)
else:
raise ValueError(f'Unsupported device implementation for Custom: {impl}')
return super().create_device(impl, host_config)
def __repr__(self) -> str:
return f'CustomInstance(#{self.id}# at "{self.exe_path}" with {self.adb_ip}:{self.adb_port})'
@ -99,6 +86,25 @@ def _type_check(ins: Instance) -> CustomInstance:
def create(exe_path: str | None, adb_ip: str, adb_port: int, adb_name: str | None, emulator_args: str = "") -> CustomInstance:
return CustomInstance(exe_path, emulator_args=emulator_args, id='custom', name='Custom', adb_ip=adb_ip, adb_port=adb_port, adb_name=adb_name)
class CustomHost(HostProtocol[CustomRecipes]):
@staticmethod
def installed() -> bool:
# Custom instances don't have a specific installation requirement
return True
@staticmethod
def list() -> list[Instance]:
# Custom instances are created manually, not discovered
return []
@staticmethod
def query(*, id: str) -> Instance | None:
# Custom instances are created manually, not discovered
return None
@staticmethod
def recipes() -> 'list[CustomRecipes]':
return ['adb', 'adb_raw', 'uiautomator2']
if __name__ == '__main__':
ins = create(r'C:\Program Files\BlueStacks_nxt\HD-Player.exe', '127.0.0.1', 5555, '**emulator-name**')

View File

@ -1,18 +1,17 @@
import os
import subprocess
from typing import cast
from typing import Literal
from functools import lru_cache
from typing_extensions import override
from kotonebot import logging
from kotonebot.client import DeviceImpl
from kotonebot.client.device import Device
from kotonebot.client.registration import AdbBasedImpl, create_device
from kotonebot.client.implements.adb import AdbImplConfig
from kotonebot.client import Device
from kotonebot.util import Countdown, Interval
from .protocol import HostProtocol, Instance, copy_type, AdbHostConfig
from .adb_common import AdbRecipes, CommonAdbCreateDeviceMixin
logger = logging.getLogger(__name__)
LeidianRecipes = AdbRecipes
if os.name == 'nt':
from ...interop.win.reg import read_reg
@ -21,7 +20,7 @@ else:
"""Stub for read_reg on non-Windows platforms."""
return default
class LeidianInstance(Instance[AdbHostConfig]):
class LeidianInstance(CommonAdbCreateDeviceMixin, Instance[AdbHostConfig]):
@copy_type(Instance.__init__)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
@ -64,35 +63,23 @@ class LeidianInstance(Instance[AdbHostConfig]):
it = Interval(5)
while not cd.expired() and not self.running():
it.wait()
self.refresh()
if not self.running():
raise TimeoutError(f'Leidian instance "{self.name}" is not available.')
@override
def running(self) -> bool:
result = LeidianHost._invoke_manager(['isrunning', '--index', str(self.index)])
return result.strip() == 'running'
return self.is_running
@override
def create_device(self, impl: DeviceImpl, host_config: AdbHostConfig) -> Device:
def create_device(self, impl: LeidianRecipes, host_config: AdbHostConfig) -> Device:
"""为雷电模拟器实例创建 Device。"""
if self.adb_port is None:
raise ValueError("ADB port is not set and is required.")
# 为 ADB 相关的实现创建配置
if impl in ['adb', 'adb_raw', 'uiautomator2']:
config = AdbImplConfig(
addr=f'{self.adb_ip}:{self.adb_port}',
connect=False, # 雷电模拟器不需要 adb connect
disconnect=False,
device_serial=self.adb_name,
timeout=host_config.timeout
)
impl = cast(AdbBasedImpl, impl) # make pylance happy
return create_device(impl, config)
else:
raise ValueError(f'Unsupported device implementation for Leidian: {impl}')
return super().create_device(impl, host_config)
class LeidianHost(HostProtocol):
class LeidianHost(HostProtocol[LeidianRecipes]):
@staticmethod
@lru_cache(maxsize=1)
def _read_install_path() -> str | None:
@ -197,6 +184,10 @@ class LeidianHost(HostProtocol):
return instance
return None
@staticmethod
def recipes() -> 'list[LeidianRecipes]':
return ['adb', 'adb_raw', 'uiautomator2']
if __name__ == '__main__':
logging.basicConfig(level=logging.DEBUG, format='[%(asctime)s] [%(levelname)s] [%(name)s] [%(funcName)s] [%(lineno)d] %(message)s')
print(LeidianHost._read_install_path())

View File

@ -1,18 +1,19 @@
from dataclasses import dataclass
import os
import json
import subprocess
from functools import lru_cache
from typing import Any, cast
from typing import Any, Literal, overload
from typing_extensions import override
from kotonebot import logging
from kotonebot.client import DeviceImpl, Device
from kotonebot.client.registration import AdbBasedImpl, create_device
from kotonebot.client.implements.adb import AdbImplConfig
from kotonebot.client import Device
from kotonebot.client.device import AndroidDevice
from kotonebot.client.implements.adb import AdbImpl
from kotonebot.client.implements.nemu_ipc import NemuIpcImpl, NemuIpcImplConfig
from kotonebot.util import Countdown, Interval
from .protocol import HostProtocol, Instance, copy_type, AdbHostConfig
logger = logging.getLogger(__name__)
from .adb_common import AdbRecipes, CommonAdbCreateDeviceMixin, connect_adb, is_adb_recipe
if os.name == 'nt':
from ...interop.win.reg import read_reg
@ -21,7 +22,21 @@ else:
"""Stub for read_reg on non-Windows platforms."""
return default
class Mumu12Instance(Instance[AdbHostConfig]):
logger = logging.getLogger(__name__)
MuMu12Recipes = AdbRecipes | Literal['nemu_ipc']
@dataclass
class MuMu12HostConfig(AdbHostConfig):
"""nemu_ipc 能力的配置模型。"""
display_id: int | None = 0
"""目标显示器 ID默认为 0主显示器。若为 None 且设置了 target_package_name则自动获取对应的 display_id。"""
target_package_name: str | None = None
"""目标应用包名,用于自动获取 display_id。"""
app_index: int = 0
"""多开应用索引,传给 get_display_id 方法。"""
class Mumu12Instance(CommonAdbCreateDeviceMixin, Instance[MuMu12HostConfig]):
@copy_type(Instance.__init__)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
@ -72,34 +87,58 @@ class Mumu12Instance(Instance[AdbHostConfig]):
def running(self) -> bool:
return self.is_android_started
@overload
def create_device(self, recipe: Literal['nemu_ipc'], host_config: MuMu12HostConfig) -> Device: ...
@overload
def create_device(self, recipe: AdbRecipes, host_config: AdbHostConfig) -> Device: ...
@override
def create_device(self, impl: DeviceImpl, host_config: AdbHostConfig) -> Device:
def create_device(self, recipe: MuMu12Recipes, host_config: MuMu12HostConfig | AdbHostConfig) -> Device:
"""为MuMu12模拟器实例创建 Device。"""
if self.adb_port is None:
raise ValueError("ADB port is not set and is required.")
# 为 ADB 相关的实现创建配置
if impl in ['adb', 'adb_raw', 'uiautomator2']:
config = AdbImplConfig(
addr=f'{self.adb_ip}:{self.adb_port}',
connect=True,
disconnect=True,
device_serial=self.adb_name,
timeout=host_config.timeout
if recipe == 'nemu_ipc' and isinstance(host_config, MuMu12HostConfig):
# NemuImpl
nemu_path = Mumu12Host._read_install_path()
if not nemu_path:
raise RuntimeError("无法找到 MuMu12 的安装路径。")
nemu_config = NemuIpcImplConfig(
nemu_folder=nemu_path,
instance_id=int(self.id),
display_id=host_config.display_id,
target_package_name=host_config.target_package_name,
app_index=host_config.app_index
)
impl = cast(AdbBasedImpl, impl) # make pylance happy
return create_device(impl, config)
else:
raise ValueError(f'Unsupported device implementation for MuMu12: {impl}')
nemu_impl = NemuIpcImpl(nemu_config)
# AdbImpl
adb_impl = AdbImpl(connect_adb(
self.adb_ip,
self.adb_port,
timeout=host_config.timeout,
device_serial=self.adb_name
))
device = AndroidDevice()
device._screenshot = nemu_impl
device._touch = nemu_impl
device.commands = adb_impl
class Mumu12Host(HostProtocol):
return device
elif isinstance(host_config, AdbHostConfig) and is_adb_recipe(recipe):
return super().create_device(recipe, host_config)
else:
raise ValueError(f'Unknown recipe: {recipe}')
class Mumu12Host(HostProtocol[MuMu12Recipes]):
@staticmethod
@lru_cache(maxsize=1)
def _read_install_path() -> str | None:
"""
Reads the installation path (DisplayIcon) of MuMu Player 12 from the registry.
r"""
从注册表中读取 MuMu Player 12 的安装路径
:return: The path to the display icon if found, otherwise None.
返回的路径为根目录 `F:\Apps\Netease\MuMuPlayer-12.0`
:return: 若找到则返回安装路径否则返回 None
"""
if os.name != 'nt':
return None
@ -116,6 +155,9 @@ class Mumu12Host(HostProtocol):
icon_path = icon_path.replace('"', '')
path = os.path.dirname(icon_path)
logger.debug('MuMu Player 12 installation path: %s', path)
# 返回根目录(去掉 shell 子目录)
if os.path.basename(path).lower() == 'shell':
path = os.path.dirname(path)
return path
return None
@ -130,7 +172,7 @@ class Mumu12Host(HostProtocol):
install_path = Mumu12Host._read_install_path()
if install_path is None:
raise RuntimeError('MuMu Player 12 is not installed.')
manager_path = os.path.join(install_path, 'MuMuManager.exe')
manager_path = os.path.join(install_path, 'shell', 'MuMuManager.exe')
logger.debug('MuMuManager execute: %s', repr(args))
output = subprocess.run(
[manager_path] + args,
@ -184,6 +226,10 @@ class Mumu12Host(HostProtocol):
if instance.id == id:
return instance
return None
@staticmethod
def recipes() -> 'list[MuMu12Recipes]':
return ['adb', 'adb_raw', 'uiautomator2', 'nemu_ipc']
if __name__ == '__main__':
logging.basicConfig(level=logging.DEBUG, format='[%(asctime)s] [%(levelname)s] [%(name)s] [%(funcName)s] [%(lineno)d] %(message)s')

View File

@ -195,7 +195,8 @@ class Instance(Generic[T_HostConfig], ABC):
def __repr__(self) -> str:
return f'{self.__class__.__name__}(name="{self.name}", id="{self.id}", adb="{self.adb_ip}:{self.adb_port}"({self.adb_name}))'
class HostProtocol(Protocol):
Recipe = TypeVar('Recipe', bound=str)
class HostProtocol(Generic[Recipe], Protocol):
@staticmethod
def installed() -> bool: ...
@ -205,6 +206,8 @@ class HostProtocol(Protocol):
@staticmethod
def query(*, id: str) -> Instance | None: ...
@staticmethod
def recipes() -> 'list[Recipe]': ...
if __name__ == '__main__':
pass

View File

@ -0,0 +1,55 @@
from abc import ABC
from typing import Literal
from typing_extensions import assert_never
from kotonebot import logging
from kotonebot.client.device import WindowsDevice
from .protocol import Device, WindowsHostConfig, RemoteWindowsHostConfig
logger = logging.getLogger(__name__)
WindowsRecipes = Literal['windows', 'remote_windows']
# Windows 相关的配置类型联合
WindowsHostConfigs = WindowsHostConfig | RemoteWindowsHostConfig
class CommonWindowsCreateDeviceMixin(ABC):
"""
通用 Windows 创建设备的 Mixin
Mixin 定义了创建 Windows 设备的通用接口
"""
def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **kwargs)
def create_device(self, recipe: WindowsRecipes, config: WindowsHostConfigs) -> Device:
"""
创建 Windows 设备
"""
match recipe:
case 'windows':
if not isinstance(config, WindowsHostConfig):
raise ValueError(f"Expected WindowsHostConfig for 'windows' recipe, got {type(config)}")
from kotonebot.client.implements.windows import WindowsImpl
d = WindowsDevice()
impl = WindowsImpl(
device=d,
window_title=config.window_title,
ahk_exe_path=config.ahk_exe_path
)
d._screenshot = impl
d._touch = impl
return d
case 'remote_windows':
if not isinstance(config, RemoteWindowsHostConfig):
raise ValueError(f"Expected RemoteWindowsHostConfig for 'remote_windows' recipe, got {type(config)}")
from kotonebot.client.implements.remote_windows import RemoteWindowsImpl
d = WindowsDevice()
impl = RemoteWindowsImpl(
device=d,
host=config.host,
port=config.port
)
d._screenshot = impl
d._touch = impl
return d
case _:
assert_never(f'Unsupported Windows recipe: {recipe}')

View File

@ -3,4 +3,5 @@ from . import adb # noqa: F401
from . import adb_raw # noqa: F401
from . import remote_windows # noqa: F401
from . import uiautomator2 # noqa: F401
from . import windows # noqa: F401
from . import windows # noqa: F401
from . import nemu_ipc # noqa: F401

View File

@ -9,7 +9,7 @@ from adbutils._device import AdbDevice as AdbUtilsDevice
from ..device import AndroidDevice
from ..protocol import AndroidCommandable, Touchable, Screenshotable
from ..registration import register_impl, ImplConfig
from ..registration import ImplConfig
from dataclasses import dataclass
logger = logging.getLogger(__name__)
@ -83,46 +83,3 @@ class AdbImpl(AndroidCommandable, Touchable, Screenshotable):
if duration is not None:
logger.warning("Swipe duration is not supported with AdbDevice. Ignoring duration.")
self.adb.shell(f"input touchscreen swipe {x1} {y1} {x2} {y2}")
def _create_adb_device_base(config: AdbImplConfig, impl_class: type) -> AndroidDevice:
"""
通用的 ADB 设备创建工厂函数
其他任意基于 ADB Impl 可以直接复用这个函数
:param config: ADB 实现配置
:param impl_class: 实现类或工厂函数构造函数接收 adb_connection 参数
"""
from adbutils import adb
if config.disconnect:
logger.debug('adb disconnect %s', config.addr)
adb.disconnect(config.addr)
if config.connect:
logger.debug('adb connect %s', config.addr)
result = adb.connect(config.addr)
if 'cannot connect to' in result:
raise ValueError(result)
serial = config.device_serial or config.addr
logger.debug('adb wait for %s', serial)
adb.wait_for(serial, timeout=config.timeout)
devices = adb.device_list()
logger.debug('adb device_list: %s', devices)
d = [d for d in devices if d.serial == serial]
if len(d) == 0:
raise ValueError(f"Device {config.addr} not found")
d = d[0]
device = AndroidDevice(d)
impl = impl_class(d)
device._touch = impl
device._screenshot = impl
device.commands = impl
return device
@register_impl('adb', config_model=AdbImplConfig)
def create_adb_device(config: AdbImplConfig) -> AndroidDevice:
"""AdbImpl 工厂函数"""
return _create_adb_device_base(config, AdbImpl)

View File

@ -11,9 +11,8 @@ import numpy as np
from cv2.typing import MatLike
from adbutils._utils import adb_path
from .adb import AdbImpl, AdbImplConfig, _create_adb_device_base
from .adb import AdbImpl
from adbutils._device import AdbDevice as AdbUtilsDevice
from ..registration import register_impl
from kotonebot import logging
logger = logging.getLogger(__name__)
@ -157,10 +156,4 @@ class AdbRawImpl(AdbImpl):
logger.verbose(f"adb raw screenshot wait time: {time.time() - start_time:.4f}s")
data = self.__data
self.__data = None
return data
@register_impl('adb_raw', config_model=AdbImplConfig)
def create_adb_raw_device(config: AdbImplConfig):
"""AdbRawImpl 工厂函数"""
return _create_adb_device_base(config, AdbRawImpl)
return data

View File

@ -0,0 +1,8 @@
from .external_renderer_ipc import ExternalRendererIpc
from .nemu_ipc import NemuIpcImpl, NemuIpcImplConfig
__all__ = [
"ExternalRendererIpc",
"NemuIpcImpl",
"NemuIpcImplConfig",
]

View File

@ -0,0 +1,280 @@
import ctypes
import logging
import os
logger = logging.getLogger(__name__)
class NemuIpcIncompatible(RuntimeError):
"""MuMu12 IPC 环境不兼容或 DLL 加载失败"""
class ExternalRendererIpc:
r"""对 `external_renderer_ipc.dll` 的轻量封装。
该类仅处理 DLL 加载与原型声明并提供带有类型提示的薄包装方法
方便在其他模块中调用且保持类型安全
传入参数为 MuMu 根目录 F:\Apps\Netease\MuMuPlayer-12.0
"""
def __init__(self, mumu_root_folder: str):
if os.name != "nt":
raise NemuIpcIncompatible("ExternalRendererIpc only supports Windows.")
self.lib = self.__load_dll(mumu_root_folder)
self.raise_on_error: bool = True
"""是否在调用 DLL 函数失败时抛出异常。"""
self.__declare_prototypes()
def connect(self, nemu_folder: str, instance_id: int) -> int:
"""
建立连接
API 原型
`int nemu_connect(const wchar_t* path, int index)`
:param nemu_folder: 模拟器安装路径
:param instance_id: 模拟器实例 ID
:return: 成功返回连接 ID失败返回 0
"""
return self.lib.nemu_connect(nemu_folder, instance_id)
def disconnect(self, connect_id: int) -> None:
"""
断开连接
API 原型
`void nemu_disconnect(int handle)`
:param connect_id: 连接 ID
:return: 无返回值
"""
return self.lib.nemu_disconnect(connect_id)
def get_display_id(self, connect_id: int, pkg: str, app_index: int) -> int:
"""
获取指定包的 display id
API 原型
`int nemu_get_display_id(int handle, const char* pkg, int appIndex)`
:param connect_id: 连接 ID
:param pkg: 包名
:param app_index: 多开应用索引
:return: <0 表示失败>=0 表示有效 display id
"""
return self.lib.nemu_get_display_id(connect_id, pkg.encode('utf-8'), app_index)
def capture_display(
self,
connect_id: int,
display_id: int,
buf_len: int,
width_ptr: ctypes.c_void_p,
height_ptr: ctypes.c_void_p,
buffer_ptr: ctypes.c_void_p,
) -> int:
"""
截取指定显示屏内容
API 原型
`int nemu_capture_display(int handle, unsigned int displayid, int buffer_size, int *width, int *height, unsigned char* pixels)`
:param connect_id: 连接 ID
:param display_id: 显示屏 ID
:param buf_len: 缓冲区长度字节
:param width_ptr: 用于接收宽度的指针ctypes.c_void_p/int 指针
:param height_ptr: 用于接收高度的指针ctypes.c_void_p/int 指针
:param buffer_ptr: 用于接收像素数据的指针ctypes.c_void_p/unsigned char* 指针
:return: 0 表示成功>0 表示失败
"""
return self.lib.nemu_capture_display(
connect_id,
display_id,
buf_len,
width_ptr,
height_ptr,
buffer_ptr,
)
def input_text(self, connect_id: int, text: str) -> int:
"""
输入文本
API 原型
`int nemu_input_text(int handle, int size, const char* buf)`
:param connect_id: 连接 ID
:param text: 输入文本utf-8
:return: 0 表示成功>0 表示失败
"""
buf = text.encode('utf-8')
return self.lib.nemu_input_text(connect_id, len(buf), buf)
def input_touch_down(self, connect_id: int, display_id: int, x: int, y: int) -> int:
"""
发送触摸按下事件
API 原型
`int nemu_input_event_touch_down(int handle, int displayid, int x_point, int y_point)`
:param connect_id: 连接 ID
:param display_id: 显示屏 ID
:param x: 触摸点 X 坐标
:param y: 触摸点 Y 坐标
:return: 0 表示成功>0 表示失败
"""
return self.lib.nemu_input_event_touch_down(connect_id, display_id, x, y)
def input_touch_up(self, connect_id: int, display_id: int) -> int:
"""
发送触摸抬起事件
API 原型
`int nemu_input_event_touch_up(int handle, int displayid)`
:param connect_id: 连接 ID
:param display_id: 显示屏 ID
:return: 0 表示成功>0 表示失败
"""
return self.lib.nemu_input_event_touch_up(connect_id, display_id)
def input_key_down(self, connect_id: int, display_id: int, key_code: int) -> int:
"""
发送按键按下事件
API 原型
`int nemu_input_event_key_down(int handle, int displayid, int key_code)`
:param connect_id: 连接 ID
:param display_id: 显示屏 ID
:param key_code: 按键码
:return: 0 表示成功>0 表示失败
"""
return self.lib.nemu_input_event_key_down(connect_id, display_id, key_code)
def input_key_up(self, connect_id: int, display_id: int, key_code: int) -> int:
"""
发送按键抬起事件
API 原型
`int nemu_input_event_key_up(int handle, int displayid, int key_code)`
:param connect_id: 连接 ID
:param display_id: 显示屏 ID
:param key_code: 按键码
:return: 0 表示成功>0 表示失败
"""
return self.lib.nemu_input_event_key_up(connect_id, display_id, key_code)
def input_finger_touch_down(self, connect_id: int, display_id: int, finger_id: int, x: int, y: int) -> int:
"""
多指触摸按下
API 原型
`int nemu_input_event_finger_touch_down(int handle, int displayid, int finger_id, int x_point, int y_point)`
:param connect_id: 连接 ID
:param display_id: 显示屏 ID
:param finger_id: 手指编号1-10
:param x: 触摸点 X 坐标
:param y: 触摸点 Y 坐标
:return: 0 表示成功>0 表示失败
"""
return self.lib.nemu_input_event_finger_touch_down(connect_id, display_id, finger_id, x, y)
def input_finger_touch_up(self, connect_id: int, display_id: int, finger_id: int) -> int:
"""
多指触摸抬起
API 原型
`int nemu_input_event_finger_touch_up(int handle, int displayid, int slot_id)`
:param connect_id: 连接 ID
:param display_id: 显示屏 ID
:param finger_id: 手指编号1-10
:return: 0 表示成功>0 表示失败
"""
return self.lib.nemu_input_event_finger_touch_up(connect_id, display_id, finger_id)
# ------------------------------------------------------------------
# 内部工具
# ------------------------------------------------------------------
def __load_dll(self, mumu_root_folder: str) -> ctypes.CDLL:
"""尝试多条路径加载 DLL。传入为 MuMu 根目录。"""
candidate_paths = [
os.path.join(mumu_root_folder, "shell", "sdk", "external_renderer_ipc.dll"),
os.path.join(
mumu_root_folder,
"shell",
"nx_device",
"12.0",
"sdk",
"external_renderer_ipc.dll",
),
]
for p in candidate_paths:
if not os.path.exists(p):
continue
try:
return ctypes.CDLL(p)
except OSError as e: # pragma: no cover
logger.warning("Failed to load DLL (%s): %s", p, e)
raise NemuIpcIncompatible("external_renderer_ipc.dll not found or failed to load.")
def __declare_prototypes(self) -> None:
"""声明 DLL 函数原型,确保 ctypes 类型安全。"""
# 连接 / 断开
self.lib.nemu_connect.argtypes = [ctypes.c_wchar_p, ctypes.c_int]
self.lib.nemu_connect.restype = ctypes.c_int
self.lib.nemu_disconnect.argtypes = [ctypes.c_int]
self.lib.nemu_disconnect.restype = None
# 获取 display id
self.lib.nemu_get_display_id.argtypes = [ctypes.c_int, ctypes.c_char_p, ctypes.c_int]
self.lib.nemu_get_display_id.restype = ctypes.c_int
# 截图
self.lib.nemu_capture_display.argtypes = [
ctypes.c_int,
ctypes.c_uint,
ctypes.c_int,
ctypes.c_void_p,
ctypes.c_void_p,
ctypes.c_void_p,
]
self.lib.nemu_capture_display.restype = ctypes.c_int
# 输入文本
self.lib.nemu_input_text.argtypes = [ctypes.c_int, ctypes.c_int, ctypes.c_char_p]
self.lib.nemu_input_text.restype = ctypes.c_int
# 触摸
self.lib.nemu_input_event_touch_down.argtypes = [
ctypes.c_int,
ctypes.c_int,
ctypes.c_int,
ctypes.c_int,
]
self.lib.nemu_input_event_touch_down.restype = ctypes.c_int
self.lib.nemu_input_event_touch_up.argtypes = [ctypes.c_int, ctypes.c_int]
self.lib.nemu_input_event_touch_up.restype = ctypes.c_int
# 按键
self.lib.nemu_input_event_key_down.argtypes = [ctypes.c_int, ctypes.c_int, ctypes.c_int]
self.lib.nemu_input_event_key_down.restype = ctypes.c_int
self.lib.nemu_input_event_key_up.argtypes = [ctypes.c_int, ctypes.c_int, ctypes.c_int]
self.lib.nemu_input_event_key_up.restype = ctypes.c_int
# 多指触摸
self.lib.nemu_input_event_finger_touch_down.argtypes = [ctypes.c_int, ctypes.c_int, ctypes.c_int, ctypes.c_int, ctypes.c_int]
self.lib.nemu_input_event_finger_touch_down.restype = ctypes.c_int
self.lib.nemu_input_event_finger_touch_up.argtypes = [ctypes.c_int, ctypes.c_int, ctypes.c_int]
self.lib.nemu_input_event_finger_touch_up.restype = ctypes.c_int
logger.debug("DLL function prototypes declared")

View File

@ -0,0 +1,327 @@
import os
import ctypes
import logging
import time
from dataclasses import dataclass
from time import sleep
from typing import Literal
from typing_extensions import override
import cv2
import numpy as np
from cv2.typing import MatLike
from ...device import AndroidDevice, Device
from ...protocol import Touchable, Screenshotable
from ...registration import ImplConfig
from .external_renderer_ipc import ExternalRendererIpc
from kotonebot.errors import KotonebotError
logger = logging.getLogger(__name__)
class NemuIpcIncompatible(Exception):
"""MuMu12 版本过低或 dll 不兼容"""
pass
class NemuIpcError(KotonebotError):
"""调用 IPC 过程中发生错误"""
pass
@dataclass
class NemuIpcImplConfig(ImplConfig):
"""nemu_ipc 能力的配置模型。"""
nemu_folder: str
r"""MuMu12 根目录(如 F:\Apps\Netease\MuMuPlayer-12.0)。"""
instance_id: int
"""模拟器实例 ID。"""
display_id: int | None = 0
"""目标显示器 ID默认为 0主显示器。若为 None 且设置了 target_package_name则自动获取对应的 display_id。"""
target_package_name: str | None = None
"""目标应用包名,用于自动获取 display_id。"""
app_index: int = 0
"""多开应用索引,传给 get_display_id 方法。"""
wait_package_timeout: float = 60 # 单位秒,-1 表示永远等待0 表示不等待,立即抛出异常
wait_package_interval: float = 0.1 # 单位秒
class NemuIpcImpl(Touchable, Screenshotable):
"""
利用 MuMu12 提供的 external_renderer_ipc.dll 进行截图与触摸控制
"""
def __init__(self, config: NemuIpcImplConfig):
self.config = config
self.__width: int = 0
self.__height: int = 0
self.__connected: bool = False
self._connect_id: int = 0
self.nemu_folder = config.nemu_folder
# --------------------------- DLL 封装 ---------------------------
self._ipc = ExternalRendererIpc(config.nemu_folder)
logger.info("ExternalRendererIpc initialized and DLL loaded")
@property
def width(self) -> int:
"""
屏幕宽度
若为 0表示未连接或未获取到分辨率
"""
return self.__width
@property
def height(self) -> int:
"""
屏幕高度
若为 0表示未连接或未获取到分辨率
"""
return self.__height
@property
def connected(self) -> bool:
"""是否已连接。"""
return self.__connected
# ------------------------------------------------------------------
# 基础控制
# ------------------------------------------------------------------
def _ensure_connected(self) -> None:
if not self.__connected:
self.connect()
def _get_display_id(self) -> int:
"""获取有效的 display_id。"""
# 如果配置中直接指定了 display_id直接返回
if self.config.display_id is not None:
return self.config.display_id
# 如果设置了 target_package_name实时获取 display_id
if self.config.target_package_name:
self._ensure_connected()
timeout = self.config.wait_package_timeout
interval = self.config.wait_package_interval
if timeout == -1:
timeout = float('inf')
start_time = time.time()
while True:
display_id = self._ipc.get_display_id(
self._connect_id,
self.config.target_package_name,
self.config.app_index
)
if display_id >= 0:
return display_id
elif display_id == -1:
# 可以继续等
pass
else:
# 未知错误
raise NemuIpcError(f"Failed to get display_id for package '{self.config.target_package_name}', error code={display_id}")
if time.time() - start_time > timeout:
break
sleep(interval)
raise NemuIpcError(f"Failed to get display_id for package '{self.config.target_package_name}' within {timeout}s")
# 如果都没有设置,抛出错误
raise NemuIpcError("display_id is None and target_package_name is not set. Please set display_id or target_package_name in config.")
def connect(self) -> None:
"""连接模拟器。"""
if self.__connected:
return
connect_id = self._ipc.connect(self.nemu_folder, self.config.instance_id)
if connect_id == 0:
raise NemuIpcError("nemu_connect failed, please check if the emulator is running and the instance ID is correct.")
self._connect_id = connect_id
self.__connected = True
logger.debug("NemuIpc connected, connect_id=%d", connect_id)
def disconnect(self) -> None:
"""断开连接。"""
if not self.__connected:
return
self._ipc.disconnect(self._connect_id)
self.__connected = False
self._connect_id = 0
logger.debug("NemuIpc disconnected.")
# ------------------------------------------------------------------
# Screenshotable 接口实现
# ------------------------------------------------------------------
@property
def screen_size(self) -> tuple[int, int]:
"""获取屏幕分辨率。"""
if self.__width == 0 or self.__height == 0:
self._refresh_resolution()
if self.__width == 0 or self.__height == 0:
raise NemuIpcError("Screen resolution not obtained, please connect to the emulator first.")
return self.__width, self.__height
@override
def detect_orientation(self):
return self.get_display_orientation(self._get_display_id())
def get_display_orientation(self, display_id: int = 0) -> Literal['portrait', 'landscape'] | None:
"""获取指定显示屏的方向。"""
width, height = self.query_resolution(display_id)
if width > height:
return "landscape"
if height > width:
return "portrait"
return None
@override
def screenshot(self) -> MatLike:
self._ensure_connected()
# 必须每次都更新分辨率,因为屏幕可能会旋转
self._refresh_resolution()
length = self.__width * self.__height * 4 # RGBA
buf_type = ctypes.c_ubyte * length
buffer = buf_type()
w_ptr = ctypes.pointer(ctypes.c_int(self.__width))
h_ptr = ctypes.pointer(ctypes.c_int(self.__height))
ret = self._ipc.capture_display(
self._connect_id,
self._get_display_id(),
length,
ctypes.cast(w_ptr, ctypes.c_void_p),
ctypes.cast(h_ptr, ctypes.c_void_p),
ctypes.cast(buffer, ctypes.c_void_p),
)
if ret != 0:
raise NemuIpcError(f"nemu_capture_display screenshot failed, error code={ret}")
# 读入并转换数据
img = np.ctypeslib.as_array(buffer).reshape((self.__height, self.__width, 4))
# RGBA -> BGR
img = cv2.cvtColor(img, cv2.COLOR_RGBA2BGR)
cv2.flip(img, 0, dst=img)
return img
# --------------------------- 内部工具 -----------------------------
def _refresh_resolution(self) -> None:
"""刷新分辨率信息。"""
display_id = self._get_display_id()
self.__width, self.__height = self.query_resolution(display_id)
def query_resolution(self, display_id: int = 0) -> tuple[int, int]:
"""
查询指定显示屏的分辨率
:param display_id: 显示屏 ID
:return: 分辨率 (width, height)
:raise NemuIpcError: 查询失败
"""
self._ensure_connected()
w_ptr = ctypes.pointer(ctypes.c_int(0))
h_ptr = ctypes.pointer(ctypes.c_int(0))
ret = self._ipc.capture_display(
self._connect_id,
display_id,
0,
ctypes.cast(w_ptr, ctypes.c_void_p),
ctypes.cast(h_ptr, ctypes.c_void_p),
ctypes.c_void_p(),
)
if ret != 0:
raise NemuIpcError(f"Call nemu_capture_display failed. Return value={ret}")
return w_ptr.contents.value, h_ptr.contents.value
# ------------------------------------------------------------------
# Touchable 接口实现
# ------------------------------------------------------------------
def __convert_pos(self, x: int, y: int) -> tuple[int, int]:
# Android 显示屏有两套坐标:逻辑坐标与物理坐标。
# 逻辑坐标原点始终是画面左上角,而物理坐标原点则始终是显示屏的左上角。
# 如果屏幕画面旋转,会导致两个坐标的原点不同,坐标也不同。
# ========
# 这里传给 MuMu 的是逻辑坐标ExternalRendererIpc DLL 内部会
# 自动判断旋转,并转换为物理坐标。但是这部分有个 bug
# 旋转没有考虑到多显示器,只是以主显示器为准,若两个显示器旋转不一致,
# 会导致错误地转换坐标。因此需要在 Python 层面 workaround 这个问题。
# 通过判断主显示器与当前显示器的旋转,将坐标进行预转换,抵消 DLL 层的错误转换。
display_id = self._get_display_id()
if display_id == 0:
return x, y
else:
primary = self.get_display_orientation(0)
primary_size = self.query_resolution(0)
current = self.get_display_orientation(display_id)
if primary == current:
return x, y
else:
# 如果旋转不一致,视为顺时针旋转了 90°
# 因此我们要提前逆时针旋转 90°
self._refresh_resolution()
x, y = y, primary_size[1] - x
return x, y
@override
def click(self, x: int, y: int) -> None:
self._ensure_connected()
display_id = self._get_display_id()
x, y = self.__convert_pos(x, y)
self._ipc.input_touch_down(self._connect_id, display_id, x, y)
sleep(0.01)
self._ipc.input_touch_up(self._connect_id, display_id)
@override
def swipe(
self,
x1: int,
y1: int,
x2: int,
y2: int,
duration: float | None = None,
) -> None:
self._ensure_connected()
duration = duration or 0.3
steps = max(int(duration / 0.01), 2)
display_id = self._get_display_id()
x1, y1 = self.__convert_pos(x1, y1)
x2, y2 = self.__convert_pos(x2, y2)
xs = np.linspace(x1, x2, steps, dtype=int)
ys = np.linspace(y1, y2, steps, dtype=int)
# 按下第一点
self._ipc.input_touch_down(self._connect_id, display_id, xs[0], ys[0])
sleep(0.01)
# 中间移动
for px, py in zip(xs[1:-1], ys[1:-1]):
self._ipc.input_touch_down(self._connect_id, display_id, px, py)
sleep(0.01)
# 最终抬起
self._ipc.input_touch_up(self._connect_id, display_id)
sleep(0.01)
if __name__ == '__main__':
nemu = NemuIpcImpl(NemuIpcImplConfig(
r'F:\Apps\Netease\MuMuPlayer-12.0', 0, None,
target_package_name='com.android.chrome',
))
nemu.connect()
# while True:
# nemu.click(0, 0)
nemu.click(100, 100)
nemu.click(100*3, 100)
nemu.click(100*3, 100*3)

View File

@ -23,8 +23,8 @@ from cv2.typing import MatLike
from kotonebot import logging
from ..device import Device, WindowsDevice
from ..protocol import Touchable, Screenshotable
from ..registration import register_impl, ImplConfig
from .windows import WindowsImpl, WindowsImplConfig, create_windows_device
from ..registration import ImplConfig
from .windows import WindowsImpl, WindowsImplConfig
logger = logging.getLogger(__name__)
@ -63,7 +63,11 @@ class RemoteWindowsServer:
self.port = port
self.server = None
self.device = WindowsDevice()
self.impl = create_windows_device(windows_impl_config)
self.impl = WindowsImpl(
WindowsDevice(),
ahk_exe_path=windows_impl_config.ahk_exe_path,
window_title=windows_impl_config.window_title
)
self.device._screenshot = self.impl
self.device._touch = self.impl
@ -186,14 +190,4 @@ class RemoteWindowsImpl(Touchable, Screenshotable):
def swipe(self, x1: int, y1: int, x2: int, y2: int, duration: float | None = None) -> None:
"""Swipe from (x1, y1) to (x2, y2) on the remote server."""
if not self.proxy.swipe(x1, y1, x2, y2, duration):
raise RuntimeError(f"Failed to swipe from ({x1}, {y1}) to ({x2}, {y2})")
# 编写并注册创建函数
@register_impl('remote_windows', config_model=RemoteWindowsImplConfig)
def create_remote_windows_device(config: RemoteWindowsImplConfig) -> Device:
device = WindowsDevice()
remote_impl = RemoteWindowsImpl(device, config.host, config.port)
device._touch = remote_impl
device._screenshot = remote_impl
return device
raise RuntimeError(f"Failed to swipe from ({x1}, {y1}) to ({x2}, {y2})")

View File

@ -4,21 +4,19 @@ from typing import Literal
import numpy as np
import uiautomator2 as u2
from cv2.typing import MatLike
from adbutils._device import AdbDevice as AdbUtilsDevice
from kotonebot import logging
from ..device import Device
from ..protocol import Screenshotable, Commandable, Touchable
from ..registration import register_impl
from .adb import AdbImplConfig, _create_adb_device_base
logger = logging.getLogger(__name__)
SCREENSHOT_INTERVAL = 0.2
class UiAutomator2Impl(Screenshotable, Commandable, Touchable):
def __init__(self, device: Device):
self.device = device
self.u2_client = u2.Device(device.adb.serial)
def __init__(self, adb_connection: AdbUtilsDevice):
self.u2_client = u2.Device(adb_connection.serial)
self.__last_screenshot_time = 0
def screenshot(self) -> MatLike:
@ -40,10 +38,7 @@ class UiAutomator2Impl(Screenshotable, Commandable, Touchable):
def screen_size(self) -> tuple[int, int]:
info = self.u2_client.info
sizes = info['displayWidth'], info['displayHeight']
if self.device.orientation == 'landscape':
return (max(sizes), min(sizes))
else:
return (min(sizes), max(sizes))
return sizes
def detect_orientation(self) -> Literal['portrait', 'landscape'] | None:
"""
@ -84,10 +79,4 @@ class UiAutomator2Impl(Screenshotable, Commandable, Touchable):
"""
滑动屏幕
"""
self.u2_client.swipe(x1, y1, x2, y2, duration=duration or 0.1)
@register_impl('uiautomator2', config_model=AdbImplConfig)
def create_uiautomator2_device(config: AdbImplConfig) -> Device:
"""UiAutomator2Impl 工厂函数"""
return _create_adb_device_base(config, UiAutomator2Impl)
self.u2_client.swipe(x1, y1, x2, y2, duration=duration or 0.1)

View File

@ -13,7 +13,7 @@ from cv2.typing import MatLike
from ..device import Device, WindowsDevice
from ..protocol import Commandable, Touchable, Screenshotable
from ..registration import register_impl, ImplConfig
from ..registration import ImplConfig
# 1. 定义配置模型
@dataclass
@ -51,15 +51,6 @@ class WindowsImpl(Touchable, Screenshotable):
# 将点击坐标设置为相对 Client
self.ahk.set_coord_mode('Mouse', 'Client')
@cached_property
def scale_ratio(self) -> float:
"""
缩放比例截图与模拟输入前都会根据这个比例缩放
"""
left, _, right, _ = self.__client_rect()
w = right - left
return 720 / w
@property
def hwnd(self) -> int:
if self.__hwnd is None:
@ -124,18 +115,14 @@ class WindowsImpl(Touchable, Screenshotable):
# 将 RGBA 转换为 RGB
cropped_im = cv2.cvtColor(cropped_im, cv2.COLOR_RGBA2RGB)
# 缩放
cropped_im = cv2.resize(cropped_im, None, fx=self.scale_ratio, fy=self.scale_ratio)
return cropped_im
@property
def screen_size(self) -> tuple[int, int]:
# 因为截图和点击的坐标都被缩放了,
# 因此这里只要返回固定值即可
if self.device.orientation == 'landscape':
return 1280, 720
else:
return 720, 1280
left, top, right, bot = self.__client_rect()
w = right - left
h = bot - top
return w, h
def detect_orientation(self) -> None | Literal['portrait'] | Literal['landscape']:
pos = self.ahk.win_get_position(self.window_title)
@ -154,7 +141,6 @@ class WindowsImpl(Touchable, Screenshotable):
x = 2
if y == 0:
y = 2
x, y = int(x / self.scale_ratio), int(y / self.scale_ratio)
if not self.ahk.win_is_active(self.window_title):
self.ahk.win_activate(self.window_title)
self.ahk.click(x, y)
@ -162,26 +148,9 @@ class WindowsImpl(Touchable, Screenshotable):
def swipe(self, x1: int, y1: int, x2: int, y2: int, duration: float | None = None) -> None:
if not self.ahk.win_is_active(self.window_title):
self.ahk.win_activate(self.window_title)
x1, y1 = int(x1 / self.scale_ratio), int(y1 / self.scale_ratio)
x2, y2 = int(x2 / self.scale_ratio), int(y2 / self.scale_ratio)
# TODO: 这个 speed 的单位是什么?
self.ahk.mouse_drag(x2, y2, from_position=(x1, y1), coord_mode='Client', speed=10)
# 3. 编写并注册创建函数
@register_impl('windows', config_model=WindowsImplConfig)
def create_windows_device(config: WindowsImplConfig) -> Device:
device = WindowsDevice()
impl = WindowsImpl(
device,
window_title=config.window_title,
ahk_exe_path=config.ahk_exe_path
)
device._touch = impl
device._screenshot = impl
return device
if __name__ == '__main__':
from ..device import Device
device = Device()

View File

@ -7,9 +7,10 @@ if TYPE_CHECKING:
from .implements.adb import AdbImplConfig
from .implements.remote_windows import RemoteWindowsImplConfig
from .implements.windows import WindowsImplConfig
from .implements.nemu_ipc import NemuIpcImplConfig
AdbBasedImpl = Literal['adb', 'adb_raw', 'uiautomator2']
DeviceImpl = str | AdbBasedImpl | Literal['windows', 'remote_windows']
DeviceImpl = str | AdbBasedImpl | Literal['windows', 'remote_windows', 'nemu_ipc']
# --- 核心类型定义 ---
@ -21,69 +22,3 @@ class ImplRegistrationError(KotonebotError):
class ImplConfig:
"""所有设备实现配置模型的名义上的基类,便于类型约束。"""
pass
T_Config = TypeVar("T_Config", bound=ImplConfig)
# 定义两种创建者函数类型
CreatorWithConfig = Callable[[Any], Device]
CreatorWithoutConfig = Callable[[], Device]
# --- 底层 API: 公开的注册表 ---
# 注册表结构: {'impl_name': (创建函数, 配置模型类 或 None)}
DEVICE_CREATORS: Dict[str, tuple[Callable[..., Device], Type[ImplConfig] | None]] = {}
def register_impl(name: str, config_model: Type[ImplConfig] | None = None) -> Callable[..., Any]:
"""
一个统一的装饰器用于向 DEVICE_CREATORS 注册表中注册一个设备实现
:param name: 实现的名称 (e.g., 'windows', 'adb')
:param config_model: (可选) 与该实现关联的 dataclass 配置模型
"""
def decorator(creator_func: Callable[..., Device]) -> Callable[..., Device]:
if name in DEVICE_CREATORS:
raise ImplRegistrationError(f"实现 '{name}' 已被注册。")
DEVICE_CREATORS[name] = (creator_func, config_model)
return creator_func
return decorator
# --- 高层 API: 带 overload 的便利函数 ---
# 为需要配置的已知 impl 提供 overload
@overload
def create_device(impl_name: Literal['windows'], config: 'WindowsImplConfig') -> Device: ...
@overload
def create_device(impl_name: Literal['remote_windows'], config: 'RemoteWindowsImplConfig') -> Device: ...
@overload
def create_device(impl_name: AdbBasedImpl, config: 'AdbImplConfig') -> Device: ...
# 函数的实际实现
def create_device(impl_name: DeviceImpl, config: ImplConfig | None = None) -> Device:
"""
根据名称和可选的配置对象统一创建设备
"""
creator_tuple = DEVICE_CREATORS.get(impl_name)
if not creator_tuple:
raise ImplRegistrationError(f"未找到名为 '{impl_name}' 的实现。")
creator_func, registered_config_model = creator_tuple
# 情况 A: 实现需要配置
if registered_config_model is not None:
creator_with_config = cast(CreatorWithConfig, creator_func)
if config is None:
raise ValueError(f"实现 '{impl_name}' 需要一个配置对象,但传入的是 None。")
if not isinstance(config, registered_config_model):
raise TypeError(f"'{impl_name}' 传入的配置类型错误,应为 '{registered_config_model.__name__}',实际为 '{type(config).__name__}'")
return creator_with_config(config)
# 情况 B: 实现无需配置
else:
creator_without_config = cast(CreatorWithoutConfig, creator_func)
if config is not None:
print(f"提示:实现 '{impl_name}' 无需配置,但你提供了一个配置对象,它将被忽略。")
return creator_without_config()

View File

@ -3,10 +3,10 @@ from typing import Generic, TypeVar, Literal
from pydantic import BaseModel, ConfigDict
from kotonebot.client import DeviceImpl
T = TypeVar('T')
BackendType = Literal['custom', 'mumu12', 'leidian', 'dmm']
DeviceRecipes = Literal['adb', 'adb_raw', 'uiautomator2', 'windows', 'remote_windows', 'nemu_ipc']
class ConfigBaseModel(BaseModel):
model_config = ConfigDict(use_attribute_docstrings=True)
@ -27,7 +27,7 @@ class BackendConfig(ConfigBaseModel):
雷电模拟器需要设置正确的模拟器名否则 自动启动模拟器 功能将无法正常工作
其他功能不受影响
"""
screenshot_impl: DeviceImpl = 'adb'
screenshot_impl: DeviceRecipes = 'adb'
"""
截图方法暂时推荐使用adb截图方式
@ -48,6 +48,10 @@ class BackendConfig(ConfigBaseModel):
"""Windows 截图方式的窗口标题"""
windows_ahk_path: str | None = None
"""Windows 截图方式的 AutoHotkey 可执行文件路径,为 None 时使用默认路径"""
mumu_background_mode: bool = False
"""MuMu12 模拟器后台保活模式"""
target_screenshot_interval: float | None = None
"""最小截图间隔,单位为秒。为 None 时不限制截图速度。"""
class PushConfig(ConfigBaseModel):
"""推送配置。"""

View File

@ -1,3 +1,5 @@
import sys
sys.path.append('./projects')
import runpy
import logging
import argparse
@ -12,14 +14,14 @@ def run_script(script_path: str) -> None:
Args:
script_path: Python 脚本的路径
"""
logging.basicConfig(level=logging.INFO, format='[%(asctime)s] [%(levelname)s] [%(name)s] [%(funcName)s] [%(lineno)d] %(message)s')
# 获取模块名
module_name = script_path.strip('.py').replace('\\', '/').strip('/').replace('/', '.')
module_name = script_path.strip('.py').lstrip('projects/').replace('\\', '/').strip('/').replace('/', '.')
print(f"正在运行脚本: {script_path}")
# 运行脚本
from kotonebot.backend.context import init_context, manual_context
from kotonebot.kaa.main.kaa import Kaa
logging.basicConfig(level=logging.INFO, format='[%(asctime)s] [%(levelname)s] [%(name)s] [%(funcName)s] [%(lineno)d] %(message)s')
logging.getLogger('kotonebot').setLevel(logging.DEBUG)
config_path = './config.json'
kaa_instance = Kaa(config_path)

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
@ -24,3 +56,17 @@ class TaskNotFoundError(KotonebotError):
def __init__(self, task_id: str):
self.task_id = task_id
super().__init__(f'Task "{task_id}" not found.')
class UnscalableResolutionError(KotonebotError):
def __init__(self, target_resolution: tuple[int, int], screen_size: tuple[int, int]):
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}')
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,960 +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_姫崎薪波_cIclumsy_trick = 18
"""姫崎薪波 cIclumsy trick 碎片"""
IdolPiece_花海咲季_FightingMyWay = 19
"""花海咲季 FightingMyWay 碎片"""
IdolPiece_藤田ことね_世界一可愛い私 = 20
"""藤田ことね 世界一可愛い私 碎片"""
IdolPiece_花海佑芽_TheRollingRiceball = 21
"""花海佑芽 The Rolling Riceball 碎片"""
IdolPiece_月村手毬_LunaSayMaybe = 22
"""月村手毬 Luna say maybe 碎片"""
@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_姫崎薪波_cIclumsy_trick:
return "姫崎薪波 cIclumsy 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 _:
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_姫崎薪波_cIclumsy_trick:
return R.Shop.IdolPiece.姫崎薪波_cIclumsy_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 _:
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_on: Literal['never', 'not_found', 'always'] = 'never'
"""
金币商店刷新逻辑
* never: 从不刷新
* not_found: 仅当要购买的物品不存在时刷新
* always: 总是刷新
"""
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 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()
"""关闭游戏配置"""
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
]
# 参考图片:

Some files were not shown because too many files have changed in this diff Show More