Compare commits
61 Commits
Author | SHA1 | Date |
---|---|---|
![]() |
524ffd58a9 | |
![]() |
de1328cdff | |
![]() |
6629bc7ae5 | |
![]() |
f9fafb9d71 | |
![]() |
738ec9ee78 | |
![]() |
9c9e4af555 | |
![]() |
0b7054e897 | |
![]() |
09252c5aa1 | |
![]() |
b51f9cdaa4 | |
![]() |
3e544e92a9 | |
![]() |
3be8485795 | |
![]() |
a167cbfbe1 | |
![]() |
ceaaed7896 | |
![]() |
a922ce5738 | |
![]() |
d7a3494d8e | |
![]() |
b07d4d3d23 | |
![]() |
4deea1d644 | |
![]() |
f929046ae2 | |
![]() |
3e67627962 | |
![]() |
1b385c09b1 | |
![]() |
acfb5548b6 | |
![]() |
b8ade2f48c | |
![]() |
16360f5764 | |
![]() |
a4d3b322e0 | |
![]() |
4bea42238f | |
![]() |
5db3ed6526 | |
![]() |
5cc9f454ee | |
![]() |
a8a5566f00 | |
![]() |
63f792db2d | |
![]() |
05a69ad947 | |
![]() |
8216310173 | |
![]() |
ca83fec19d | |
![]() |
ef725b4e6f | |
![]() |
68b0cbda73 | |
![]() |
41e7c8b4a8 | |
![]() |
4e4b91d670 | |
![]() |
e548518dcd | |
![]() |
a0d3c31b6b | |
![]() |
0651d949d7 | |
![]() |
497561c721 | |
![]() |
c7d5cd88d6 | |
![]() |
e0549c6b85 | |
![]() |
c3d24018db | |
![]() |
6dd2b3510b | |
![]() |
7ce4b17fb2 | |
![]() |
c6b52a599f | |
![]() |
b8b56bbf4c | |
![]() |
353fa3fcb2 | |
![]() |
03aa2b508c | |
![]() |
9b37bcf541 | |
![]() |
68dbc487e8 | |
![]() |
f5a4e50611 | |
![]() |
cf1605d913 | |
![]() |
3b3aac65dc | |
![]() |
c8fbf80640 | |
![]() |
0e183b0ca6 | |
![]() |
8e5fcaf4fc | |
![]() |
50d1403825 | |
![]() |
f2eadad7eb | |
![]() |
5306f5c875 | |
![]() |
d9077e74e2 |
|
@ -10,6 +10,7 @@ kotonebot-ui/.vite
|
|||
dumps*/
|
||||
config.json
|
||||
config.v*.json
|
||||
conf/
|
||||
reports/
|
||||
tmp/
|
||||
res/sprites_compiled/
|
||||
|
|
102
WHATS_NEW.md
|
@ -1,5 +1,107 @@
|
|||
# 更新日志
|
||||
## 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)
|
||||
|
|
|
@ -49,7 +49,7 @@ int APIENTRY wWinMain(_In_ HINSTANCE hInstance,
|
|||
}
|
||||
|
||||
// 构建命令行
|
||||
std::wstring cmd = pythonPath + L" " + bootstrapPath;
|
||||
std::wstring cmd = L"\"" + pythonPath + L"\" \"" + bootstrapPath + L"\"";
|
||||
|
||||
// 如果有命令行参数,将其传递给 bootstrap
|
||||
if (lpCmdLine && wcslen(lpCmdLine) > 0) {
|
||||
|
|
Before Width: | Height: | Size: 264 KiB After Width: | Height: | Size: 160 KiB |
Before Width: | Height: | Size: 264 KiB After Width: | Height: | Size: 160 KiB |
|
@ -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}}]}
|
|
@ -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}}]}
|
Before Width: | Height: | Size: 300 KiB |
|
@ -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}}]}
|
Before Width: | Height: | Size: 307 KiB |
|
@ -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}}]}
|
Before Width: | Height: | Size: 295 KiB |
|
@ -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}}]}
|
Before Width: | Height: | Size: 297 KiB |
|
@ -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}}]}
|
Before Width: | Height: | Size: 294 KiB |
|
@ -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}}]}
|
Before Width: | Height: | Size: 291 KiB |
|
@ -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}}]}
|
|
@ -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}}]}
|
After Width: | Height: | Size: 549 KiB |
|
@ -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}}]}
|
After Width: | Height: | Size: 539 KiB |
|
@ -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}}]}
|
After Width: | Height: | Size: 506 KiB |
|
@ -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}}]}
|
After Width: | Height: | Size: 522 KiB |
|
@ -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}}]}
|
Before Width: | Height: | Size: 5.1 KiB |
Before Width: | Height: | Size: 6.5 KiB |
Before Width: | Height: | Size: 7.6 KiB |
Before Width: | Height: | Size: 405 B |
Before Width: | Height: | Size: 1.8 KiB |
Before Width: | Height: | Size: 973 B |
|
@ -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}}]}
|
|
@ -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:
|
||||
|
|
|
@ -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,
|
||||
|
@ -49,7 +50,7 @@ from kotonebot.backend.ocr import (
|
|||
from kotonebot.config.manager import load_config, save_config
|
||||
from kotonebot.config.base_config import UserConfig
|
||||
from kotonebot.backend.core import Image, HintBox
|
||||
from kotonebot.errors import KotonebotWarning
|
||||
from kotonebot.errors import ContextNotInitializedError, KotonebotWarning
|
||||
from kotonebot.backend.preprocessor import PreprocessorProtocol
|
||||
from kotonebot.primitives import Rect
|
||||
|
||||
|
@ -284,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()
|
||||
|
@ -300,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,
|
||||
|
@ -310,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,
|
||||
|
@ -327,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,
|
||||
|
@ -342,6 +354,7 @@ class ContextOcr:
|
|||
*,
|
||||
rect: Rect | None = None,
|
||||
hint: HintBox | None = None,
|
||||
lang: OcrLanguage | None = None,
|
||||
) -> OcrResult:
|
||||
|
||||
"""
|
||||
|
@ -349,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
|
||||
|
||||
|
@ -705,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):
|
||||
"""
|
||||
|
@ -734,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:
|
||||
|
@ -780,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)
|
||||
|
@ -788,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,
|
||||
|
@ -900,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 模块。
|
||||
|
@ -911,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:
|
||||
|
@ -919,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
|
||||
|
@ -941,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:
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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:
|
||||
"""
|
||||
|
|
|
@ -29,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
|
||||
|
@ -356,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]:
|
||||
"""
|
||||
|
|
|
@ -50,6 +50,8 @@ class BackendConfig(ConfigBaseModel):
|
|||
"""Windows 截图方式的 AutoHotkey 可执行文件路径,为 None 时使用默认路径"""
|
||||
mumu_background_mode: bool = False
|
||||
"""MuMu12 模拟器后台保活模式"""
|
||||
target_screenshot_interval: float | None = None
|
||||
"""最小截图间隔,单位为秒。为 None 时不限制截图速度。"""
|
||||
|
||||
class PushConfig(ConfigBaseModel):
|
||||
"""推送配置。"""
|
||||
|
|
|
@ -1,9 +1,41 @@
|
|||
from typing import Callable
|
||||
|
||||
|
||||
class KotonebotError(Exception):
|
||||
pass
|
||||
|
||||
class KotonebotWarning(Warning):
|
||||
pass
|
||||
|
||||
class UserFriendlyError(KotonebotError):
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
actions: list[tuple[int, str, Callable[[], None]]] = [],
|
||||
*args, **kwargs
|
||||
) -> None:
|
||||
super().__init__(*args, **kwargs)
|
||||
self.message = message
|
||||
self.actions = actions or []
|
||||
|
||||
@property
|
||||
def action_buttons(self) -> list[tuple[int, str]]:
|
||||
"""
|
||||
以 (id: int, btn_text: str) 的形式返回所有按钮定义。
|
||||
"""
|
||||
return [(id, text) for id, text, _ in self.actions]
|
||||
|
||||
def invoke(self, action_id: int):
|
||||
"""
|
||||
执行指定 ID 的 action。
|
||||
"""
|
||||
for id, _, func in self.actions:
|
||||
if id == action_id:
|
||||
func()
|
||||
break
|
||||
else:
|
||||
raise ValueError(f'Action with id {action_id} not found.')
|
||||
|
||||
class UnrecoverableError(KotonebotError):
|
||||
pass
|
||||
|
||||
|
@ -30,4 +62,11 @@ class UnscalableResolutionError(KotonebotError):
|
|||
self.target_resolution = target_resolution
|
||||
self.screen_size = screen_size
|
||||
super().__init__(f'Cannot scale to target resolution {target_resolution}. '
|
||||
f'Screen size: {screen_size}')
|
||||
f'Screen size: {screen_size}')
|
||||
|
||||
class ContextNotInitializedError(KotonebotError):
|
||||
def __init__(self, msg: str = 'Context not initialized'):
|
||||
super().__init__(msg)
|
||||
|
||||
class StopCurrentTask(KotonebotError):
|
||||
pass
|
|
@ -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'
|
||||
)
|
|
@ -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")
|
|
@ -11,978 +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 MiscConfig(ConfigBaseModel):
|
||||
check_update: Literal['never', 'startup'] = 'startup'
|
||||
"""
|
||||
检查更新时机。
|
||||
|
||||
* never: 从不检查更新。
|
||||
* startup: 启动时检查更新。
|
||||
"""
|
||||
auto_install_update: bool = True
|
||||
"""
|
||||
是否自动安装更新。
|
||||
|
||||
若启用,则每次自动检查更新时若有新版本会自动安装,否则只是会提示。
|
||||
"""
|
||||
|
||||
class BaseConfig(ConfigBaseModel):
|
||||
purchase: PurchaseConfig = PurchaseConfig()
|
||||
"""商店购买配置"""
|
||||
|
||||
activity_funds: ActivityFundsConfig = ActivityFundsConfig()
|
||||
"""活动费配置"""
|
||||
|
||||
presents: PresentsConfig = PresentsConfig()
|
||||
"""收取礼物配置"""
|
||||
|
||||
assignment: AssignmentConfig = AssignmentConfig()
|
||||
"""工作配置"""
|
||||
|
||||
contest: ContestConfig = ContestConfig()
|
||||
"""竞赛配置"""
|
||||
|
||||
produce: ProduceConfig = ProduceConfig()
|
||||
"""培育配置"""
|
||||
|
||||
mission_reward: MissionRewardConfig = MissionRewardConfig()
|
||||
"""领取任务奖励配置"""
|
||||
|
||||
club_reward: ClubRewardConfig = ClubRewardConfig()
|
||||
"""领取社团奖励配置"""
|
||||
|
||||
upgrade_support_card: UpgradeSupportCardConfig = UpgradeSupportCardConfig()
|
||||
"""支援卡升级配置"""
|
||||
|
||||
capsule_toys: CapsuleToysConfig = CapsuleToysConfig()
|
||||
"""扭蛋机配置"""
|
||||
|
||||
trace: TraceConfig = TraceConfig()
|
||||
"""跟踪配置"""
|
||||
|
||||
start_game: StartGameConfig = StartGameConfig()
|
||||
"""启动游戏配置"""
|
||||
|
||||
end_game: EndGameConfig = EndGameConfig()
|
||||
"""关闭游戏配置"""
|
||||
|
||||
misc: MiscConfig = MiscConfig()
|
||||
"""杂项配置"""
|
||||
|
||||
|
||||
def conf() -> BaseConfig:
|
||||
"""获取当前配置数据"""
|
||||
c = config.to(BaseConfig).current
|
||||
return c.options
|
||||
|
||||
def sprite_path(path: str) -> str:
|
||||
standalone = os.path.join('kotonebot/kaa/sprites', path)
|
||||
if os.path.exists(standalone):
|
||||
return standalone
|
||||
return str(resources.files('kotonebot.kaa.sprites') / path)
|
||||
|
||||
def upgrade_config() -> str | None:
|
||||
"""
|
||||
升级配置文件
|
||||
"""
|
||||
if not os.path.exists('config.json'):
|
||||
return None
|
||||
with open('config.json', 'r', encoding='utf-8') as f:
|
||||
root = json.load(f)
|
||||
|
||||
user_configs = root['user_configs']
|
||||
old_version = root['version']
|
||||
messages = []
|
||||
def upgrade_user_config(version: int, user_config: dict[str, Any]) -> int:
|
||||
nonlocal messages
|
||||
while True:
|
||||
match version:
|
||||
case 1:
|
||||
logger.info('Upgrading config: v1 -> v2')
|
||||
user_config, msg = upgrade_v1_to_v2(user_config['options'])
|
||||
messages.append(msg)
|
||||
version = 2
|
||||
case 2:
|
||||
logger.info('Upgrading config: v2 -> v3')
|
||||
user_config, msg = upgrade_v2_to_v3(user_config['options'])
|
||||
messages.append(msg)
|
||||
version = 3
|
||||
case 3:
|
||||
logger.info('Upgrading config: v3 -> v4')
|
||||
user_config, msg = upgrade_v3_to_v4(user_config['options'])
|
||||
messages.append(msg)
|
||||
version = 4
|
||||
case 4:
|
||||
logger.info('Upgrading config: v4 -> v5')
|
||||
user_config, msg = upgrade_v4_to_v5(user_config, user_config['options'])
|
||||
messages.append(msg)
|
||||
version = 5
|
||||
case _:
|
||||
logger.info('No config upgrade needed.')
|
||||
return version
|
||||
for user_config in user_configs:
|
||||
new_version = upgrade_user_config(old_version, user_config)
|
||||
root['version'] = new_version
|
||||
|
||||
with open('config.json', 'w', encoding='utf-8') as f:
|
||||
json.dump(root, f, ensure_ascii=False, indent=4)
|
||||
return '\n'.join(messages)
|
||||
|
||||
|
||||
倉本千奈_BASE = 0
|
||||
十王星南_BASE = 100
|
||||
姫崎莉波_BASE = 200
|
||||
月村手毬_BASE = 300
|
||||
有村麻央_BASE = 400
|
||||
篠泽广_BASE = 500
|
||||
紫云清夏_BASE = 600
|
||||
花海佑芽_BASE = 700
|
||||
花海咲季_BASE = 800
|
||||
葛城リーリヤ_BASE = 900
|
||||
藤田ことね_BASE = 1000
|
||||
|
||||
class PIdol(IntEnum):
|
||||
"""
|
||||
P偶像。已废弃,仅为 upgrade_v1_to_v2()、upgrade_v2_to_v3() 而保留。
|
||||
"""
|
||||
倉本千奈_Campusmode = 倉本千奈_BASE + 0
|
||||
倉本千奈_WonderScale = 倉本千奈_BASE + 1
|
||||
倉本千奈_ようこそ初星温泉 = 倉本千奈_BASE + 2
|
||||
倉本千奈_仮装狂騒曲 = 倉本千奈_BASE + 3
|
||||
倉本千奈_初心 = 倉本千奈_BASE + 4
|
||||
倉本千奈_学園生活 = 倉本千奈_BASE + 5
|
||||
倉本千奈_日々_発見的ステップ = 倉本千奈_BASE + 6
|
||||
倉本千奈_胸を張って一歩ずつ = 倉本千奈_BASE + 7
|
||||
|
||||
十王星南_Campusmode = 十王星南_BASE + 0
|
||||
十王星南_一番星 = 十王星南_BASE + 1
|
||||
十王星南_学園生活 = 十王星南_BASE + 2
|
||||
十王星南_小さな野望 = 十王星南_BASE + 3
|
||||
|
||||
姫崎莉波_clumsytrick = 姫崎莉波_BASE + 0
|
||||
姫崎莉波_私らしさのはじまり = 姫崎莉波_BASE + 1
|
||||
姫崎莉波_キミとセミブルー = 姫崎莉波_BASE + 2
|
||||
姫崎莉波_Campusmode = 姫崎莉波_BASE + 3
|
||||
姫崎莉波_LUV = 姫崎莉波_BASE + 4
|
||||
姫崎莉波_ようこそ初星温泉 = 姫崎莉波_BASE + 5
|
||||
姫崎莉波_ハッピーミルフィーユ = 姫崎莉波_BASE + 6
|
||||
姫崎莉波_初心 = 姫崎莉波_BASE + 7
|
||||
姫崎莉波_学園生活 = 姫崎莉波_BASE + 8
|
||||
|
||||
月村手毬_Lunasaymaybe = 月村手毬_BASE + 0
|
||||
月村手毬_一匹狼 = 月村手毬_BASE + 1
|
||||
月村手毬_Campusmode = 月村手毬_BASE + 2
|
||||
月村手毬_アイヴイ = 月村手毬_BASE + 3
|
||||
月村手毬_初声 = 月村手毬_BASE + 4
|
||||
月村手毬_学園生活 = 月村手毬_BASE + 5
|
||||
月村手毬_仮装狂騒曲 = 月村手毬_BASE + 6
|
||||
|
||||
有村麻央_Fluorite = 有村麻央_BASE + 0
|
||||
有村麻央_はじまりはカッコよく = 有村麻央_BASE + 1
|
||||
有村麻央_Campusmode = 有村麻央_BASE + 2
|
||||
有村麻央_FeelJewelDream = 有村麻央_BASE + 3
|
||||
有村麻央_キミとセミブルー = 有村麻央_BASE + 4
|
||||
有村麻央_初恋 = 有村麻央_BASE + 5
|
||||
有村麻央_学園生活 = 有村麻央_BASE + 6
|
||||
|
||||
篠泽广_コントラスト = 篠泽广_BASE + 0
|
||||
篠泽广_一番向いていないこと = 篠泽广_BASE + 1
|
||||
篠泽广_光景 = 篠泽广_BASE + 2
|
||||
篠泽广_Campusmode = 篠泽广_BASE + 3
|
||||
篠泽广_仮装狂騒曲 = 篠泽广_BASE + 4
|
||||
篠泽广_ハッピーミルフィーユ = 篠泽广_BASE + 5
|
||||
篠泽广_初恋 = 篠泽广_BASE + 6
|
||||
篠泽广_学園生活 = 篠泽广_BASE + 7
|
||||
|
||||
紫云清夏_TameLieOneStep = 紫云清夏_BASE + 0
|
||||
紫云清夏_カクシタワタシ = 紫云清夏_BASE + 1
|
||||
紫云清夏_夢へのリスタート = 紫云清夏_BASE + 2
|
||||
紫云清夏_Campusmode = 紫云清夏_BASE + 3
|
||||
紫云清夏_キミとセミブルー = 紫云清夏_BASE + 4
|
||||
紫云清夏_初恋 = 紫云清夏_BASE + 5
|
||||
紫云清夏_学園生活 = 紫云清夏_BASE + 6
|
||||
|
||||
花海佑芽_WhiteNightWhiteWish = 花海佑芽_BASE + 0
|
||||
花海佑芽_学園生活 = 花海佑芽_BASE + 1
|
||||
花海佑芽_Campusmode = 花海佑芽_BASE + 2
|
||||
花海佑芽_TheRollingRiceball = 花海佑芽_BASE + 3
|
||||
花海佑芽_アイドル_はじめっ = 花海佑芽_BASE + 4
|
||||
|
||||
花海咲季_BoomBoomPow = 花海咲季_BASE + 0
|
||||
花海咲季_Campusmode = 花海咲季_BASE + 1
|
||||
花海咲季_FightingMyWay = 花海咲季_BASE + 2
|
||||
花海咲季_わたしが一番 = 花海咲季_BASE + 3
|
||||
花海咲季_冠菊 = 花海咲季_BASE + 4
|
||||
花海咲季_初声 = 花海咲季_BASE + 5
|
||||
花海咲季_古今東西ちょちょいのちょい = 花海咲季_BASE + 6
|
||||
花海咲季_学園生活 = 花海咲季_BASE + 7
|
||||
|
||||
葛城リーリヤ_一つ踏み出した先に = 葛城リーリヤ_BASE + 0
|
||||
葛城リーリヤ_白線 = 葛城リーリヤ_BASE + 1
|
||||
葛城リーリヤ_Campusmode = 葛城リーリヤ_BASE + 2
|
||||
葛城リーリヤ_WhiteNightWhiteWish = 葛城リーリヤ_BASE + 3
|
||||
葛城リーリヤ_冠菊 = 葛城リーリヤ_BASE + 4
|
||||
葛城リーリヤ_初心 = 葛城リーリヤ_BASE + 5
|
||||
葛城リーリヤ_学園生活 = 葛城リーリヤ_BASE + 6
|
||||
|
||||
藤田ことね_カワイイ_はじめました = 藤田ことね_BASE + 0
|
||||
藤田ことね_世界一可愛い私 = 藤田ことね_BASE + 1
|
||||
藤田ことね_Campusmode = 藤田ことね_BASE + 2
|
||||
藤田ことね_YellowBigBang = 藤田ことね_BASE + 3
|
||||
藤田ことね_WhiteNightWhiteWish = 藤田ことね_BASE + 4
|
||||
藤田ことね_冠菊 = 藤田ことね_BASE + 5
|
||||
藤田ことね_初声 = 藤田ことね_BASE + 6
|
||||
藤田ことね_学園生活 = 藤田ことね_BASE + 7
|
||||
|
||||
|
||||
def upgrade_v1_to_v2(options: dict[str, Any]) -> tuple[dict[str, Any], str]:
|
||||
"""
|
||||
v1 -> v2 变更:
|
||||
|
||||
1. 将 PIdol 的枚举值改为整数
|
||||
"""
|
||||
msg = ''
|
||||
# 转换 PIdol 的枚举值
|
||||
def map_idol(idol: list[str]) -> PIdol | None:
|
||||
logger.debug("Converting %s", idol)
|
||||
match idol:
|
||||
case ["倉本千奈", "Campus mode!!"]:
|
||||
return PIdol.倉本千奈_Campusmode
|
||||
case ["倉本千奈", "Wonder Scale"]:
|
||||
return PIdol.倉本千奈_WonderScale
|
||||
case ["倉本千奈", "ようこそ初星温泉"]:
|
||||
return PIdol.倉本千奈_ようこそ初星温泉
|
||||
case ["倉本千奈", "仮装狂騒曲"]:
|
||||
return PIdol.倉本千奈_仮装狂騒曲
|
||||
case ["倉本千奈", "初心"]:
|
||||
return PIdol.倉本千奈_初心
|
||||
case ["倉本千奈", "学園生活"]:
|
||||
return PIdol.倉本千奈_学園生活
|
||||
case ["倉本千奈", "日々、発見的ステップ!"]:
|
||||
return PIdol.倉本千奈_日々_発見的ステップ
|
||||
case ["倉本千奈", "胸を張って一歩ずつ"]:
|
||||
return PIdol.倉本千奈_胸を張って一歩ずつ
|
||||
case ["十王星南", "Campus mode!!"]:
|
||||
return PIdol.十王星南_Campusmode
|
||||
case ["十王星南", "一番星"]:
|
||||
return PIdol.十王星南_一番星
|
||||
case ["十王星南", "学園生活"]:
|
||||
return PIdol.十王星南_学園生活
|
||||
case ["十王星南", "小さな野望"]:
|
||||
return PIdol.十王星南_小さな野望
|
||||
case ["姫崎莉波", "clumsy trick"]:
|
||||
return PIdol.姫崎莉波_clumsytrick
|
||||
case ["姫崎莉波", "『私らしさ』のはじまり"]:
|
||||
return PIdol.姫崎莉波_私らしさのはじまり
|
||||
case ["姫崎莉波", "キミとセミブルー"]:
|
||||
return PIdol.姫崎莉波_キミとセミブルー
|
||||
case ["姫崎莉波", "Campus mode!!"]:
|
||||
return PIdol.姫崎莉波_Campusmode
|
||||
case ["姫崎莉波", "L.U.V"]:
|
||||
return PIdol.姫崎莉波_LUV
|
||||
case ["姫崎莉波", "ようこそ初星温泉"]:
|
||||
return PIdol.姫崎莉波_ようこそ初星温泉
|
||||
case ["姫崎莉波", "ハッピーミルフィーユ"]:
|
||||
return PIdol.姫崎莉波_ハッピーミルフィーユ
|
||||
case ["姫崎莉波", "初心"]:
|
||||
return PIdol.姫崎莉波_初心
|
||||
case ["姫崎莉波", "学園生活"]:
|
||||
return PIdol.姫崎莉波_学園生活
|
||||
case ["月村手毬", "Luna say maybe"]:
|
||||
return PIdol.月村手毬_Lunasaymaybe
|
||||
case ["月村手毬", "一匹狼"]:
|
||||
return PIdol.月村手毬_一匹狼
|
||||
case ["月村手毬", "Campus mode!!"]:
|
||||
return PIdol.月村手毬_Campusmode
|
||||
case ["月村手毬", "アイヴイ"]:
|
||||
return PIdol.月村手毬_アイヴイ
|
||||
case ["月村手毬", "初声"]:
|
||||
return PIdol.月村手毬_初声
|
||||
case ["月村手毬", "学園生活"]:
|
||||
return PIdol.月村手毬_学園生活
|
||||
case ["月村手毬", "仮装狂騒曲"]:
|
||||
return PIdol.月村手毬_仮装狂騒曲
|
||||
case ["有村麻央", "Fluorite"]:
|
||||
return PIdol.有村麻央_Fluorite
|
||||
case ["有村麻央", "はじまりはカッコよく"]:
|
||||
return PIdol.有村麻央_はじまりはカッコよく
|
||||
case ["有村麻央", "Campus mode!!"]:
|
||||
return PIdol.有村麻央_Campusmode
|
||||
case ["有村麻央", "Feel Jewel Dream"]:
|
||||
return PIdol.有村麻央_FeelJewelDream
|
||||
case ["有村麻央", "キミとセミブルー"]:
|
||||
return PIdol.有村麻央_キミとセミブルー
|
||||
case ["有村麻央", "初恋"]:
|
||||
return PIdol.有村麻央_初恋
|
||||
case ["有村麻央", "学園生活"]:
|
||||
return PIdol.有村麻央_学園生活
|
||||
case ["篠泽广", "コントラスト"]:
|
||||
return PIdol.篠泽广_コントラスト
|
||||
case ["篠泽广", "一番向いていないこと"]:
|
||||
return PIdol.篠泽广_一番向いていないこと
|
||||
case ["篠泽广", "光景"]:
|
||||
return PIdol.篠泽广_光景
|
||||
case ["篠泽广", "Campus mode!!"]:
|
||||
return PIdol.篠泽广_Campusmode
|
||||
case ["篠泽广", "仮装狂騒曲"]:
|
||||
return PIdol.篠泽广_仮装狂騒曲
|
||||
case ["篠泽广", "ハッピーミルフィーユ"]:
|
||||
return PIdol.篠泽广_ハッピーミルフィーユ
|
||||
case ["篠泽广", "初恋"]:
|
||||
return PIdol.篠泽广_初恋
|
||||
case ["篠泽广", "学園生活"]:
|
||||
return PIdol.篠泽广_学園生活
|
||||
case ["紫云清夏", "Tame-Lie-One-Step"]:
|
||||
return PIdol.紫云清夏_TameLieOneStep
|
||||
case ["紫云清夏", "カクシタワタシ"]:
|
||||
return PIdol.紫云清夏_カクシタワタシ
|
||||
case ["紫云清夏", "夢へのリスタート"]:
|
||||
return PIdol.紫云清夏_夢へのリスタート
|
||||
case ["紫云清夏", "Campus mode!!"]:
|
||||
return PIdol.紫云清夏_Campusmode
|
||||
case ["紫云清夏", "キミとセミブルー"]:
|
||||
return PIdol.紫云清夏_キミとセミブルー
|
||||
case ["紫云清夏", "初恋"]:
|
||||
return PIdol.紫云清夏_初恋
|
||||
case ["紫云清夏", "学園生活"]:
|
||||
return PIdol.紫云清夏_学園生活
|
||||
case ["花海佑芽", "White Night! White Wish!"]:
|
||||
return PIdol.花海佑芽_WhiteNightWhiteWish
|
||||
case ["花海佑芽", "学園生活"]:
|
||||
return PIdol.花海佑芽_学園生活
|
||||
case ["花海佑芽", "Campus mode!!"]:
|
||||
return PIdol.花海佑芽_Campusmode
|
||||
case ["花海佑芽", "The Rolling Riceball"]:
|
||||
return PIdol.花海佑芽_TheRollingRiceball
|
||||
case ["花海佑芽", "アイドル、はじめっ!"]:
|
||||
return PIdol.花海佑芽_アイドル_はじめっ
|
||||
case ["花海咲季", "Boom Boom Pow"]:
|
||||
return PIdol.花海咲季_BoomBoomPow
|
||||
case ["花海咲季", "Campus mode!!"]:
|
||||
return PIdol.花海咲季_Campusmode
|
||||
case ["花海咲季", "Fighting My Way"]:
|
||||
return PIdol.花海咲季_FightingMyWay
|
||||
case ["花海咲季", "わたしが一番!"]:
|
||||
return PIdol.花海咲季_わたしが一番
|
||||
case ["花海咲季", "冠菊"]:
|
||||
return PIdol.花海咲季_冠菊
|
||||
case ["花海咲季", "初声"]:
|
||||
return PIdol.花海咲季_初声
|
||||
case ["花海咲季", "古今東西ちょちょいのちょい"]:
|
||||
return PIdol.花海咲季_古今東西ちょちょいのちょい
|
||||
case ["花海咲季", "学園生活"]:
|
||||
return PIdol.花海咲季_学園生活
|
||||
case ["葛城リーリヤ", "一つ踏み出した先に"]:
|
||||
return PIdol.葛城リーリヤ_一つ踏み出した先に
|
||||
case ["葛城リーリヤ", "白線"]:
|
||||
return PIdol.葛城リーリヤ_白線
|
||||
case ["葛城リーリヤ", "Campus mode!!"]:
|
||||
return PIdol.葛城リーリヤ_Campusmode
|
||||
case ["葛城リーリヤ", "White Night! White Wish!"]:
|
||||
return PIdol.葛城リーリヤ_WhiteNightWhiteWish
|
||||
case ["葛城リーリヤ", "冠菊"]:
|
||||
return PIdol.葛城リーリヤ_冠菊
|
||||
case ["葛城リーリヤ", "初心"]:
|
||||
return PIdol.葛城リーリヤ_初心
|
||||
case ["葛城リーリヤ", "学園生活"]:
|
||||
return PIdol.葛城リーリヤ_学園生活
|
||||
case ["藤田ことね", "カワイイ", "はじめました"]:
|
||||
return PIdol.藤田ことね_カワイイ_はじめました
|
||||
case ["藤田ことね", "世界一可愛い私"]:
|
||||
return PIdol.藤田ことね_世界一可愛い私
|
||||
case ["藤田ことね", "Campus mode!!"]:
|
||||
return PIdol.藤田ことね_Campusmode
|
||||
case ["藤田ことね", "Yellow Big Bang!"]:
|
||||
return PIdol.藤田ことね_YellowBigBang
|
||||
case ["藤田ことね", "White Night! White Wish!"]:
|
||||
return PIdol.藤田ことね_WhiteNightWhiteWish
|
||||
case ["藤田ことね", "冠菊"]:
|
||||
return PIdol.藤田ことね_冠菊
|
||||
case ["藤田ことね", "初声"]:
|
||||
return PIdol.藤田ことね_初声
|
||||
case ["藤田ことね", "学園生活"]:
|
||||
return PIdol.藤田ことね_学園生活
|
||||
case _:
|
||||
nonlocal msg
|
||||
if msg == '':
|
||||
msg = '培育设置中的以下偶像升级失败。请尝试手动添加。\n'
|
||||
msg += f'{idol} 未找到\n'
|
||||
return None
|
||||
old_idols = options['produce']['idols']
|
||||
new_idols = list(filter(lambda x: x is not None, map(map_idol, old_idols)))
|
||||
options['produce']['idols'] = new_idols
|
||||
shutil.copy('config.json', 'config.v1.json')
|
||||
return options, msg
|
||||
|
||||
def upgrade_v2_to_v3(options: dict[str, Any]) -> tuple[dict[str, Any], str]:
|
||||
"""
|
||||
v2 -> v3 变更:\n
|
||||
引入了游戏解包数据,因此 PIdol 枚举废弃,直接改用游戏内 ID。
|
||||
"""
|
||||
msg = ''
|
||||
def map_idol(idol: PIdol) -> str | None:
|
||||
match idol:
|
||||
case PIdol.倉本千奈_Campusmode: return "i_card-skin-kcna-3-007"
|
||||
case PIdol.倉本千奈_WonderScale: return "i_card-skin-kcna-3-000"
|
||||
case PIdol.倉本千奈_ようこそ初星温泉: return "i_card-skin-kcna-3-005"
|
||||
case PIdol.倉本千奈_仮装狂騒曲: return "i_card-skin-kcna-3-002"
|
||||
case PIdol.倉本千奈_初心: return "i_card-skin-kcna-1-001"
|
||||
case PIdol.倉本千奈_学園生活: return "i_card-skin-kcna-1-000"
|
||||
case PIdol.倉本千奈_日々_発見的ステップ: return "i_card-skin-kcna-3-001"
|
||||
case PIdol.倉本千奈_胸を張って一歩ずつ: return "i_card-skin-kcna-2-000"
|
||||
case PIdol.十王星南_Campusmode: return "i_card-skin-jsna-3-002"
|
||||
case PIdol.十王星南_一番星: return "i_card-skin-jsna-2-000"
|
||||
case PIdol.十王星南_学園生活: return "i_card-skin-jsna-1-000"
|
||||
case PIdol.十王星南_小さな野望: return "i_card-skin-jsna-3-000"
|
||||
case PIdol.姫崎莉波_clumsytrick: return "i_card-skin-hrnm-3-000"
|
||||
case PIdol.姫崎莉波_私らしさのはじまり: return "i_card-skin-hrnm-2-000"
|
||||
case PIdol.姫崎莉波_キミとセミブルー: return "i_card-skin-hrnm-3-001"
|
||||
case PIdol.姫崎莉波_Campusmode: return "i_card-skin-hrnm-3-007"
|
||||
case PIdol.姫崎莉波_LUV: return "i_card-skin-hrnm-3-002"
|
||||
case PIdol.姫崎莉波_ようこそ初星温泉: return "i_card-skin-hrnm-3-004"
|
||||
case PIdol.姫崎莉波_ハッピーミルフィーユ: return "i_card-skin-hrnm-3-008"
|
||||
case PIdol.姫崎莉波_初心: return "i_card-skin-hrnm-1-001"
|
||||
case PIdol.姫崎莉波_学園生活: return "i_card-skin-hrnm-1-000"
|
||||
case PIdol.月村手毬_Lunasaymaybe: return "i_card-skin-ttmr-3-000"
|
||||
case PIdol.月村手毬_一匹狼: return "i_card-skin-ttmr-2-000"
|
||||
case PIdol.月村手毬_Campusmode: return "i_card-skin-ttmr-3-007"
|
||||
case PIdol.月村手毬_アイヴイ: return "i_card-skin-ttmr-3-001"
|
||||
case PIdol.月村手毬_初声: return "i_card-skin-ttmr-1-001"
|
||||
case PIdol.月村手毬_学園生活: return "i_card-skin-ttmr-1-000"
|
||||
case PIdol.月村手毬_仮装狂騒曲: return "i_card-skin-ttmr-3-002"
|
||||
case PIdol.有村麻央_Fluorite: return "i_card-skin-amao-3-000"
|
||||
case PIdol.有村麻央_はじまりはカッコよく: return "i_card-skin-amao-2-000"
|
||||
case PIdol.有村麻央_Campusmode: return "i_card-skin-amao-3-007"
|
||||
case PIdol.有村麻央_FeelJewelDream: return "i_card-skin-amao-3-002"
|
||||
case PIdol.有村麻央_キミとセミブルー: return "i_card-skin-amao-3-001"
|
||||
case PIdol.有村麻央_初恋: return "i_card-skin-amao-1-001"
|
||||
case PIdol.有村麻央_学園生活: return "i_card-skin-amao-1-000"
|
||||
case PIdol.篠泽广_コントラスト: return "i_card-skin-shro-3-001"
|
||||
case PIdol.篠泽广_一番向いていないこと: return "i_card-skin-shro-2-000"
|
||||
case PIdol.篠泽广_光景: return "i_card-skin-shro-3-000"
|
||||
case PIdol.篠泽广_Campusmode: return "i_card-skin-shro-3-007"
|
||||
case PIdol.篠泽广_仮装狂騒曲: return "i_card-skin-shro-3-002"
|
||||
case PIdol.篠泽广_ハッピーミルフィーユ: return "i_card-skin-shro-3-008"
|
||||
case PIdol.篠泽广_初恋: return "i_card-skin-shro-1-001"
|
||||
case PIdol.篠泽广_学園生活: return "i_card-skin-shro-1-000"
|
||||
case PIdol.紫云清夏_TameLieOneStep: return "i_card-skin-ssmk-3-000"
|
||||
case PIdol.紫云清夏_カクシタワタシ: return "i_card-skin-ssmk-3-002"
|
||||
case PIdol.紫云清夏_夢へのリスタート: return "i_card-skin-ssmk-2-000"
|
||||
case PIdol.紫云清夏_Campusmode: return "i_card-skin-ssmk-3-007"
|
||||
case PIdol.紫云清夏_キミとセミブルー: return "i_card-skin-ssmk-3-001"
|
||||
case PIdol.紫云清夏_初恋: return "i_card-skin-ssmk-1-001"
|
||||
case PIdol.紫云清夏_学園生活: return "i_card-skin-ssmk-1-000"
|
||||
case PIdol.花海佑芽_WhiteNightWhiteWish: return "i_card-skin-hume-3-005"
|
||||
case PIdol.花海佑芽_学園生活: return "i_card-skin-hume-1-000"
|
||||
case PIdol.花海佑芽_Campusmode: return "i_card-skin-hume-3-006"
|
||||
case PIdol.花海佑芽_TheRollingRiceball: return "i_card-skin-hume-3-000"
|
||||
case PIdol.花海佑芽_アイドル_はじめっ: return "i_card-skin-hume-2-000"
|
||||
case PIdol.花海咲季_BoomBoomPow: return "i_card-skin-hski-3-001"
|
||||
case PIdol.花海咲季_Campusmode: return "i_card-skin-hski-3-008"
|
||||
case PIdol.花海咲季_FightingMyWay: return "i_card-skin-hski-3-000"
|
||||
case PIdol.花海咲季_わたしが一番: return "i_card-skin-hski-2-000"
|
||||
case PIdol.花海咲季_冠菊: return "i_card-skin-hski-3-002"
|
||||
case PIdol.花海咲季_初声: return "i_card-skin-hski-1-001"
|
||||
case PIdol.花海咲季_古今東西ちょちょいのちょい: return "i_card-skin-hski-3-006"
|
||||
case PIdol.花海咲季_学園生活: return "i_card-skin-hski-1-000"
|
||||
case PIdol.葛城リーリヤ_一つ踏み出した先に: return "i_card-skin-kllj-2-000"
|
||||
case PIdol.葛城リーリヤ_白線: return "i_card-skin-kllj-3-000"
|
||||
case PIdol.葛城リーリヤ_Campusmode: return "i_card-skin-kllj-3-006"
|
||||
case PIdol.葛城リーリヤ_WhiteNightWhiteWish: return "i_card-skin-kllj-3-005"
|
||||
case PIdol.葛城リーリヤ_冠菊: return "i_card-skin-kllj-3-001"
|
||||
case PIdol.葛城リーリヤ_初心: return "i_card-skin-kllj-1-001"
|
||||
case PIdol.葛城リーリヤ_学園生活: return "i_card-skin-kllj-1-000"
|
||||
case PIdol.藤田ことね_カワイイ_はじめました: return "i_card-skin-fktn-2-000"
|
||||
case PIdol.藤田ことね_世界一可愛い私: return "i_card-skin-fktn-3-000"
|
||||
case PIdol.藤田ことね_Campusmode: return "i_card-skin-fktn-3-007"
|
||||
case PIdol.藤田ことね_YellowBigBang: return "i_card-skin-fktn-3-001"
|
||||
case PIdol.藤田ことね_WhiteNightWhiteWish: return "i_card-skin-fktn-3-006"
|
||||
case PIdol.藤田ことね_冠菊: return "i_card-skin-fktn-3-002"
|
||||
case PIdol.藤田ことね_初声: return "i_card-skin-fktn-1-001"
|
||||
case PIdol.藤田ことね_学園生活: return "i_card-skin-fktn-1-000"
|
||||
case _:
|
||||
nonlocal msg
|
||||
if msg == '':
|
||||
msg = '培育设置中的以下偶像升级失败。请尝试手动添加。\n'
|
||||
msg += f'{idol} 未找到\n'
|
||||
return None
|
||||
old_idols = options['produce']['idols']
|
||||
new_idols = list(filter(lambda x: x is not None, map(map_idol, old_idols)))
|
||||
options['produce']['idols'] = new_idols
|
||||
shutil.copy('config.json', 'config.v2.json')
|
||||
return options, msg
|
||||
|
||||
def upgrade_v3_to_v4(options: dict[str, Any]) -> tuple[dict[str, Any], str]:
|
||||
"""
|
||||
v3 -> v4 变更:
|
||||
自动纠正错误游戏包名
|
||||
"""
|
||||
shutil.copy('config.json', 'config.v3.json')
|
||||
if options['start_game']['game_package_name'] == 'com.bandinamcoent.idolmaster_gakuen':
|
||||
options['start_game']['game_package_name'] = 'com.bandainamcoent.idolmaster_gakuen'
|
||||
logger.info('Corrected game package name to com.bandainamcoent.idolmaster_gakuen')
|
||||
return options, ''
|
||||
|
||||
def upgrade_v4_to_v5(user_config: dict[str, Any], options: dict[str, Any]) -> tuple[dict[str, Any], str]:
|
||||
"""
|
||||
v4 -> v5 变更:
|
||||
为 windows 和 windows_remote 截图方式的 type 设置为 dmm
|
||||
"""
|
||||
shutil.copy('config.json', 'config.v4.json')
|
||||
if user_config['backend']['screenshot_impl'] in ['windows', 'remote_windows']:
|
||||
logger.info('Set backend type to dmm.')
|
||||
user_config['backend']['type'] = 'dmm'
|
||||
return options, ''
|
||||
|
||||
if __name__ == '__main__':
|
||||
print(PurchaseConfig.model_fields['money_refresh_on'].description)
|
||||
return str(resources.files('kotonebot.kaa.sprites') / path)
|
|
@ -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",
|
||||
]
|
|
@ -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]
|
|
@ -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",
|
||||
]
|
|
@ -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"]
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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)
|
|
@ -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
|
|
@ -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'
|
||||
)
|
|
@ -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
|
||||
]
|
||||
|
||||
# 参考图片:
|
||||
|
|
|
@ -6,7 +6,7 @@ from cv2.typing import MatLike
|
|||
from kotonebot.primitives import Rect
|
||||
from kotonebot import ocr, device, image, action
|
||||
from kotonebot.backend.core import HintBox
|
||||
from kotonebot.kaa.common import ProduceAction
|
||||
from kotonebot.kaa.config import ProduceAction
|
||||
from kotonebot.kaa.tasks import R
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
|
@ -34,10 +34,11 @@ def toolbar_menu(critical: Literal[True]) -> TemplateMatchResult:
|
|||
"""寻找工具栏上的菜单按钮。若未找到,则抛出异常。"""
|
||||
...
|
||||
|
||||
_TOOLBAR_THRESHOLD = 0.6
|
||||
@action('工具栏按钮.寻找菜单', screenshot_mode='manual-inherit')
|
||||
def toolbar_menu(critical: bool = False):
|
||||
device.screenshot()
|
||||
if critical:
|
||||
return image.expect_wait(R.Common.ButtonToolbarMenu, preprocessors=[WhiteFilter()])
|
||||
return image.expect_wait(R.Common.ButtonToolbarMenu, preprocessors=[WhiteFilter()], threshold=_TOOLBAR_THRESHOLD)
|
||||
else:
|
||||
return image.find(R.Common.ButtonToolbarMenu, preprocessors=[WhiteFilter()])
|
||||
return image.find(R.Common.ButtonToolbarMenu, preprocessors=[WhiteFilter()], threshold=_TOOLBAR_THRESHOLD)
|
|
@ -1,5 +1,6 @@
|
|||
import io
|
||||
import os
|
||||
import sys
|
||||
from typing import Any, Literal, cast
|
||||
import zipfile
|
||||
import logging
|
||||
|
@ -18,7 +19,7 @@ from kotonebot import KotoneBot
|
|||
from ..util.paths import get_ahk_path
|
||||
from ..kaa_context import _set_instance
|
||||
from .dmm_host import DmmHost, DmmInstance
|
||||
from ..common import BaseConfig, upgrade_config
|
||||
from ..config import BaseConfig, upgrade_config
|
||||
from kotonebot.config.base_config import UserConfig
|
||||
from kotonebot.client.host import (
|
||||
Mumu12Host, LeidianHost, Mumu12Instance,
|
||||
|
@ -30,36 +31,38 @@ from kotonebot.client.host.protocol import (
|
|||
)
|
||||
|
||||
# 初始化日志
|
||||
log_formatter = logging.Formatter('[%(asctime)s][%(levelname)s][%(name)s] %(message)s')
|
||||
|
||||
console_handler = logging.StreamHandler()
|
||||
console_handler.setFormatter(log_formatter)
|
||||
console_handler.setLevel(logging.CRITICAL)
|
||||
format = '[%(asctime)s][%(levelname)s][%(name)s:%(lineno)d] %(message)s'
|
||||
log_formatter = logging.Formatter(format)
|
||||
logging.basicConfig(level=logging.INFO, format=format)
|
||||
|
||||
log_stream = io.StringIO()
|
||||
stream_handler = logging.StreamHandler(log_stream)
|
||||
stream_handler.setFormatter(logging.Formatter('[%(asctime)s] [%(levelname)s] [%(name)s] [%(filename)s:%(lineno)d] - %(message)s'))
|
||||
memo_handler = logging.StreamHandler(log_stream)
|
||||
memo_handler.setFormatter(log_formatter)
|
||||
memo_handler.setLevel(logging.DEBUG)
|
||||
|
||||
root_logger = logging.getLogger()
|
||||
root_logger.setLevel(logging.INFO)
|
||||
root_logger.addHandler(console_handler)
|
||||
root_logger.addHandler(memo_handler)
|
||||
|
||||
logging.getLogger("kotonebot").setLevel(logging.DEBUG)
|
||||
logging.getLogger("httpx").setLevel(logging.WARNING)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 升级配置
|
||||
upgrade_msg = upgrade_config()
|
||||
|
||||
class Kaa(KotoneBot):
|
||||
"""
|
||||
琴音小助手 kaa 主类。由其他 GUI/TUI 调用。
|
||||
"""
|
||||
def __init__(self, config_path: str):
|
||||
# 升级配置
|
||||
upgrade_msg = upgrade_config()
|
||||
super().__init__(module='kotonebot.kaa.tasks', config_path=config_path, config_type=BaseConfig)
|
||||
self.upgrade_msg = upgrade_msg
|
||||
self.version = importlib.metadata.version('ksaa')
|
||||
logger.info('Version: %s', self.version)
|
||||
logger.info('Python Version: %s', sys.version)
|
||||
logger.info('Python Executable: %s', sys.executable)
|
||||
|
||||
def add_file_logger(self, log_path: str):
|
||||
log_dir = os.path.abspath(os.path.dirname(log_path))
|
||||
|
@ -70,7 +73,12 @@ class Kaa(KotoneBot):
|
|||
root_logger.addHandler(file_handler)
|
||||
|
||||
def set_log_level(self, level: int):
|
||||
console_handler.setLevel(level)
|
||||
handlers = logging.getLogger().handlers
|
||||
if len(handlers) == 0:
|
||||
print('Warning: No default handler found.')
|
||||
else:
|
||||
# 第一个 handler 是默认的 StreamHandler
|
||||
handlers[0].setLevel(level)
|
||||
|
||||
def dump_error_report(
|
||||
self,
|
||||
|
@ -110,6 +118,28 @@ class Kaa(KotoneBot):
|
|||
logger.exception('Failed to save error report:')
|
||||
return ''
|
||||
|
||||
@override
|
||||
def _on_init_context(self) -> None:
|
||||
"""
|
||||
初始化 Context,从配置中读取 target_screenshot_interval。
|
||||
"""
|
||||
from kotonebot.config.manager import load_config
|
||||
from kotonebot.backend.context import init_context
|
||||
|
||||
# 加载配置以获取 target_screenshot_interval
|
||||
config = load_config(self.config_path, type=self.config_type)
|
||||
user_config = config.user_configs[0] # HACK: 硬编码
|
||||
target_screenshot_interval = user_config.backend.target_screenshot_interval
|
||||
|
||||
d = self._on_create_device()
|
||||
init_context(
|
||||
config_path=self.config_path,
|
||||
config_type=self.config_type,
|
||||
target_device=d,
|
||||
target_screenshot_interval=target_screenshot_interval,
|
||||
force=True # 强制重新初始化,用于配置热重载
|
||||
)
|
||||
|
||||
@override
|
||||
def _on_after_init_context(self):
|
||||
if self.backend_instance is None:
|
||||
|
|
|
@ -1,8 +1,9 @@
|
|||
"""收取活动费"""
|
||||
import logging
|
||||
|
||||
from kotonebot.backend.loop import Loop
|
||||
from kotonebot.kaa.tasks import R
|
||||
from kotonebot.kaa.common import conf
|
||||
from kotonebot.kaa.config import conf
|
||||
from ..actions.scenes import at_home, goto_home
|
||||
from kotonebot import task, device, image, color
|
||||
|
||||
|
@ -16,17 +17,18 @@ def acquire_activity_funds():
|
|||
|
||||
if not at_home():
|
||||
goto_home()
|
||||
device.screenshot()
|
||||
if color.find('#ff1249', rect=R.Daily.BoxHomeActivelyFunds):
|
||||
logger.info('Claiming activity funds.')
|
||||
device.click(R.Daily.BoxHomeActivelyFunds)
|
||||
device.click(image.expect_wait(R.Common.ButtonClose))
|
||||
logger.info('Activity funds claimed.')
|
||||
else:
|
||||
logger.info('No activity funds to claim.')
|
||||
|
||||
while not at_home():
|
||||
pass
|
||||
for _ in Loop():
|
||||
if (
|
||||
not color.find('#ff1249', rect=R.Daily.BoxHomeActivelyFunds)
|
||||
and at_home()
|
||||
):
|
||||
break
|
||||
elif image.find(R.Common.ButtonClose):
|
||||
logger.info('Closing popup dialog.')
|
||||
device.click()
|
||||
else:
|
||||
device.click(R.Daily.BoxHomeActivelyFunds)
|
||||
|
||||
if __name__ == '__main__':
|
||||
import logging
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
import logging
|
||||
|
||||
from kotonebot.kaa.tasks import R
|
||||
from kotonebot.kaa.common import conf
|
||||
from kotonebot.kaa.config import conf
|
||||
from ..actions.scenes import at_home, goto_home
|
||||
from kotonebot import device, image, task, color, rect_expand, sleep
|
||||
|
||||
|
|
|
@ -4,7 +4,7 @@ from typing import Literal
|
|||
from datetime import timedelta
|
||||
|
||||
from kotonebot.kaa.tasks import R
|
||||
from kotonebot.kaa.common import conf
|
||||
from kotonebot.kaa.config import conf
|
||||
from ..actions.scenes import at_home, goto_home
|
||||
from kotonebot import task, device, image, action, ocr, contains, cropped, rect_expand, color, sleep, regex
|
||||
|
||||
|
@ -155,17 +155,15 @@ def assignment():
|
|||
if not at_home():
|
||||
goto_home()
|
||||
btn_assignment = image.expect_wait(R.Daily.ButtonAssignmentPartial)
|
||||
notification_rect = rect_expand(btn_assignment.rect, top=40, right=40)
|
||||
complete_rect = rect_expand(btn_assignment.rect, right=40, bottom=60)
|
||||
with device.pinned():
|
||||
completed = color.find('#ff6085', rect=complete_rect)
|
||||
if completed:
|
||||
logger.info('Assignment completed. Acquiring...')
|
||||
notification_dot = color.find('#ff134a', rect=notification_rect)
|
||||
if not notification_dot and not completed:
|
||||
logger.info('No action needed.')
|
||||
# TODO: 获取剩余时间,并根据时间更新调度
|
||||
return
|
||||
|
||||
completed = color.find('#ff6085', rect=R.Daily.BoxHomeAssignment)
|
||||
if completed:
|
||||
logger.info('Assignment completed. Acquiring...')
|
||||
notification_dot = color.find('#ff134a', rect=R.Daily.BoxHomeAssignment)
|
||||
if not notification_dot and not completed:
|
||||
logger.info('No action needed.')
|
||||
# TODO: 获取剩余时间,并根据时间更新调度
|
||||
return
|
||||
|
||||
# 点击工作按钮
|
||||
logger.debug('Clicking assignment icon.')
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
import logging
|
||||
|
||||
from kotonebot.kaa.tasks import R
|
||||
from kotonebot.kaa.common import conf
|
||||
from kotonebot.kaa.config import conf
|
||||
from kotonebot.kaa.game_ui.scrollable import Scrollable
|
||||
from ..actions.scenes import at_home, goto_home
|
||||
from kotonebot.backend.image import TemplateMatchResult
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
import logging
|
||||
|
||||
from kotonebot.kaa.tasks import R
|
||||
from kotonebot.kaa.common import conf
|
||||
from kotonebot.kaa.config import conf
|
||||
from kotonebot.kaa.game_ui import toolbar_menu
|
||||
from ..actions.scenes import at_home, goto_home
|
||||
from kotonebot import task, device, image, sleep, ocr
|
||||
|
|
|
@ -2,12 +2,15 @@
|
|||
import logging
|
||||
from gettext import gettext as _
|
||||
|
||||
from kotonebot.errors import StopCurrentTask
|
||||
from kotonebot.kaa.tasks import R
|
||||
from kotonebot.kaa.common import conf
|
||||
from kotonebot.kaa.game_ui import WhiteFilter
|
||||
from kotonebot.kaa.config import conf
|
||||
from kotonebot.kaa.game_ui import WhiteFilter, dialog
|
||||
from ..actions.scenes import at_home, goto_home
|
||||
from ..actions.loading import wait_loading_end
|
||||
from kotonebot import device, image, ocr, color, action, task, user, rect_expand, sleep, contains, Interval
|
||||
from kotonebot import device, image, ocr, color, action, task, rect_expand, sleep, contains, Interval
|
||||
from kotonebot.backend.context.context import vars
|
||||
from kotonebot.ui import user as ui_user
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
@ -70,11 +73,39 @@ def handle_challenge() -> bool:
|
|||
|
||||
# 记忆未编成 [screenshots/contest/no_memo.png]
|
||||
if image.find(R.Daily.TextContestNoMemory):
|
||||
logger.debug('Memory not set. Using auto-compilation.')
|
||||
user.warning('竞赛未编成', _('记忆未编成。将使用自动编成。'), once=True)
|
||||
if image.find(R.Daily.ButtonContestChallenge):
|
||||
device.click()
|
||||
return True
|
||||
logger.debug('Memory not set.')
|
||||
when_no_set = conf().contest.when_no_set
|
||||
|
||||
auto_compilation = False
|
||||
match when_no_set:
|
||||
case 'remind':
|
||||
# 关闭编成提示弹窗
|
||||
dialog.expect_no(msg='Closed memory not set dialog.')
|
||||
ui_user.warning('竞赛未编成', '已跳过此次竞赛任务。')
|
||||
logger.info('Contest skipped due to memory not set (remind mode).')
|
||||
raise StopCurrentTask
|
||||
case 'wait':
|
||||
dialog.expect_no(msg='Closed memory not set dialog.')
|
||||
ui_user.warning('竞赛未编成', '已自动暂停,请手动编成后返回至挑战开始页,并点击网页上「恢复」按钮或使用快捷键继续执行。')
|
||||
vars.flow.request_pause(wait_resume=True)
|
||||
logger.info('Contest paused due to memory not set (wait mode).')
|
||||
return True
|
||||
case 'auto_set' | 'auto_set_silent':
|
||||
if when_no_set == 'auto_set':
|
||||
ui_user.warning('竞赛未编成', '将使用自动编成。', once=True)
|
||||
logger.debug('Using auto-compilation with notification.')
|
||||
else: # auto_set_silent
|
||||
logger.debug('Using auto-compilation silently.')
|
||||
auto_compilation = True
|
||||
case _:
|
||||
logger.warning(f'Unknown value for contest.when_no_set: {when_no_set}, fallback to auto.')
|
||||
logger.debug('Using auto-compilation silently.')
|
||||
auto_compilation = True
|
||||
|
||||
if auto_compilation:
|
||||
if image.find(R.Daily.ButtonContestChallenge):
|
||||
device.click()
|
||||
return True
|
||||
|
||||
# 勾选跳过所有
|
||||
# [screenshots/contest/contest2.png]
|
||||
|
@ -85,7 +116,7 @@ def handle_challenge() -> bool:
|
|||
|
||||
# 跳过所有
|
||||
# [screenshots/contest/contest1.png]
|
||||
if image.find(R.Daily.ButtonIconSkip, preprocessors=[WhiteFilter()]):
|
||||
if image.find(R.Daily.ButtonIconSkip, preprocessors=[WhiteFilter()], threshold=0.7):
|
||||
logger.debug('Skipping all.')
|
||||
device.click()
|
||||
return True
|
||||
|
|
|
@ -4,7 +4,7 @@ import logging
|
|||
from kotonebot.kaa.tasks import R
|
||||
|
||||
from kotonebot.primitives import Rect
|
||||
from kotonebot.kaa.common import conf, Priority
|
||||
from kotonebot.kaa.config import conf, Priority
|
||||
from ..actions.loading import wait_loading_end
|
||||
from ..actions.scenes import at_home, goto_home
|
||||
from kotonebot import device, image, color, task, action, rect_expand, sleep
|
||||
|
|
|
@ -2,11 +2,12 @@
|
|||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from kotonebot.backend.loop import Loop
|
||||
from kotonebot.kaa.tasks import R
|
||||
from kotonebot.kaa.common import conf, DailyMoneyShopItems
|
||||
from kotonebot.util import cropped
|
||||
from kotonebot.kaa.config import conf, DailyMoneyShopItems
|
||||
from kotonebot.primitives.geometry import Point
|
||||
from kotonebot.util import Countdown, cropped
|
||||
from kotonebot import task, device, image, action, sleep
|
||||
from kotonebot.backend.dispatch import SimpleDispatcher
|
||||
from ..actions.scenes import goto_home, goto_shop, at_daily_shop
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
@ -37,10 +38,10 @@ def money_items2(items: Optional[list[DailyMoneyShopItems]] = None):
|
|||
scroll = 0
|
||||
while items:
|
||||
for item in items:
|
||||
if image.find(item.to_resource(), colored=True):
|
||||
if ret := image.find(item.to_resource(), colored=True):
|
||||
logger.info(f'Purchasing {item.to_ui_text(item)}...')
|
||||
device.click()
|
||||
handle_purchase_dialog()
|
||||
confirm_purchase(ret.position)
|
||||
finished.append(item)
|
||||
items = [item for item in items if item not in finished]
|
||||
# 全都买完了
|
||||
|
@ -71,16 +72,18 @@ def dispatch_recommended_items():
|
|||
|
||||
while True:
|
||||
device.screenshot()
|
||||
if image.find(R.Daily.TextShopRecommended):
|
||||
if rec := image.find(R.Daily.TextShopRecommended):
|
||||
logger.info(f'Clicking on recommended item.') # TODO: 计数
|
||||
device.click()
|
||||
handle_purchase_dialog()
|
||||
pos = rec.position.offset(dx=0, dy=80)
|
||||
device.click(pos)
|
||||
confirm_purchase(pos)
|
||||
sleep(2.5) #
|
||||
elif image.find(R.Daily.IconTitleDailyShop) and not image.find(R.Daily.TextShopRecommended):
|
||||
logger.info(f'No recommended item found. Finished.')
|
||||
break
|
||||
|
||||
@action('确认购买', screenshot_mode='manual-inherit')
|
||||
def handle_purchase_dialog():
|
||||
def confirm_purchase(target_item_pos: Point | None = None):
|
||||
"""
|
||||
确认购买
|
||||
|
||||
|
@ -89,11 +92,27 @@ def handle_purchase_dialog():
|
|||
"""
|
||||
# 前置条件:[screenshots\shop\dialog.png]
|
||||
# TODO: 需要有个更好的方式检测是否已购买
|
||||
purchased = (SimpleDispatcher('dispatch_purchase_dialog')
|
||||
.until(R.Common.ButtonConfirm, result=False)
|
||||
.until(R.Daily.TextShopPurchased, result=True)
|
||||
.timeout(timeout=3, result=True)
|
||||
).run()
|
||||
purchased = False
|
||||
cd = Countdown(sec=3)
|
||||
for _ in Loop():
|
||||
if cd.expired():
|
||||
purchased = True
|
||||
break
|
||||
if image.find(R.Daily.TextShopItemSoldOut):
|
||||
logger.info('Item sold out.')
|
||||
purchased = True
|
||||
break
|
||||
elif image.find(R.Daily.TextShopItemPurchased):
|
||||
logger.info('Item already purchased.')
|
||||
purchased = True
|
||||
break
|
||||
elif image.find(R.Common.ButtonConfirm):
|
||||
logger.info('Confirming purchase...')
|
||||
device.click()
|
||||
sleep(0.5)
|
||||
else:
|
||||
if target_item_pos:
|
||||
device.click(target_item_pos)
|
||||
|
||||
if purchased:
|
||||
logger.info('Item sold out.')
|
||||
|
@ -132,20 +151,19 @@ def ap_items():
|
|||
logger.info(f'Purchasing #{index} AP item.')
|
||||
device.click(results[index])
|
||||
sleep(0.5)
|
||||
with cropped(device, y1=0.3):
|
||||
purchased = image.wait_for(R.Daily.TextShopPurchased, timeout=1)
|
||||
if purchased is not None:
|
||||
logger.info(f'AP item #{index} already purchased.')
|
||||
continue
|
||||
comfirm = image.expect_wait(R.Common.ButtonConfirm, timeout=2)
|
||||
# 如果数量不是最大,调到最大
|
||||
while image.find(R.Daily.ButtonShopCountAdd, colored=True):
|
||||
logger.debug('Adjusting quantity(+1)...')
|
||||
device.click()
|
||||
sleep(0.3)
|
||||
logger.debug(f'Confirming purchase...')
|
||||
device.click(comfirm)
|
||||
sleep(1.5)
|
||||
purchased = image.wait_for(R.Daily.TextShopItemSoldOut, timeout=1)
|
||||
if purchased is not None:
|
||||
logger.info(f'AP item #{index} already purchased.')
|
||||
continue
|
||||
comfirm = image.expect_wait(R.Common.ButtonConfirm, timeout=2)
|
||||
# 如果数量不是最大,调到最大
|
||||
while image.find(R.Daily.ButtonShopCountAdd, colored=True):
|
||||
logger.debug('Adjusting quantity(+1)...')
|
||||
device.click()
|
||||
sleep(0.3)
|
||||
logger.debug(f'Confirming purchase...')
|
||||
device.click(comfirm)
|
||||
sleep(1.5)
|
||||
else:
|
||||
logger.warning(f'AP item #{index} not found')
|
||||
logger.info(f'Purchasing AP items completed. {len(item_indices)} items purchased.')
|
||||
|
@ -158,8 +176,8 @@ def purchase():
|
|||
if not conf().purchase.enabled:
|
||||
logger.info('Purchase is disabled.')
|
||||
return
|
||||
if not at_daily_shop():
|
||||
goto_shop()
|
||||
|
||||
goto_shop()
|
||||
# 进入每日商店 [screenshots\shop\shop.png]
|
||||
device.click(image.expect(R.Daily.ButtonDailyShop)) # TODO: memoable
|
||||
# 等待载入
|
||||
|
@ -170,6 +188,12 @@ def purchase():
|
|||
image.expect_wait(R.Daily.IconShopMoney)
|
||||
money_items2()
|
||||
sleep(0.5)
|
||||
if image.find(R.Daily.ButtonRefreshMoneyShop):
|
||||
logger.info('Refreshing money shop.')
|
||||
device.click()
|
||||
sleep(0.5)
|
||||
money_items2()
|
||||
sleep(0.5)
|
||||
else:
|
||||
logger.info('Money purchase is disabled.')
|
||||
|
||||
|
@ -178,7 +202,7 @@ def purchase():
|
|||
# 点击 AP 选项卡
|
||||
device.click(ap_tab)
|
||||
# 等待 AP 选项卡加载完成
|
||||
image.expect_wait(R.Daily.IconShopAp)
|
||||
image.expect_wait(R.Daily.IconShopAp, threshold=0.7)
|
||||
ap_items()
|
||||
sleep(0.5)
|
||||
else:
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
import logging
|
||||
|
||||
from kotonebot.kaa.tasks import R
|
||||
from kotonebot.kaa.common import conf
|
||||
from kotonebot.kaa.config import conf
|
||||
from kotonebot.kaa.game_ui.scrollable import Scrollable
|
||||
from ..actions.scenes import at_home, goto_home
|
||||
from kotonebot import task, device, image, sleep
|
||||
|
|
|
@ -5,9 +5,10 @@ import logging
|
|||
import _thread
|
||||
import threading
|
||||
|
||||
from kotonebot.backend.bot import PostTaskContext
|
||||
from kotonebot.ui import user
|
||||
from ..kaa_context import instance
|
||||
from kotonebot.kaa.common import Priority, conf
|
||||
from kotonebot.kaa.config import Priority, conf
|
||||
from kotonebot import task, action, config, device
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
@ -35,8 +36,8 @@ def windows_close():
|
|||
os.system('taskkill /f /im gakumas.exe')
|
||||
logger.info("Game closed successfully")
|
||||
|
||||
@task('关闭游戏', priority=Priority.END_GAME)
|
||||
def end_game():
|
||||
@task('关闭游戏', priority=Priority.END_GAME, run_at='post')
|
||||
def end_game(ctx: PostTaskContext):
|
||||
"""
|
||||
游戏结束时执行的任务。
|
||||
"""
|
||||
|
@ -101,4 +102,4 @@ if __name__ == '__main__':
|
|||
conf().end_game.kill_game = True
|
||||
conf().end_game.kill_dmm = True
|
||||
conf().end_game.kill_emulator = True
|
||||
end_game()
|
||||
end_game(PostTaskContext(False, None))
|
|
@ -7,7 +7,7 @@ import numpy as np
|
|||
from cv2.typing import MatLike
|
||||
|
||||
from kotonebot.kaa.tasks import R
|
||||
from kotonebot.kaa.common import conf
|
||||
from kotonebot.kaa.config import conf
|
||||
from kotonebot.kaa.game_ui import dialog
|
||||
from kotonebot.kaa.util.trace import trace
|
||||
from kotonebot.primitives import RectTuple, Rect
|
||||
|
|
|
@ -9,11 +9,12 @@ from kotonebot import (
|
|||
sleep,
|
||||
Interval,
|
||||
)
|
||||
from kotonebot.kaa.config.schema import produce_solution
|
||||
from kotonebot.primitives import Rect
|
||||
from kotonebot.kaa.tasks import R
|
||||
from .p_drink import acquire_p_drink
|
||||
from kotonebot.util import measure_time
|
||||
from kotonebot.kaa.common import conf
|
||||
from kotonebot.kaa.config import conf
|
||||
from kotonebot.kaa.tasks.actions.loading import loading
|
||||
from kotonebot.kaa.game_ui import CommuEventButtonUI, dialog, badge
|
||||
from kotonebot.kaa.tasks.actions.commu import handle_unread_commu
|
||||
|
@ -188,7 +189,7 @@ def fast_acquisitions() -> AcquisitionType | None:
|
|||
|
||||
# 跳过未读交流
|
||||
logger.debug("Check skip commu...")
|
||||
if conf().produce.skip_commu and handle_unread_commu(img):
|
||||
if produce_solution().data.skip_commu and handle_unread_commu(img):
|
||||
return "SkipCommu"
|
||||
device.click(10, 10)
|
||||
|
||||
|
|
|
@ -2,6 +2,7 @@ import logging
|
|||
from typing_extensions import assert_never
|
||||
from typing import Literal
|
||||
|
||||
from kotonebot.kaa.config.schema import produce_solution
|
||||
from kotonebot.kaa.game_ui.schedule import Schedule
|
||||
from kotonebot.kaa.tasks import R
|
||||
from ..actions import loading
|
||||
|
@ -11,8 +12,8 @@ from .cards import do_cards, CardDetectResult
|
|||
from ..actions.commu import handle_unread_commu
|
||||
from kotonebot.errors import UnrecoverableError
|
||||
from kotonebot.util import Countdown, Interval, cropped
|
||||
from kotonebot.backend.dispatch import DispatcherContext
|
||||
from kotonebot.kaa.common import ProduceAction, RecommendCardDetectionMode, conf
|
||||
from kotonebot.backend.loop import Loop
|
||||
from kotonebot.kaa.config import ProduceAction, RecommendCardDetectionMode
|
||||
from ..produce.common import until_acquisition_clear, commu_event, fast_acquisitions
|
||||
from kotonebot import ocr, device, contains, image, regex, action, sleep, wait
|
||||
from ..produce.non_lesson_actions import (
|
||||
|
@ -192,11 +193,11 @@ def practice():
|
|||
|
||||
def threshold_predicate(card_count: int, result: CardDetectResult):
|
||||
border_scores = (result.left_score, result.right_score, result.top_score, result.bottom_score)
|
||||
is_strict_mode = conf().produce.recommend_card_detection_mode == RecommendCardDetectionMode.STRICT
|
||||
is_strict_mode = produce_solution().data.recommend_card_detection_mode == RecommendCardDetectionMode.STRICT
|
||||
if is_strict_mode:
|
||||
return (
|
||||
result.score >= 0.05
|
||||
and len(list(filter(lambda x: x >= 0.05, border_scores))) >= 3
|
||||
result.score >= 0.043
|
||||
and len(list(filter(lambda x: x >= 0.04, border_scores))) >= 3
|
||||
)
|
||||
else:
|
||||
return result.score >= 0.03
|
||||
|
@ -224,7 +225,7 @@ def exam(type: Literal['mid', 'final']):
|
|||
logger.info("Exam started")
|
||||
|
||||
def threshold_predicate(card_count: int, result: CardDetectResult):
|
||||
is_strict_mode = conf().produce.recommend_card_detection_mode == RecommendCardDetectionMode.STRICT
|
||||
is_strict_mode = produce_solution().data.recommend_card_detection_mode == RecommendCardDetectionMode.STRICT
|
||||
total = lambda t: result.score >= t
|
||||
def borders(t):
|
||||
# 卡片数量小于三时无遮挡,以及最后一张卡片也总是无遮挡
|
||||
|
@ -253,7 +254,7 @@ def exam(type: Literal['mid', 'final']):
|
|||
if result.type == 10: # SKIP
|
||||
return total(0.4) and borders(0.02)
|
||||
else:
|
||||
return total(0.2) and borders(0.02)
|
||||
return total(0.15) and borders(0.02)
|
||||
else:
|
||||
return total(0.10) and borders(0.01)
|
||||
|
||||
|
@ -422,7 +423,7 @@ def produce_end():
|
|||
# [screenshots/produce_end/end_follow.png]
|
||||
elif image.find(R.InPurodyuusu.ButtonCancel):
|
||||
logger.info("Follow producer dialog found. Click to close.")
|
||||
if conf().produce.follow_producer:
|
||||
if produce_solution().data.follow_producer:
|
||||
logger.info("Follow producer")
|
||||
device.click(image.expect_wait(R.InPurodyuusu.ButtonFollowNoIcon))
|
||||
else:
|
||||
|
@ -506,12 +507,12 @@ def week_normal(week_first: bool = False):
|
|||
action: ProduceAction | None = None
|
||||
# SP 课程
|
||||
if (
|
||||
conf().produce.prefer_lesson_ap
|
||||
produce_solution().data.prefer_lesson_ap
|
||||
and handle_sp_lesson()
|
||||
):
|
||||
action = ProduceAction.DANCE
|
||||
else:
|
||||
actions = conf().produce.actions_order
|
||||
actions = produce_solution().data.actions_order
|
||||
for action in actions:
|
||||
logger.debug("Checking action: %s", action)
|
||||
if action := handle_action(action):
|
||||
|
@ -539,7 +540,7 @@ def week_normal(week_first: bool = False):
|
|||
def week_final_lesson():
|
||||
until_action_scene()
|
||||
action: ProduceAction | None = None
|
||||
actions = conf().produce.actions_order
|
||||
actions = produce_solution().data.actions_order
|
||||
for action in actions:
|
||||
logger.debug("Checking action: %s", action)
|
||||
if action := handle_action(action, True):
|
||||
|
@ -703,8 +704,8 @@ ProduceStage = Literal[
|
|||
'unknown', # 未知场景
|
||||
]
|
||||
|
||||
@action('检测当前培育场景', dispatcher=True)
|
||||
def detect_produce_scene(ctx: DispatcherContext) -> ProduceStage:
|
||||
@action('检测当前培育场景')
|
||||
def detect_produce_scene() -> ProduceStage:
|
||||
"""
|
||||
判断当前是培育的什么阶段,并开始 Regular 培育。
|
||||
|
||||
|
@ -713,31 +714,33 @@ def detect_produce_scene(ctx: DispatcherContext) -> ProduceStage:
|
|||
"""
|
||||
logger.info("Detecting current produce stage...")
|
||||
|
||||
# 行动场景
|
||||
texts = ocr.ocr()
|
||||
if (
|
||||
image.find_multi([
|
||||
R.InPurodyuusu.TextPDiary, # 普通周
|
||||
R.InPurodyuusu.ButtonFinalPracticeDance # 离考试剩余一周
|
||||
])
|
||||
):
|
||||
logger.info("Detection result: At action scene.")
|
||||
ctx.finish()
|
||||
return 'action'
|
||||
elif texts.where(regex('CLEARまで|PERFECTまで')):
|
||||
logger.info("Detection result: At practice ongoing.")
|
||||
ctx.finish()
|
||||
return 'practice-ongoing'
|
||||
elif is_exam_scene():
|
||||
logger.info("Detection result: At exam scene.")
|
||||
ctx.finish()
|
||||
return 'exam-ongoing'
|
||||
else:
|
||||
if fast_acquisitions():
|
||||
return 'unknown'
|
||||
if commu_event():
|
||||
return 'unknown'
|
||||
return 'unknown'
|
||||
for _ in Loop():
|
||||
# 行动场景
|
||||
texts = ocr.ocr()
|
||||
if (
|
||||
image.find_multi([
|
||||
R.InPurodyuusu.TextPDiary, # 普通周
|
||||
R.InPurodyuusu.ButtonFinalPracticeDance # 离考试剩余一周
|
||||
])
|
||||
):
|
||||
logger.info("Detection result: At action scene.")
|
||||
return 'action'
|
||||
elif texts.where(regex('CLEARまで|PERFECTまで')):
|
||||
logger.info("Detection result: At practice ongoing.")
|
||||
return 'practice-ongoing'
|
||||
elif is_exam_scene():
|
||||
logger.info("Detection result: At exam scene.")
|
||||
return 'exam-ongoing'
|
||||
else:
|
||||
if fast_acquisitions():
|
||||
# 继续循环检测
|
||||
pass
|
||||
elif commu_event():
|
||||
# 继续循环检测
|
||||
pass
|
||||
# 如果没有返回,说明需要继续检测
|
||||
sleep(0.5) # 等待一段时间再重新检测
|
||||
return 'unknown'
|
||||
|
||||
@action('开始 Hajime 培育')
|
||||
def hajime_from_stage(stage: ProduceStage, type: Literal['regular', 'pro', 'master'], week: int):
|
||||
|
|
|
@ -5,10 +5,11 @@
|
|||
"""
|
||||
from logging import getLogger
|
||||
|
||||
from kotonebot.kaa.config.schema import produce_solution
|
||||
from kotonebot.kaa.game_ui import dialog
|
||||
|
||||
from kotonebot.kaa.tasks import R
|
||||
from kotonebot.kaa.common import conf
|
||||
from kotonebot.kaa.config import conf
|
||||
from ..produce.common import fast_acquisitions
|
||||
from kotonebot.kaa.game_ui.commu_event_buttons import CommuEventButtonUI
|
||||
from kotonebot.util import Countdown, Interval
|
||||
|
@ -66,7 +67,7 @@ def enter_study():
|
|||
R.InPurodyuusu.TextSelfStudyVocal
|
||||
]):
|
||||
logger.info("授業 type: Self study.")
|
||||
target = conf().produce.self_study_lesson
|
||||
target = produce_solution().data.self_study_lesson
|
||||
if target == 'dance':
|
||||
logger.debug("Clicking on lesson dance.")
|
||||
device.double_click(image.expect(R.InPurodyuusu.TextSelfStudyDance))
|
||||
|
|
|
@ -1,11 +1,11 @@
|
|||
import logging
|
||||
from itertools import cycle
|
||||
from typing import Optional, Literal
|
||||
from typing_extensions import assert_never
|
||||
|
||||
from kotonebot.kaa.config.schema import produce_solution
|
||||
from kotonebot.ui import user
|
||||
from kotonebot.kaa.tasks import R
|
||||
from kotonebot.kaa.common import conf
|
||||
from kotonebot.kaa.config import conf
|
||||
from kotonebot.kaa.game_ui import dialog
|
||||
from ..actions.scenes import at_home, goto_home
|
||||
from kotonebot.backend.loop import Loop, StatedLoop
|
||||
|
@ -15,6 +15,7 @@ from kotonebot.kaa.game_ui.idols_overview import locate_idol, match_idol
|
|||
from ..produce.in_purodyuusu import hajime_pro, hajime_regular, hajime_master, resume_pro_produce, resume_regular_produce, \
|
||||
resume_master_produce
|
||||
from kotonebot import device, image, ocr, task, action, sleep, contains, regex
|
||||
from kotonebot.kaa.errors import IdolCardNotFoundError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
@ -58,7 +59,7 @@ def select_idol(skin_id: str):
|
|||
# 选择偶像
|
||||
pos = locate_idol(skin_id)
|
||||
if pos is None:
|
||||
raise ValueError(f"Idol {skin_id} not found.")
|
||||
raise IdolCardNotFoundError(skin_id)
|
||||
# 确认
|
||||
it.reset()
|
||||
while btn_confirm := image.find(R.Common.ButtonConfirmNoIcon):
|
||||
|
@ -150,7 +151,7 @@ def resume_produce():
|
|||
max_retries = 5
|
||||
current_week = None
|
||||
while retry_count < max_retries:
|
||||
week_text = ocr.ocr(R.Produce.BoxResumeDialogWeeks).squash().regex(r'\d+/\d+')
|
||||
week_text = ocr.ocr(R.Produce.BoxResumeDialogWeeks, lang='en').squash().regex(r'\d+/\d+')
|
||||
if week_text:
|
||||
weeks = week_text[0].split('/')
|
||||
logger.info(f'Current week: {weeks[0]}/{weeks[1]}')
|
||||
|
@ -191,7 +192,7 @@ def do_produce(
|
|||
|
||||
前置条件:可导航至首页的任意页面\n
|
||||
结束状态:游戏首页\n
|
||||
|
||||
|
||||
:param memory_set_index: 回忆编成编号。
|
||||
:param idol_skin_id: 要培育的偶像。如果为 None,则使用配置文件中的偶像。
|
||||
:param mode: 培育模式。
|
||||
|
@ -205,11 +206,18 @@ def do_produce(
|
|||
goto_home()
|
||||
|
||||
device.screenshot()
|
||||
# 有进行中培育的情况
|
||||
if ocr.find(contains('中'), rect=R.Produce.BoxProduceOngoing):
|
||||
logger.info('Ongoing produce found. Try to resume produce.')
|
||||
resume_produce()
|
||||
return True
|
||||
# 点击培育按钮,然后判断是新开还是再开培育
|
||||
for _ in Loop(interval=0.6):
|
||||
if image.find(R.Produce.TitleIconProudce):
|
||||
# 新开
|
||||
break
|
||||
elif image.find(R.Produce.ButtonResume):
|
||||
# 再开
|
||||
resume_produce()
|
||||
return True
|
||||
else:
|
||||
device.click(R.Produce.BoxProduceOngoing)
|
||||
sleep(2)
|
||||
|
||||
# 0. 进入培育页面
|
||||
logger.info(f'Enter produce page. Mode: {mode}')
|
||||
|
@ -235,7 +243,7 @@ def do_produce(
|
|||
result = False
|
||||
break
|
||||
if not result:
|
||||
if conf().produce.use_ap_drink:
|
||||
if produce_solution().data.use_ap_drink:
|
||||
# [kotonebot-resource\sprites\jp\produce\screenshot_no_enough_ap_1.png]
|
||||
# [kotonebot-resource\sprites\jp\produce\screenshot_no_enough_ap_2.png]
|
||||
# [kotonebot-resource\sprites\jp\produce\screenshot_no_enough_ap_3.png]
|
||||
|
@ -344,11 +352,11 @@ def do_produce(
|
|||
|
||||
# 4. 选择道具 [screenshots/produce/screenshot_produce_start_4_end.png]
|
||||
# TODO: 如果道具不足,这里加入推送提醒
|
||||
if conf().produce.use_note_boost:
|
||||
if produce_solution().data.use_note_boost:
|
||||
if image.find(R.Produce.CheckboxIconNoteBoost):
|
||||
device.click()
|
||||
sleep(0.1)
|
||||
if conf().produce.use_pt_boost:
|
||||
if produce_solution().data.use_pt_boost:
|
||||
if image.find(R.Produce.CheckboxIconSupportPtBoost):
|
||||
device.click()
|
||||
sleep(0.1)
|
||||
|
@ -382,28 +390,33 @@ def produce():
|
|||
return
|
||||
import time
|
||||
count = conf().produce.produce_count
|
||||
idols = conf().produce.idols
|
||||
memory_sets = conf().produce.memory_sets
|
||||
mode = conf().produce.mode
|
||||
idol = produce_solution().data.idol
|
||||
memory_set = produce_solution().data.memory_set
|
||||
support_card_set = produce_solution().data.support_card_set
|
||||
mode = produce_solution().data.mode
|
||||
# 数据验证
|
||||
if count < 0:
|
||||
user.warning('配置有误', '培育次数不能小于 0。将跳过本次培育。')
|
||||
return
|
||||
if idol is None:
|
||||
user.warning('配置有误', '未设置要培育的偶像。将跳过本次培育。')
|
||||
return
|
||||
|
||||
idol_iterator = cycle(idols)
|
||||
memory_set_iterator = cycle(memory_sets)
|
||||
for i in range(count):
|
||||
start_time = time.time()
|
||||
idol = next(idol_iterator)
|
||||
if conf().produce.auto_set_memory:
|
||||
memory_set = None
|
||||
if produce_solution().data.auto_set_memory:
|
||||
memory_set_to_use = None
|
||||
else:
|
||||
memory_set = next(memory_set_iterator, None)
|
||||
memory_set_to_use = memory_set
|
||||
if produce_solution().data.auto_set_support_card:
|
||||
support_card_set_to_use = None
|
||||
else:
|
||||
support_card_set_to_use = support_card_set
|
||||
logger.info(
|
||||
f'Produce start with: '
|
||||
f'idol: {idol}, mode: {mode}, memory_set: #{memory_set}'
|
||||
f'idol: {idol}, mode: {mode}, memory_set: #{memory_set_to_use}, support_card_set: #{support_card_set_to_use}'
|
||||
)
|
||||
if not do_produce(idol, mode, memory_set):
|
||||
if not do_produce(idol, mode, memory_set_to_use):
|
||||
user.info('AP 不足', f'由于 AP 不足,跳过了 {count - i} 次培育。')
|
||||
logger.info('%d produce(s) skipped because of insufficient AP.', count - i)
|
||||
break
|
||||
|
@ -420,11 +433,11 @@ if __name__ == '__main__':
|
|||
from kotonebot.kaa.main import Kaa
|
||||
|
||||
conf().produce.enabled = True
|
||||
conf().produce.mode = 'pro'
|
||||
conf().produce.produce_count = 1
|
||||
# conf().produce.idols = ['i_card-skin-hski-3-002']
|
||||
conf().produce.memory_sets = [1]
|
||||
conf().produce.auto_set_memory = False
|
||||
produce_solution().data.mode = 'pro'
|
||||
# produce_solution().data.idol = 'i_card-skin-hski-3-002'
|
||||
produce_solution().data.memory_set = 1
|
||||
produce_solution().data.auto_set_memory = False
|
||||
# do_produce(PIdol.月村手毬_初声, 'pro', 5)
|
||||
produce()
|
||||
# a()
|
||||
|
|
|
@ -5,13 +5,14 @@ import ctypes
|
|||
import logging
|
||||
|
||||
from kotonebot.kaa.tasks import R
|
||||
from kotonebot.kaa.common import Priority, conf
|
||||
from kotonebot.kaa.config import Priority, conf
|
||||
from .actions.loading import loading
|
||||
from kotonebot.util import Countdown, Interval
|
||||
from .actions.scenes import at_home, goto_home
|
||||
from .actions.commu import handle_unread_commu
|
||||
from kotonebot.errors import GameUpdateNeededError
|
||||
from kotonebot import task, action, sleep, device, image, ocr, config
|
||||
from kotonebot.backend.context.context import vars
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
@ -170,6 +171,7 @@ def windows_launch():
|
|||
# 等待游戏窗口出现
|
||||
it = Interval()
|
||||
while True:
|
||||
vars.flow.check()
|
||||
if ahk.find_window(title='gakumas', title_match_mode=3):
|
||||
logger.debug('Game window found.')
|
||||
break
|
||||
|
|
|
@ -4,6 +4,7 @@ import time
|
|||
|
||||
import cv2
|
||||
from cv2.typing import MatLike
|
||||
from win11toast import toast
|
||||
|
||||
from .pushkit import Wxpusher
|
||||
from .. import logging
|
||||
|
@ -25,17 +26,6 @@ def retry(func):
|
|||
continue
|
||||
return wrapper
|
||||
|
||||
def ask(
|
||||
question: str,
|
||||
options: list[str],
|
||||
*,
|
||||
timeout: float = -1,
|
||||
) -> bool:
|
||||
"""
|
||||
询问用户
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def _save_local(
|
||||
title: str,
|
||||
message: str,
|
||||
|
@ -74,6 +64,43 @@ def push(
|
|||
logger.warning('push remote message failed: %s', e)
|
||||
_save_local(title, message, images)
|
||||
|
||||
def _show_toast(title: str, message: str | None = None, buttons: list[str] | None = None):
|
||||
"""
|
||||
统一的 Toast 通知函数
|
||||
|
||||
:param title: 通知标题
|
||||
:param message: 通知消息内容
|
||||
:param buttons: 按钮列表,如果提供则显示带按钮的通知
|
||||
"""
|
||||
try:
|
||||
if buttons:
|
||||
logger.verbose('showing toast notification with buttons: %s - %s', title, message or '')
|
||||
toast(title, message or '', buttons=buttons)
|
||||
else:
|
||||
# 如果没有 message,只显示 title
|
||||
if message:
|
||||
logger.verbose('showing toast notification: %s - %s', title, message)
|
||||
toast(title, message)
|
||||
else:
|
||||
logger.verbose('showing toast notification: %s', title)
|
||||
toast(title)
|
||||
except Exception as e:
|
||||
logger.warning('toast notification failed: %s', e)
|
||||
|
||||
def ask(
|
||||
question: str,
|
||||
options: list[tuple[str, str]],
|
||||
*,
|
||||
timeout: float = -1,
|
||||
) -> str:
|
||||
"""
|
||||
询问用户
|
||||
"""
|
||||
# 将选项转换为按钮列表
|
||||
buttons = [option[1] for option in options]
|
||||
_show_toast("琴音小助手询问", question, buttons=buttons)
|
||||
raise NotImplementedError
|
||||
|
||||
def info(
|
||||
title: str,
|
||||
message: str | None = None,
|
||||
|
@ -83,6 +110,7 @@ def info(
|
|||
):
|
||||
logger.info('user.info: %s', message)
|
||||
push('KAA:' + title, message, images=images)
|
||||
_show_toast('KAA:' + title, message)
|
||||
|
||||
def warning(
|
||||
title: str,
|
||||
|
@ -98,7 +126,8 @@ def warning(
|
|||
:param once: 每次运行是否只显示一次。
|
||||
"""
|
||||
logger.warning('user.warning: %s', message)
|
||||
push("KAA 警告:" + title, message, images=images)
|
||||
push("琴音小助手警告:" + title, message, images=images)
|
||||
_show_toast("琴音小助手警告:" + title, message)
|
||||
|
||||
def error(
|
||||
title: str,
|
||||
|
@ -111,4 +140,5 @@ def error(
|
|||
错误信息。
|
||||
"""
|
||||
logger.error('user.error: %s', message)
|
||||
push("KAA 错误:" + title, message, images=images)
|
||||
push("琴音小助手错误:" + title, message, images=images)
|
||||
_show_toast("琴音小助手错误:" + title, message)
|
|
@ -41,6 +41,7 @@ dependencies = [
|
|||
# TODO: move these dependencies to optional-dependencies
|
||||
"pywin32==310",
|
||||
"ahk==1.8.3",
|
||||
"win11toast==0.35", # For Windows Toast Notification
|
||||
]
|
||||
|
||||
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
-r requirements.txt
|
||||
|
||||
pywin32==310
|
||||
ahk==1.8.3
|
||||
ahk==1.8.3
|
||||
win11toast==0.35
|
|
@ -1 +1 @@
|
|||
Subproject commit bca78ea35b0883c24814c523cec1856615205199
|
||||
Subproject commit 4328e2b53dc6e3efa4fef691064f40ceb998c97a
|
|
@ -0,0 +1,570 @@
|
|||
import os
|
||||
import json
|
||||
import tempfile
|
||||
import shutil
|
||||
import uuid
|
||||
from unittest import TestCase
|
||||
|
||||
|
||||
from kotonebot.kaa.config.produce import (
|
||||
ProduceData,
|
||||
ProduceSolution,
|
||||
ProduceSolutionManager
|
||||
)
|
||||
from kotonebot.kaa.config.const import ProduceAction, RecommendCardDetectionMode
|
||||
from kotonebot.kaa.errors import ProduceSolutionNotFoundError
|
||||
|
||||
|
||||
class TestProduceData(TestCase):
|
||||
|
||||
def test_produce_data_field_validation(self):
|
||||
"""测试字段验证"""
|
||||
# 测试有效的 mode 值
|
||||
for mode in ['regular', 'pro', 'master']:
|
||||
data = ProduceData(mode=mode) # type: ignore[arg-type]
|
||||
self.assertEqual(data.mode, mode)
|
||||
|
||||
# 测试有效的 self_study_lesson 值
|
||||
for lesson in ['dance', 'visual', 'vocal']:
|
||||
data = ProduceData(self_study_lesson=lesson) # type: ignore[arg-type]
|
||||
self.assertEqual(data.self_study_lesson, lesson)
|
||||
|
||||
def test_produce_data_serialization(self):
|
||||
"""测试序列化和反序列化"""
|
||||
# 创建测试数据
|
||||
data = ProduceData(
|
||||
mode='pro',
|
||||
idol='test_idol_123',
|
||||
memory_set=2,
|
||||
support_card_set=3,
|
||||
auto_set_memory=True,
|
||||
auto_set_support_card=True,
|
||||
use_pt_boost=True,
|
||||
use_note_boost=True,
|
||||
follow_producer=True,
|
||||
self_study_lesson='vocal',
|
||||
prefer_lesson_ap=True,
|
||||
actions_order=[ProduceAction.DANCE, ProduceAction.VOCAL],
|
||||
recommend_card_detection_mode=RecommendCardDetectionMode.STRICT,
|
||||
use_ap_drink=True,
|
||||
skip_commu=False
|
||||
)
|
||||
|
||||
# 序列化
|
||||
json_data = data.model_dump(mode='json')
|
||||
|
||||
# 反序列化
|
||||
restored_data = ProduceData.model_validate(json_data)
|
||||
|
||||
# 验证数据一致性
|
||||
self.assertEqual(restored_data.mode, 'pro')
|
||||
self.assertEqual(restored_data.idol, 'test_idol_123')
|
||||
self.assertEqual(restored_data.memory_set, 2)
|
||||
self.assertEqual(restored_data.support_card_set, 3)
|
||||
self.assertTrue(restored_data.auto_set_memory)
|
||||
self.assertTrue(restored_data.auto_set_support_card)
|
||||
self.assertTrue(restored_data.use_pt_boost)
|
||||
self.assertTrue(restored_data.use_note_boost)
|
||||
self.assertTrue(restored_data.follow_producer)
|
||||
self.assertEqual(restored_data.self_study_lesson, 'vocal')
|
||||
self.assertTrue(restored_data.prefer_lesson_ap)
|
||||
self.assertEqual(restored_data.actions_order, [ProduceAction.DANCE, ProduceAction.VOCAL])
|
||||
self.assertEqual(restored_data.recommend_card_detection_mode, RecommendCardDetectionMode.STRICT)
|
||||
self.assertTrue(restored_data.use_ap_drink)
|
||||
self.assertFalse(restored_data.skip_commu)
|
||||
|
||||
|
||||
class TestProduceSolution(TestCase):
|
||||
"""测试 ProduceSolution 类"""
|
||||
|
||||
def test_produce_solution_creation(self):
|
||||
"""测试创建培育方案"""
|
||||
data = ProduceData(mode='pro', idol='test_idol')
|
||||
solution = ProduceSolution(
|
||||
id='test_id_123',
|
||||
name='测试方案',
|
||||
description='这是一个测试方案',
|
||||
data=data
|
||||
)
|
||||
|
||||
self.assertEqual(solution.type, 'produce_solution')
|
||||
self.assertEqual(solution.id, 'test_id_123')
|
||||
self.assertEqual(solution.name, '测试方案')
|
||||
self.assertEqual(solution.description, '这是一个测试方案')
|
||||
self.assertEqual(solution.data.mode, 'pro')
|
||||
self.assertEqual(solution.data.idol, 'test_idol')
|
||||
|
||||
def test_produce_solution_validation(self):
|
||||
"""测试字段验证"""
|
||||
data = ProduceData()
|
||||
|
||||
# 测试必需字段
|
||||
solution = ProduceSolution(
|
||||
id='test_id',
|
||||
name='测试方案',
|
||||
data=data
|
||||
)
|
||||
self.assertEqual(solution.id, 'test_id')
|
||||
self.assertEqual(solution.name, '测试方案')
|
||||
self.assertIsNone(solution.description)
|
||||
|
||||
def test_produce_solution_serialization(self):
|
||||
"""测试序列化和反序列化"""
|
||||
data = ProduceData(mode='master', idol='test_idol_456')
|
||||
solution = ProduceSolution(
|
||||
id='test_id_456',
|
||||
name='高级测试方案',
|
||||
description='这是一个高级测试方案',
|
||||
data=data
|
||||
)
|
||||
|
||||
# 序列化
|
||||
json_data = solution.model_dump(mode='json')
|
||||
|
||||
# 反序列化
|
||||
restored_solution = ProduceSolution.model_validate(json_data)
|
||||
|
||||
# 验证数据一致性
|
||||
self.assertEqual(restored_solution.type, 'produce_solution')
|
||||
self.assertEqual(restored_solution.id, 'test_id_456')
|
||||
self.assertEqual(restored_solution.name, '高级测试方案')
|
||||
self.assertEqual(restored_solution.description, '这是一个高级测试方案')
|
||||
self.assertEqual(restored_solution.data.mode, 'master')
|
||||
self.assertEqual(restored_solution.data.idol, 'test_idol_456')
|
||||
|
||||
|
||||
class TestProduceSolutionManager(TestCase):
|
||||
"""测试 ProduceSolutionManager 类"""
|
||||
|
||||
def setUp(self):
|
||||
"""设置测试环境"""
|
||||
# 创建临时目录
|
||||
self.temp_dir = tempfile.mkdtemp()
|
||||
self.original_solutions_dir = ProduceSolutionManager.SOLUTIONS_DIR
|
||||
ProduceSolutionManager.SOLUTIONS_DIR = os.path.join(self.temp_dir, "test_solutions") # type: ignore[assignment]
|
||||
self.manager = ProduceSolutionManager()
|
||||
|
||||
def tearDown(self):
|
||||
"""清理测试环境"""
|
||||
# 恢复原始目录设置
|
||||
ProduceSolutionManager.SOLUTIONS_DIR = self.original_solutions_dir # type: ignore[assignment]
|
||||
# 删除临时目录
|
||||
shutil.rmtree(self.temp_dir)
|
||||
|
||||
def test_manager_init(self):
|
||||
"""测试管理器初始化和目录创建"""
|
||||
# 验证目录已创建
|
||||
self.assertTrue(os.path.exists(self.manager.SOLUTIONS_DIR))
|
||||
self.assertTrue(os.path.isdir(self.manager.SOLUTIONS_DIR))
|
||||
|
||||
def test_sanitize_filename(self):
|
||||
"""测试文件名清理功能"""
|
||||
test_cases = [
|
||||
('正常文件名', '正常文件名'),
|
||||
('包含\\斜杠/的:文件*名?', '包含_斜杠_的_文件_名_'),
|
||||
('包含"引号"和<尖括号>', '包含_引号_和_尖括号_'),
|
||||
('包含|管道符', '包含_管道符'),
|
||||
('', ''),
|
||||
]
|
||||
|
||||
for input_name, expected_output in test_cases:
|
||||
with self.subTest(input_name=input_name):
|
||||
result = self.manager._sanitize_filename(input_name)
|
||||
self.assertEqual(result, expected_output)
|
||||
|
||||
def test_get_file_path(self):
|
||||
"""测试根据名称获取文件路径"""
|
||||
name = '测试方案'
|
||||
expected_path = os.path.join(self.manager.SOLUTIONS_DIR, '测试方案.json')
|
||||
result = self.manager._get_file_path(name)
|
||||
self.assertEqual(result, expected_path)
|
||||
|
||||
# 测试特殊字符处理
|
||||
name_with_special = '测试/方案:名称'
|
||||
expected_path_special = os.path.join(self.manager.SOLUTIONS_DIR, '测试_方案_名称.json')
|
||||
result_special = self.manager._get_file_path(name_with_special)
|
||||
self.assertEqual(result_special, expected_path_special)
|
||||
|
||||
def test_find_file_path_by_id(self):
|
||||
"""测试根据ID查找文件路径"""
|
||||
# 创建测试文件
|
||||
test_id = 'test_id_123'
|
||||
solution = ProduceSolution(
|
||||
id=test_id,
|
||||
name='测试方案',
|
||||
data=ProduceData()
|
||||
)
|
||||
|
||||
# 保存文件
|
||||
file_path = self.manager._get_file_path(solution.name)
|
||||
with open(file_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(solution.model_dump(mode='json'), f, ensure_ascii=False, indent=4)
|
||||
|
||||
# 测试查找
|
||||
found_path = self.manager._find_file_path_by_id(test_id)
|
||||
self.assertEqual(found_path, file_path)
|
||||
|
||||
# 测试查找不存在的ID
|
||||
not_found_path = self.manager._find_file_path_by_id('nonexistent_id')
|
||||
self.assertIsNone(not_found_path)
|
||||
|
||||
def test_new_solution(self):
|
||||
"""测试创建新方案"""
|
||||
name = '新测试方案'
|
||||
solution = self.manager.new(name)
|
||||
|
||||
self.assertEqual(solution.name, name)
|
||||
self.assertEqual(solution.type, 'produce_solution')
|
||||
self.assertIsNotNone(solution.id)
|
||||
self.assertIsNone(solution.description)
|
||||
self.assertIsInstance(solution.data, ProduceData)
|
||||
|
||||
# 验证ID是有效的UUID
|
||||
try:
|
||||
uuid.UUID(solution.id)
|
||||
except ValueError:
|
||||
self.fail("Generated ID is not a valid UUID")
|
||||
|
||||
def test_list_solutions_empty(self):
|
||||
"""测试空目录时列出方案"""
|
||||
solutions = self.manager.list()
|
||||
self.assertEqual(solutions, [])
|
||||
|
||||
def test_list_solutions_with_files(self):
|
||||
"""测试有文件时列出方案"""
|
||||
# 创建测试方案
|
||||
solution1 = ProduceSolution(
|
||||
id='id1',
|
||||
name='方案1',
|
||||
data=ProduceData(mode='regular')
|
||||
)
|
||||
solution2 = ProduceSolution(
|
||||
id='id2',
|
||||
name='方案2',
|
||||
data=ProduceData(mode='pro')
|
||||
)
|
||||
|
||||
# 保存文件
|
||||
for solution in [solution1, solution2]:
|
||||
file_path = self.manager._get_file_path(solution.name)
|
||||
with open(file_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(solution.model_dump(mode='json'), f, ensure_ascii=False, indent=4)
|
||||
|
||||
# 列出方案
|
||||
solutions = self.manager.list()
|
||||
self.assertEqual(len(solutions), 2)
|
||||
|
||||
# 验证方案内容(顺序可能不同)
|
||||
solution_ids = {s.id for s in solutions}
|
||||
self.assertEqual(solution_ids, {'id1', 'id2'})
|
||||
|
||||
def test_list_solutions_with_invalid_files(self):
|
||||
"""测试包含无效文件时列出方案"""
|
||||
# 创建有效方案文件
|
||||
valid_solution = ProduceSolution(
|
||||
id='valid_id',
|
||||
name='有效方案',
|
||||
data=ProduceData()
|
||||
)
|
||||
valid_file_path = self.manager._get_file_path(valid_solution.name)
|
||||
with open(valid_file_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(valid_solution.model_dump(mode='json'), f, ensure_ascii=False, indent=4)
|
||||
|
||||
# 创建无效JSON文件
|
||||
invalid_file_path = os.path.join(self.manager.SOLUTIONS_DIR, '无效文件.json')
|
||||
with open(invalid_file_path, 'w', encoding='utf-8') as f:
|
||||
f.write('invalid json content')
|
||||
|
||||
# 创建非JSON文件
|
||||
non_json_file_path = os.path.join(self.manager.SOLUTIONS_DIR, '非JSON文件.txt')
|
||||
with open(non_json_file_path, 'w', encoding='utf-8') as f:
|
||||
f.write('not a json file')
|
||||
|
||||
# 列出方案,应该只返回有效的方案
|
||||
solutions = self.manager.list()
|
||||
self.assertEqual(len(solutions), 1)
|
||||
self.assertEqual(solutions[0].id, 'valid_id')
|
||||
|
||||
def test_save_solution(self):
|
||||
"""测试保存方案"""
|
||||
solution = ProduceSolution(
|
||||
id='save_test_id',
|
||||
name='保存测试方案',
|
||||
description='测试保存功能',
|
||||
data=ProduceData(mode='master', idol='test_idol')
|
||||
)
|
||||
|
||||
# 保存方案
|
||||
self.manager.save(solution.id, solution)
|
||||
|
||||
# 验证文件已创建
|
||||
expected_file_path = self.manager._get_file_path(solution.name)
|
||||
self.assertTrue(os.path.exists(expected_file_path))
|
||||
|
||||
# 验证文件内容
|
||||
with open(expected_file_path, 'r', encoding='utf-8') as f:
|
||||
saved_data = json.load(f)
|
||||
|
||||
self.assertEqual(saved_data['id'], 'save_test_id')
|
||||
self.assertEqual(saved_data['name'], '保存测试方案')
|
||||
self.assertEqual(saved_data['description'], '测试保存功能')
|
||||
self.assertEqual(saved_data['data']['mode'], 'master')
|
||||
self.assertEqual(saved_data['data']['idol'], 'test_idol')
|
||||
|
||||
def test_save_solution_with_name_change(self):
|
||||
"""测试保存时名称变更的处理"""
|
||||
solution_id = 'name_change_test_id'
|
||||
|
||||
# 创建初始方案
|
||||
original_solution = ProduceSolution(
|
||||
id=solution_id,
|
||||
name='原始名称',
|
||||
data=ProduceData()
|
||||
)
|
||||
self.manager.save(solution_id, original_solution)
|
||||
original_file_path = self.manager._get_file_path('原始名称')
|
||||
self.assertTrue(os.path.exists(original_file_path))
|
||||
|
||||
# 修改名称并保存
|
||||
updated_solution = ProduceSolution(
|
||||
id=solution_id,
|
||||
name='新名称',
|
||||
data=ProduceData()
|
||||
)
|
||||
self.manager.save(solution_id, updated_solution)
|
||||
|
||||
# 验证旧文件已删除,新文件已创建
|
||||
self.assertFalse(os.path.exists(original_file_path))
|
||||
new_file_path = self.manager._get_file_path('新名称')
|
||||
self.assertTrue(os.path.exists(new_file_path))
|
||||
|
||||
def test_read_solution(self):
|
||||
"""测试读取方案"""
|
||||
# 创建并保存方案
|
||||
solution = ProduceSolution(
|
||||
id='read_test_id',
|
||||
name='读取测试方案',
|
||||
description='测试读取功能',
|
||||
data=ProduceData(mode='pro', memory_set=5)
|
||||
)
|
||||
self.manager.save(solution.id, solution)
|
||||
|
||||
# 读取方案
|
||||
read_solution = self.manager.read(solution.id)
|
||||
|
||||
# 验证读取的数据
|
||||
self.assertEqual(read_solution.id, 'read_test_id')
|
||||
self.assertEqual(read_solution.name, '读取测试方案')
|
||||
self.assertEqual(read_solution.description, '测试读取功能')
|
||||
self.assertEqual(read_solution.data.mode, 'pro')
|
||||
self.assertEqual(read_solution.data.memory_set, 5)
|
||||
|
||||
def test_read_nonexistent_solution(self):
|
||||
"""测试读取不存在的方案"""
|
||||
with self.assertRaises(ProduceSolutionNotFoundError) as context:
|
||||
self.manager.read('nonexistent_id')
|
||||
|
||||
self.assertIn("Solution with id 'nonexistent_id' not found", str(context.exception))
|
||||
|
||||
def test_delete_solution(self):
|
||||
"""测试删除方案"""
|
||||
# 创建并保存方案
|
||||
solution = ProduceSolution(
|
||||
id='delete_test_id',
|
||||
name='删除测试方案',
|
||||
data=ProduceData()
|
||||
)
|
||||
self.manager.save(solution.id, solution)
|
||||
|
||||
# 验证文件存在
|
||||
file_path = self.manager._get_file_path(solution.name)
|
||||
self.assertTrue(os.path.exists(file_path))
|
||||
|
||||
# 删除方案
|
||||
self.manager.delete(solution.id)
|
||||
|
||||
# 验证文件已删除
|
||||
self.assertFalse(os.path.exists(file_path))
|
||||
|
||||
def test_delete_nonexistent_solution(self):
|
||||
"""测试删除不存在的方案"""
|
||||
# 删除不存在的方案不应该抛出异常
|
||||
try:
|
||||
self.manager.delete('nonexistent_id')
|
||||
except Exception as e:
|
||||
self.fail(f"Deleting nonexistent solution should not raise exception: {e}")
|
||||
|
||||
def test_duplicate_solution(self):
|
||||
"""测试复制方案"""
|
||||
# 创建原始方案
|
||||
original_solution = ProduceSolution(
|
||||
id='original_id',
|
||||
name='原始方案',
|
||||
description='原始描述',
|
||||
data=ProduceData(mode='master', idol='test_idol', memory_set=3)
|
||||
)
|
||||
self.manager.save(original_solution.id, original_solution)
|
||||
|
||||
# 复制方案
|
||||
duplicated_solution = self.manager.duplicate(original_solution.id)
|
||||
|
||||
# 验证复制的方案
|
||||
self.assertNotEqual(duplicated_solution.id, original_solution.id)
|
||||
self.assertEqual(duplicated_solution.name, '原始方案 - 副本')
|
||||
self.assertEqual(duplicated_solution.description, '原始描述')
|
||||
self.assertEqual(duplicated_solution.type, 'produce_solution')
|
||||
|
||||
# 验证数据深拷贝
|
||||
self.assertEqual(duplicated_solution.data.mode, 'master')
|
||||
self.assertEqual(duplicated_solution.data.idol, 'test_idol')
|
||||
self.assertEqual(duplicated_solution.data.memory_set, 3)
|
||||
|
||||
# 验证是深拷贝而不是浅拷贝
|
||||
self.assertIsNot(duplicated_solution.data, original_solution.data)
|
||||
|
||||
# 验证新ID是有效的UUID
|
||||
try:
|
||||
uuid.UUID(duplicated_solution.id)
|
||||
except ValueError:
|
||||
self.fail("Duplicated solution ID is not a valid UUID")
|
||||
|
||||
def test_duplicate_nonexistent_solution(self):
|
||||
"""测试复制不存在的方案"""
|
||||
with self.assertRaises(ProduceSolutionNotFoundError):
|
||||
self.manager.duplicate('nonexistent_id')
|
||||
|
||||
def test_corrupted_json_handling(self):
|
||||
"""测试处理损坏的JSON文件"""
|
||||
# 创建损坏的JSON文件
|
||||
corrupted_file_path = os.path.join(self.manager.SOLUTIONS_DIR, '损坏文件.json')
|
||||
with open(corrupted_file_path, 'w', encoding='utf-8') as f:
|
||||
f.write('{"id": "corrupted_id", "name": "corrupted", invalid json}')
|
||||
|
||||
# list() 方法应该跳过损坏的文件
|
||||
solutions = self.manager.list()
|
||||
self.assertEqual(len(solutions), 0)
|
||||
|
||||
# _find_file_path_by_id 应该跳过损坏的文件
|
||||
found_path = self.manager._find_file_path_by_id('corrupted_id')
|
||||
self.assertIsNone(found_path)
|
||||
|
||||
def test_special_characters_in_names(self):
|
||||
"""测试名称中的特殊字符处理"""
|
||||
special_names = [
|
||||
'包含/斜杠的名称',
|
||||
'包含:冒号的名称',
|
||||
'包含*星号的名称',
|
||||
'包含?问号的名称',
|
||||
'包含"引号的名称',
|
||||
'包含<尖括号>的名称',
|
||||
'包含|管道符的名称',
|
||||
]
|
||||
|
||||
for name in special_names:
|
||||
with self.subTest(name=name):
|
||||
# 创建方案
|
||||
solution = ProduceSolution(
|
||||
id=f'special_id_{hash(name)}',
|
||||
name=name,
|
||||
data=ProduceData()
|
||||
)
|
||||
|
||||
# 保存方案
|
||||
self.manager.save(solution.id, solution)
|
||||
|
||||
# 验证能够读取
|
||||
read_solution = self.manager.read(solution.id)
|
||||
self.assertEqual(read_solution.name, name)
|
||||
|
||||
# 验证能够列出
|
||||
solutions = self.manager.list()
|
||||
names = [s.name for s in solutions]
|
||||
self.assertIn(name, names)
|
||||
|
||||
def test_full_workflow(self):
|
||||
"""测试完整的工作流程(创建→保存→读取→修改→删除)"""
|
||||
# 1. 创建新方案
|
||||
solution = self.manager.new('工作流程测试')
|
||||
original_id = solution.id
|
||||
|
||||
# 2. 修改方案数据
|
||||
solution.description = '完整工作流程测试'
|
||||
solution.data.mode = 'pro'
|
||||
solution.data.idol = 'workflow_test_idol'
|
||||
|
||||
# 3. 保存方案
|
||||
self.manager.save(solution.id, solution)
|
||||
|
||||
# 4. 读取方案
|
||||
read_solution = self.manager.read(solution.id)
|
||||
self.assertEqual(read_solution.id, original_id)
|
||||
self.assertEqual(read_solution.name, '工作流程测试')
|
||||
self.assertEqual(read_solution.description, '完整工作流程测试')
|
||||
self.assertEqual(read_solution.data.mode, 'pro')
|
||||
self.assertEqual(read_solution.data.idol, 'workflow_test_idol')
|
||||
|
||||
# 5. 修改方案名称
|
||||
read_solution.name = '修改后的名称'
|
||||
self.manager.save(read_solution.id, read_solution)
|
||||
|
||||
# 6. 验证修改
|
||||
modified_solution = self.manager.read(read_solution.id)
|
||||
self.assertEqual(modified_solution.name, '修改后的名称')
|
||||
|
||||
# 7. 复制方案
|
||||
duplicated = self.manager.duplicate(modified_solution.id)
|
||||
self.assertNotEqual(duplicated.id, modified_solution.id)
|
||||
self.assertEqual(duplicated.name, '修改后的名称 - 副本')
|
||||
|
||||
# 8. 列出所有方案
|
||||
all_solutions = self.manager.list()
|
||||
self.assertEqual(len(all_solutions), 1) # 只有原始方案,复制的方案还没保存
|
||||
|
||||
# 9. 保存复制的方案
|
||||
self.manager.save(duplicated.id, duplicated)
|
||||
all_solutions = self.manager.list()
|
||||
self.assertEqual(len(all_solutions), 2)
|
||||
|
||||
# 10. 删除原始方案
|
||||
self.manager.delete(modified_solution.id)
|
||||
remaining_solutions = self.manager.list()
|
||||
self.assertEqual(len(remaining_solutions), 1)
|
||||
self.assertEqual(remaining_solutions[0].id, duplicated.id)
|
||||
|
||||
# 11. 删除复制的方案
|
||||
self.manager.delete(duplicated.id)
|
||||
final_solutions = self.manager.list()
|
||||
self.assertEqual(len(final_solutions), 0)
|
||||
|
||||
def test_concurrent_operations(self):
|
||||
"""测试并发操作的安全性"""
|
||||
# 这个测试主要验证基本的文件操作不会相互干扰
|
||||
solutions = []
|
||||
|
||||
# 创建多个方案
|
||||
for i in range(5):
|
||||
solution = self.manager.new(f'并发测试方案{i}')
|
||||
solution.data.mode = 'pro' if i % 2 == 0 else 'regular'
|
||||
solutions.append(solution)
|
||||
|
||||
# 同时保存所有方案
|
||||
for solution in solutions:
|
||||
self.manager.save(solution.id, solution)
|
||||
|
||||
# 验证所有方案都已保存
|
||||
saved_solutions = self.manager.list()
|
||||
self.assertEqual(len(saved_solutions), 5)
|
||||
|
||||
# 验证每个方案的数据完整性
|
||||
for original in solutions:
|
||||
read_solution = self.manager.read(original.id)
|
||||
self.assertEqual(read_solution.name, original.name)
|
||||
self.assertEqual(read_solution.data.mode, original.data.mode)
|
||||
|
||||
# 同时删除所有方案
|
||||
for solution in solutions:
|
||||
self.manager.delete(solution.id)
|
||||
|
||||
# 验证所有方案都已删除
|
||||
remaining_solutions = self.manager.list()
|
||||
self.assertEqual(len(remaining_solutions), 0)
|
|
@ -0,0 +1,200 @@
|
|||
"""测试 v5 到 v6 的配置迁移脚本"""
|
||||
import unittest
|
||||
import tempfile
|
||||
import os
|
||||
import json
|
||||
import shutil
|
||||
from typing import Any
|
||||
|
||||
from kotonebot.kaa.config.migrations._v5_to_v6 import migrate
|
||||
|
||||
|
||||
class TestMigrationV5ToV6(unittest.TestCase):
|
||||
"""测试 v5 到 v6 的配置迁移"""
|
||||
|
||||
def setUp(self):
|
||||
"""设置测试环境"""
|
||||
# 创建临时目录
|
||||
self.temp_dir = tempfile.mkdtemp()
|
||||
self.original_cwd = os.getcwd()
|
||||
os.chdir(self.temp_dir)
|
||||
|
||||
def tearDown(self):
|
||||
"""清理测试环境"""
|
||||
os.chdir(self.original_cwd)
|
||||
shutil.rmtree(self.temp_dir)
|
||||
|
||||
def test_migrate_empty_config(self):
|
||||
"""测试空配置的迁移"""
|
||||
user_config = {}
|
||||
result = migrate(user_config)
|
||||
self.assertIsNone(result)
|
||||
|
||||
def test_migrate_no_options(self):
|
||||
"""测试没有 options 的配置"""
|
||||
user_config = {"backend": {"type": "mumu12"}}
|
||||
result = migrate(user_config)
|
||||
self.assertIsNone(result)
|
||||
|
||||
def test_migrate_no_produce_config(self):
|
||||
"""测试没有 produce 配置的情况"""
|
||||
user_config = {"options": {"purchase": {"enabled": False}}}
|
||||
result = migrate(user_config)
|
||||
self.assertIsNone(result)
|
||||
|
||||
def test_migrate_already_v6_format(self):
|
||||
"""测试已经是 v6 格式的配置"""
|
||||
user_config = {
|
||||
"options": {
|
||||
"produce": {
|
||||
"enabled": True,
|
||||
"selected_solution_id": "test-id",
|
||||
"produce_count": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
result = migrate(user_config)
|
||||
self.assertIsNone(result)
|
||||
|
||||
def test_migrate_v5_to_v6_basic(self):
|
||||
"""测试基本的 v5 到 v6 迁移"""
|
||||
# 创建 v5 格式的配置
|
||||
old_produce_config = {
|
||||
"enabled": True,
|
||||
"mode": "pro",
|
||||
"produce_count": 3,
|
||||
"idols": ["i_card-skin-fktn-3-000"],
|
||||
"memory_sets": [1],
|
||||
"support_card_sets": [2],
|
||||
"auto_set_memory": False,
|
||||
"auto_set_support_card": True,
|
||||
"use_pt_boost": True,
|
||||
"use_note_boost": False,
|
||||
"follow_producer": True,
|
||||
"self_study_lesson": "vocal",
|
||||
"prefer_lesson_ap": True,
|
||||
"actions_order": ["recommended", "visual", "vocal"],
|
||||
"recommend_card_detection_mode": "strict",
|
||||
"use_ap_drink": True,
|
||||
"skip_commu": False
|
||||
}
|
||||
|
||||
user_config = {"options": {"produce": old_produce_config}}
|
||||
|
||||
# 执行迁移
|
||||
result = migrate(user_config)
|
||||
|
||||
# 验证结果
|
||||
self.assertIsNotNone(result)
|
||||
assert result is not None # make pylance happy
|
||||
self.assertIn("已将培育配置迁移到新的方案系统", result)
|
||||
|
||||
# 验证新配置格式
|
||||
new_produce_config = user_config["options"]["produce"]
|
||||
self.assertEqual(new_produce_config["enabled"], True)
|
||||
self.assertEqual(new_produce_config["produce_count"], 3)
|
||||
self.assertIsNotNone(new_produce_config["selected_solution_id"])
|
||||
|
||||
# 验证方案文件是否创建
|
||||
solutions_dir = "conf/produce"
|
||||
self.assertTrue(os.path.exists(solutions_dir))
|
||||
|
||||
# 查找创建的方案文件
|
||||
solution_files = [f for f in os.listdir(solutions_dir) if f.endswith('.json')]
|
||||
self.assertEqual(len(solution_files), 1)
|
||||
|
||||
# 验证方案文件内容
|
||||
solution_file = os.path.join(solutions_dir, solution_files[0])
|
||||
with open(solution_file, 'r', encoding='utf-8') as f:
|
||||
solution_data = json.load(f)
|
||||
|
||||
self.assertEqual(solution_data["type"], "produce_solution")
|
||||
self.assertEqual(solution_data["name"], "默认方案")
|
||||
self.assertEqual(solution_data["description"], "从旧配置迁移的默认培育方案")
|
||||
|
||||
# 验证培育数据
|
||||
produce_data = solution_data["data"]
|
||||
self.assertEqual(produce_data["mode"], "pro")
|
||||
self.assertEqual(produce_data["idol"], "i_card-skin-fktn-3-000")
|
||||
self.assertEqual(produce_data["memory_set"], 1)
|
||||
self.assertEqual(produce_data["support_card_set"], 2)
|
||||
self.assertEqual(produce_data["auto_set_memory"], False)
|
||||
self.assertEqual(produce_data["auto_set_support_card"], True)
|
||||
self.assertEqual(produce_data["use_pt_boost"], True)
|
||||
self.assertEqual(produce_data["use_note_boost"], False)
|
||||
self.assertEqual(produce_data["follow_producer"], True)
|
||||
self.assertEqual(produce_data["self_study_lesson"], "vocal")
|
||||
self.assertEqual(produce_data["prefer_lesson_ap"], True)
|
||||
self.assertEqual(produce_data["actions_order"], ["recommended", "visual", "vocal"])
|
||||
self.assertEqual(produce_data["recommend_card_detection_mode"], "strict")
|
||||
self.assertEqual(produce_data["use_ap_drink"], True)
|
||||
self.assertEqual(produce_data["skip_commu"], False)
|
||||
|
||||
def test_migrate_v5_to_v6_with_defaults(self):
|
||||
"""测试使用默认值的 v5 到 v6 迁移"""
|
||||
# 创建最小的 v5 格式配置
|
||||
old_produce_config = {"enabled": False}
|
||||
user_config = {"options": {"produce": old_produce_config}}
|
||||
|
||||
# 执行迁移
|
||||
result = migrate(user_config)
|
||||
|
||||
# 验证结果
|
||||
self.assertIsNotNone(result)
|
||||
|
||||
# 验证新配置格式
|
||||
new_produce_config = user_config["options"]["produce"]
|
||||
self.assertEqual(new_produce_config["enabled"], False)
|
||||
self.assertEqual(new_produce_config["produce_count"], 1)
|
||||
self.assertIsNotNone(new_produce_config["selected_solution_id"])
|
||||
|
||||
# 验证方案文件内容使用了默认值
|
||||
solutions_dir = "conf/produce"
|
||||
solution_files = [f for f in os.listdir(solutions_dir) if f.endswith('.json')]
|
||||
solution_file = os.path.join(solutions_dir, solution_files[0])
|
||||
|
||||
with open(solution_file, 'r', encoding='utf-8') as f:
|
||||
solution_data = json.load(f)
|
||||
|
||||
produce_data = solution_data["data"]
|
||||
self.assertEqual(produce_data["mode"], "regular")
|
||||
self.assertIsNone(produce_data["idol"])
|
||||
self.assertIsNone(produce_data["memory_set"])
|
||||
self.assertIsNone(produce_data["support_card_set"])
|
||||
self.assertEqual(produce_data["auto_set_memory"], False)
|
||||
self.assertEqual(produce_data["auto_set_support_card"], False)
|
||||
self.assertEqual(produce_data["self_study_lesson"], "dance")
|
||||
self.assertEqual(produce_data["skip_commu"], True)
|
||||
|
||||
def test_migrate_v5_to_v6_multiple_idols_memory_support(self):
|
||||
"""测试多个偶像、回忆、支援卡的迁移(只取第一个)"""
|
||||
old_produce_config = {
|
||||
"enabled": True,
|
||||
"idols": ["idol1", "idol2", "idol3"],
|
||||
"memory_sets": [1, 2, 3],
|
||||
"support_card_sets": [4, 5, 6]
|
||||
}
|
||||
user_config = {"options": {"produce": old_produce_config}}
|
||||
|
||||
# 执行迁移
|
||||
result = migrate(user_config)
|
||||
|
||||
# 验证结果
|
||||
self.assertIsNotNone(result)
|
||||
|
||||
# 验证方案文件内容只使用了第一个值
|
||||
solutions_dir = "conf/produce"
|
||||
solution_files = [f for f in os.listdir(solutions_dir) if f.endswith('.json')]
|
||||
solution_file = os.path.join(solutions_dir, solution_files[0])
|
||||
|
||||
with open(solution_file, 'r', encoding='utf-8') as f:
|
||||
solution_data = json.load(f)
|
||||
|
||||
produce_data = solution_data["data"]
|
||||
self.assertEqual(produce_data["idol"], "idol1")
|
||||
self.assertEqual(produce_data["memory_set"], 1)
|
||||
self.assertEqual(produce_data["support_card_set"], 4)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|