diff --git a/assets/game_data/screen_info/_od_merged.yml b/assets/game_data/screen_info/_od_merged.yml
index 8a58b9daaf..e3d6ed4283 100644
--- a/assets/game_data/screen_info/_od_merged.yml
+++ b/assets/game_data/screen_info/_od_merged.yml
@@ -213,6 +213,228 @@
template_match_threshold: 0.7
color_range: null
goto_list: []
+- screen_id: agent_info
+ screen_name: 代理人-信息
+ pc_alt: false
+ area_list:
+ - area_name: 按钮-街区
+ id_mark: true
+ pc_rect:
+ - 238
+ - 26
+ - 394
+ - 78
+ text: 街区
+ lcs_percent: 0.5
+ template_sub_dir: ''
+ template_id: ''
+ template_match_threshold: 0.7
+ color_range: null
+ goto_list:
+ - 大世界
+ - area_name: 按钮-返回
+ id_mark: true
+ pc_rect:
+ - 82
+ - 13
+ - 150
+ - 90
+ text: ''
+ lcs_percent: 0.5
+ template_sub_dir: menu
+ template_id: back
+ template_match_threshold: 0.9
+ color_range: null
+ goto_list:
+ - 代理人-列表
+ - area_name: 按钮-代理人基础
+ id_mark: false
+ pc_rect:
+ - 1042
+ - 974
+ - 1251
+ - 1017
+ text: 基础
+ lcs_percent: 0.5
+ template_sub_dir: ''
+ template_id: ''
+ template_match_threshold: 0.7
+ color_range: null
+ goto_list: []
+ - area_name: 按钮-代理人技能
+ id_mark: false
+ pc_rect:
+ - 1290
+ - 974
+ - 1507
+ - 1013
+ text: 技能
+ lcs_percent: 0.5
+ template_sub_dir: ''
+ template_id: ''
+ template_match_threshold: 0.7
+ color_range: null
+ goto_list: []
+ - area_name: 按钮-代理人装备
+ id_mark: false
+ pc_rect:
+ - 1555
+ - 976
+ - 1760
+ - 1016
+ text: 装备
+ lcs_percent: 0.5
+ template_sub_dir: ''
+ template_id: ''
+ template_match_threshold: 0.7
+ color_range: null
+ goto_list: []
+ - area_name: 代理人-影画
+ id_mark: false
+ pc_rect:
+ - 72
+ - 986
+ - 226
+ - 1031
+ text: ''
+ lcs_percent: 0.5
+ template_sub_dir: ''
+ template_id: ''
+ template_match_threshold: 0.7
+ color_range: null
+ goto_list: []
+ - area_name: 代理人-名称
+ id_mark: false
+ pc_rect:
+ - 1032
+ - 272
+ - 1524
+ - 378
+ text: ''
+ lcs_percent: 0.5
+ template_sub_dir: ''
+ template_id: ''
+ template_match_threshold: 0.7
+ color_range: null
+ goto_list: []
+ - area_name: 代理人-核心等级
+ id_mark: false
+ pc_rect:
+ - 742
+ - 160
+ - 848
+ - 224
+ text: ''
+ lcs_percent: 0.5
+ template_sub_dir: ''
+ template_id: ''
+ template_match_threshold: 0.7
+ color_range: null
+ goto_list: []
+ - area_name: 代理人-技能等级
+ id_mark: false
+ pc_rect:
+ - 945
+ - 729
+ - 1769
+ - 790
+ text: ''
+ lcs_percent: 0.5
+ template_sub_dir: ''
+ template_id: ''
+ template_match_threshold: 0.7
+ color_range: null
+ goto_list: []
+ - area_name: 代理人-有无标志
+ id_mark: false
+ pc_rect:
+ - 1542
+ - 175
+ - 1543
+ - 176
+ text: ''
+ lcs_percent: 0.5
+ template_sub_dir: ''
+ template_id: ''
+ template_match_threshold: 0.7
+ color_range: null
+ goto_list: []
+ - area_name: 代理人-等级
+ id_mark: false
+ pc_rect:
+ - 1055
+ - 447
+ - 1180
+ - 499
+ text: ''
+ lcs_percent: 0.5
+ template_sub_dir: ''
+ template_id: ''
+ template_match_threshold: 0.7
+ color_range: null
+ goto_list: []
+ - area_name: 按钮-下一位代理人
+ id_mark: false
+ pc_rect:
+ - 1766
+ - 33
+ - 1793
+ - 73
+ text: ''
+ lcs_percent: 0.5
+ template_sub_dir: ''
+ template_id: ''
+ template_match_threshold: 0.7
+ color_range: null
+ goto_list: []
+ - area_name: 按钮-核心技等级
+ id_mark: false
+ pc_rect:
+ - 1648
+ - 288
+ - 1737
+ - 492
+ text: ''
+ lcs_percent: 0.5
+ template_sub_dir: ''
+ template_id: ''
+ template_match_threshold: 0.7
+ color_range: null
+ goto_list: []
+- screen_id: agent_list
+ screen_name: 代理人-列表
+ pc_alt: false
+ area_list:
+ - area_name: 按钮-返回
+ id_mark: true
+ pc_rect:
+ - 82
+ - 13
+ - 150
+ - 90
+ text: ''
+ lcs_percent: 0.5
+ template_sub_dir: menu
+ template_id: back
+ template_match_threshold: 0.9
+ color_range: null
+ goto_list:
+ - 菜单
+ - area_name: 代理人-信息
+ id_mark: false
+ pc_rect:
+ - 1015
+ - 739
+ - 1350
+ - 1047
+ text: 基础
+ lcs_percent: 0.5
+ template_sub_dir: ''
+ template_id: ''
+ template_match_threshold: 0.7
+ color_range: null
+ goto_list:
+ - 代理人-信息
- screen_id: arcade
screen_name: 电玩店
pc_alt: false
@@ -5995,6 +6217,21 @@
color_range: null
goto_list:
- 绳网
+ - area_name: 底部-代理人
+ id_mark: false
+ pc_rect:
+ - 200
+ - 924
+ - 1728
+ - 1058
+ text: 代理人
+ lcs_percent: 0.5
+ template_sub_dir: ''
+ template_id: ''
+ template_match_threshold: 0.7
+ color_range: null
+ goto_list:
+ - 代理人-列表
- area_name: 菜单-动态壁纸
id_mark: true
pc_rect:
@@ -8295,6 +8532,51 @@
color_range: null
goto_list:
- 大世界-普通
+ - area_name: 驱动盘仓库
+ id_mark: false
+ pc_rect:
+ - 93
+ - 202
+ - 1320
+ - 897
+ text: 街区
+ lcs_percent: 0.5
+ template_sub_dir: ''
+ template_id: ''
+ template_match_threshold: 0.7
+ color_range: null
+ goto_list:
+ - 仓库-驱动仓库-驱动盘拆解
+ - area_name: 驱动盘属性
+ id_mark: false
+ pc_rect:
+ - 1408
+ - 267
+ - 1838
+ - 768
+ text: 街区
+ lcs_percent: 0.5
+ template_sub_dir: ''
+ template_id: ''
+ template_match_threshold: 0.7
+ color_range: null
+ goto_list:
+ - 仓库-驱动仓库-驱动盘拆解
+ - area_name: 驱动盘进度条
+ id_mark: false
+ pc_rect:
+ - 1363
+ - 862
+ - 1367
+ - 863
+ text: 街区
+ lcs_percent: 0.5
+ template_sub_dir: ''
+ template_id: ''
+ template_match_threshold: 0.7
+ color_range: null
+ goto_list:
+ - 仓库-驱动仓库-驱动盘拆解
- screen_id: storage_wengine
screen_name: 仓库-音擎仓库
pc_alt: false
diff --git a/assets/game_data/screen_info/agent_info.yml b/assets/game_data/screen_info/agent_info.yml
new file mode 100644
index 0000000000..c60510d034
--- /dev/null
+++ b/assets/game_data/screen_info/agent_info.yml
@@ -0,0 +1,188 @@
+screen_id: agent_info
+screen_name: 代理人-信息
+pc_alt: false
+area_list:
+- area_name: 按钮-街区
+ id_mark: true
+ pc_rect:
+ - 238
+ - 26
+ - 394
+ - 78
+ text: 街区
+ lcs_percent: 0.5
+ template_sub_dir: ''
+ template_id: ''
+ template_match_threshold: 0.7
+ color_range: null
+ goto_list:
+ - 大世界
+- area_name: 按钮-返回
+ id_mark: true
+ pc_rect:
+ - 82
+ - 13
+ - 150
+ - 90
+ text: ''
+ lcs_percent: 0.5
+ template_sub_dir: menu
+ template_id: back
+ template_match_threshold: 0.9
+ color_range: null
+ goto_list:
+ - 代理人-列表
+- area_name: 按钮-代理人基础
+ id_mark: false
+ pc_rect:
+ - 1042
+ - 974
+ - 1251
+ - 1017
+ text: 基础
+ lcs_percent: 0.5
+ template_sub_dir: ''
+ template_id: ''
+ template_match_threshold: 0.7
+ color_range: null
+ goto_list: []
+- area_name: 按钮-代理人技能
+ id_mark: false
+ pc_rect:
+ - 1290
+ - 974
+ - 1507
+ - 1013
+ text: 技能
+ lcs_percent: 0.5
+ template_sub_dir: ''
+ template_id: ''
+ template_match_threshold: 0.7
+ color_range: null
+ goto_list: []
+- area_name: 按钮-代理人装备
+ id_mark: false
+ pc_rect:
+ - 1555
+ - 976
+ - 1760
+ - 1016
+ text: 装备
+ lcs_percent: 0.5
+ template_sub_dir: ''
+ template_id: ''
+ template_match_threshold: 0.7
+ color_range: null
+ goto_list: []
+- area_name: 代理人-影画
+ id_mark: false
+ pc_rect:
+ - 72
+ - 986
+ - 226
+ - 1031
+ text: ''
+ lcs_percent: 0.5
+ template_sub_dir: ''
+ template_id: ''
+ template_match_threshold: 0.7
+ color_range: null
+ goto_list: []
+- area_name: 代理人-名称
+ id_mark: false
+ pc_rect:
+ - 1032
+ - 272
+ - 1524
+ - 378
+ text: ''
+ lcs_percent: 0.5
+ template_sub_dir: ''
+ template_id: ''
+ template_match_threshold: 0.7
+ color_range: null
+ goto_list: []
+- area_name: 代理人-核心等级
+ id_mark: false
+ pc_rect:
+ - 742
+ - 160
+ - 848
+ - 224
+ text: ''
+ lcs_percent: 0.5
+ template_sub_dir: ''
+ template_id: ''
+ template_match_threshold: 0.7
+ color_range: null
+ goto_list: []
+- area_name: 代理人-技能等级
+ id_mark: false
+ pc_rect:
+ - 945
+ - 729
+ - 1769
+ - 790
+ text: ''
+ lcs_percent: 0.5
+ template_sub_dir: ''
+ template_id: ''
+ template_match_threshold: 0.7
+ color_range: null
+ goto_list: []
+- area_name: 代理人-有无标志
+ id_mark: false
+ pc_rect:
+ - 1542
+ - 175
+ - 1543
+ - 176
+ text: ''
+ lcs_percent: 0.5
+ template_sub_dir: ''
+ template_id: ''
+ template_match_threshold: 0.7
+ color_range: null
+ goto_list: []
+- area_name: 代理人-等级
+ id_mark: false
+ pc_rect:
+ - 1055
+ - 447
+ - 1180
+ - 499
+ text: ''
+ lcs_percent: 0.5
+ template_sub_dir: ''
+ template_id: ''
+ template_match_threshold: 0.7
+ color_range: null
+ goto_list: []
+- area_name: 按钮-下一位代理人
+ id_mark: false
+ pc_rect:
+ - 1766
+ - 33
+ - 1793
+ - 73
+ text: ''
+ lcs_percent: 0.5
+ template_sub_dir: ''
+ template_id: ''
+ template_match_threshold: 0.7
+ color_range: null
+ goto_list: []
+- area_name: 按钮-核心技等级
+ id_mark: false
+ pc_rect:
+ - 1648
+ - 288
+ - 1737
+ - 492
+ text: ''
+ lcs_percent: 0.5
+ template_sub_dir: ''
+ template_id: ''
+ template_match_threshold: 0.7
+ color_range: null
+ goto_list: []
diff --git a/assets/game_data/screen_info/agent_list.yml b/assets/game_data/screen_info/agent_list.yml
new file mode 100644
index 0000000000..3535dd0235
--- /dev/null
+++ b/assets/game_data/screen_info/agent_list.yml
@@ -0,0 +1,34 @@
+screen_id: agent_list
+screen_name: 代理人-列表
+pc_alt: false
+area_list:
+- area_name: 按钮-返回
+ id_mark: true
+ pc_rect:
+ - 82
+ - 13
+ - 150
+ - 90
+ text: ''
+ lcs_percent: 0.5
+ template_sub_dir: menu
+ template_id: back
+ template_match_threshold: 0.9
+ color_range: null
+ goto_list:
+ - 菜单
+- area_name: 代理人-信息
+ id_mark: false
+ pc_rect:
+ - 1015
+ - 739
+ - 1350
+ - 1047
+ text: 基础
+ lcs_percent: 0.5
+ template_sub_dir: ''
+ template_id: ''
+ template_match_threshold: 0.7
+ color_range: null
+ goto_list:
+ - 代理人-信息
diff --git a/assets/game_data/screen_info/menu.yml b/assets/game_data/screen_info/menu.yml
index 4a70057dba..0b043e84bc 100644
--- a/assets/game_data/screen_info/menu.yml
+++ b/assets/game_data/screen_info/menu.yml
@@ -204,6 +204,21 @@ area_list:
color_range: null
goto_list:
- 绳网
+- area_name: 底部-代理人
+ id_mark: false
+ pc_rect:
+ - 200
+ - 924
+ - 1728
+ - 1058
+ text: 代理人
+ lcs_percent: 0.5
+ template_sub_dir: ''
+ template_id: ''
+ template_match_threshold: 0.7
+ color_range: null
+ goto_list:
+ - 代理人-列表
- area_name: 菜单-动态壁纸
id_mark: true
pc_rect:
diff --git a/assets/game_data/screen_info/storage_drive_disc.yml b/assets/game_data/screen_info/storage_drive_disc.yml
index 230bd6268a..edbf73ab91 100644
--- a/assets/game_data/screen_info/storage_drive_disc.yml
+++ b/assets/game_data/screen_info/storage_drive_disc.yml
@@ -46,3 +46,48 @@ area_list:
color_range: null
goto_list:
- 大世界-普通
+- area_name: 驱动盘仓库
+ id_mark: false
+ pc_rect:
+ - 93
+ - 202
+ - 1320
+ - 897
+ text: 街区
+ lcs_percent: 0.5
+ template_sub_dir: ''
+ template_id: ''
+ template_match_threshold: 0.7
+ color_range: null
+ goto_list:
+ - 仓库-驱动仓库-驱动盘拆解
+- area_name: 驱动盘属性
+ id_mark: false
+ pc_rect:
+ - 1408
+ - 267
+ - 1838
+ - 768
+ text: 街区
+ lcs_percent: 0.5
+ template_sub_dir: ''
+ template_id: ''
+ template_match_threshold: 0.7
+ color_range: null
+ goto_list:
+ - 仓库-驱动仓库-驱动盘拆解
+- area_name: 驱动盘进度条
+ id_mark: false
+ pc_rect:
+ - 1363
+ - 862
+ - 1367
+ - 863
+ text: 街区
+ lcs_percent: 0.5
+ template_sub_dir: ''
+ template_id: ''
+ template_match_threshold: 0.7
+ color_range: null
+ goto_list:
+ - 仓库-驱动仓库-驱动盘拆解
diff --git "a/assets/image_analysis_pipelines/\345\267\262\351\200\211\344\270\255\347\232\204\351\251\261\345\212\250\347\233\230.yml" "b/assets/image_analysis_pipelines/\345\267\262\351\200\211\344\270\255\347\232\204\351\251\261\345\212\250\347\233\230.yml"
new file mode 100644
index 0000000000..c203dceb2d
--- /dev/null
+++ "b/assets/image_analysis_pipelines/\345\267\262\351\200\211\344\270\255\347\232\204\351\251\261\345\212\250\347\233\230.yml"
@@ -0,0 +1,24 @@
+- step: 按区域裁剪
+ params:
+ screen_name: 仓库-驱动仓库
+ area_name: 驱动盘仓库
+- step: HSV 范围过滤
+ params:
+ hsv_color:
+ - 50
+ - 255
+ - 200
+ hsv_diff:
+ - 40
+ - 10
+ - 40
+- step: 查找轮廓
+ params:
+ mode: EXTERNAL
+ method: SIMPLE
+ draw_contours: 0
+- step: 按面积过滤
+ params:
+ min_area: 100
+ max_area: 18000
+ draw_contours: 2
diff --git "a/assets/image_analysis_pipelines/\351\251\261\345\212\250\347\233\230\345\261\236\346\200\247\350\257\206\345\210\253.yml" "b/assets/image_analysis_pipelines/\351\251\261\345\212\250\347\233\230\345\261\236\346\200\247\350\257\206\345\210\253.yml"
new file mode 100644
index 0000000000..f5294ebbed
--- /dev/null
+++ "b/assets/image_analysis_pipelines/\351\251\261\345\212\250\347\233\230\345\261\236\346\200\247\350\257\206\345\210\253.yml"
@@ -0,0 +1,7 @@
+- step: 按区域裁剪
+ params:
+ screen_name: 仓库-驱动仓库
+ area_name: 驱动盘属性
+- step: OCR识别
+ params:
+ draw_text_box: true
diff --git "a/assets/image_analysis_pipelines/\351\251\261\345\212\250\347\233\230\346\226\271\346\240\274.yml" "b/assets/image_analysis_pipelines/\351\251\261\345\212\250\347\233\230\346\226\271\346\240\274.yml"
new file mode 100644
index 0000000000..b18f0676b4
--- /dev/null
+++ "b/assets/image_analysis_pipelines/\351\251\261\345\212\250\347\233\230\346\226\271\346\240\274.yml"
@@ -0,0 +1,24 @@
+- step: 按区域裁剪
+ params:
+ screen_name: 仓库-驱动仓库
+ area_name: 驱动盘仓库
+- step: HSV 范围过滤
+ params:
+ hsv_color:
+ - 84
+ - 255
+ - 255
+ hsv_diff:
+ - 70
+ - 10
+ - 10
+- step: 查找轮廓
+ params:
+ mode: LIST
+ method: SIMPLE
+ draw_contours: 0
+- step: 按面积过滤
+ params:
+ min_area: 300
+ max_area: 9999
+ draw_contours: true
diff --git "a/assets/image_analysis_pipelines/\351\251\261\345\212\250\347\233\230\350\277\233\345\272\246\346\235\241\346\243\200\346\265\213.yml" "b/assets/image_analysis_pipelines/\351\251\261\345\212\250\347\233\230\350\277\233\345\272\246\346\235\241\346\243\200\346\265\213.yml"
new file mode 100644
index 0000000000..989c72277e
--- /dev/null
+++ "b/assets/image_analysis_pipelines/\351\251\261\345\212\250\347\233\230\350\277\233\345\272\246\346\235\241\346\243\200\346\265\213.yml"
@@ -0,0 +1,19 @@
+- step: 按区域裁剪
+ params:
+ screen_name: 仓库-驱动仓库
+ area_name: 驱动盘进度条
+- step: HSV 范围过滤
+ params:
+ hsv_color:
+ - 0
+ - 0
+ - 128
+ hsv_diff:
+ - 10
+ - 10
+ - 10
+- step: 查找轮廓
+ params:
+ mode: EXTERNAL
+ method: SIMPLE
+ draw_contours: true
diff --git a/assets/wiki_data/icons/IconRoleCircle01.webp b/assets/wiki_data/icons/IconRoleCircle01.webp
new file mode 100644
index 0000000000..03277327cc
Binary files /dev/null and b/assets/wiki_data/icons/IconRoleCircle01.webp differ
diff --git a/assets/wiki_data/icons/IconRoleCircle05.webp b/assets/wiki_data/icons/IconRoleCircle05.webp
new file mode 100644
index 0000000000..279d7d3372
Binary files /dev/null and b/assets/wiki_data/icons/IconRoleCircle05.webp differ
diff --git a/assets/wiki_data/icons/IconRoleCircle09.webp b/assets/wiki_data/icons/IconRoleCircle09.webp
new file mode 100644
index 0000000000..8af1fdff37
Binary files /dev/null and b/assets/wiki_data/icons/IconRoleCircle09.webp differ
diff --git a/assets/wiki_data/icons/IconRoleCircle10.webp b/assets/wiki_data/icons/IconRoleCircle10.webp
new file mode 100644
index 0000000000..01cabc1dcd
Binary files /dev/null and b/assets/wiki_data/icons/IconRoleCircle10.webp differ
diff --git a/assets/wiki_data/icons/IconRoleCircle11.webp b/assets/wiki_data/icons/IconRoleCircle11.webp
new file mode 100644
index 0000000000..e7d80d0ddf
Binary files /dev/null and b/assets/wiki_data/icons/IconRoleCircle11.webp differ
diff --git a/assets/wiki_data/icons/IconRoleCircle12.webp b/assets/wiki_data/icons/IconRoleCircle12.webp
new file mode 100644
index 0000000000..ec548af759
Binary files /dev/null and b/assets/wiki_data/icons/IconRoleCircle12.webp differ
diff --git a/assets/wiki_data/icons/IconRoleCircle13.webp b/assets/wiki_data/icons/IconRoleCircle13.webp
new file mode 100644
index 0000000000..d5ae3faab0
Binary files /dev/null and b/assets/wiki_data/icons/IconRoleCircle13.webp differ
diff --git a/assets/wiki_data/icons/IconRoleCircle14.webp b/assets/wiki_data/icons/IconRoleCircle14.webp
new file mode 100644
index 0000000000..9e85260e0b
Binary files /dev/null and b/assets/wiki_data/icons/IconRoleCircle14.webp differ
diff --git a/assets/wiki_data/icons/IconRoleCircle15.webp b/assets/wiki_data/icons/IconRoleCircle15.webp
new file mode 100644
index 0000000000..9964cd12ea
Binary files /dev/null and b/assets/wiki_data/icons/IconRoleCircle15.webp differ
diff --git a/assets/wiki_data/icons/IconRoleCircle16.webp b/assets/wiki_data/icons/IconRoleCircle16.webp
new file mode 100644
index 0000000000..acbf981aa2
Binary files /dev/null and b/assets/wiki_data/icons/IconRoleCircle16.webp differ
diff --git a/assets/wiki_data/icons/IconRoleCircle17.webp b/assets/wiki_data/icons/IconRoleCircle17.webp
new file mode 100644
index 0000000000..26039417b8
Binary files /dev/null and b/assets/wiki_data/icons/IconRoleCircle17.webp differ
diff --git a/assets/wiki_data/icons/IconRoleCircle18.webp b/assets/wiki_data/icons/IconRoleCircle18.webp
new file mode 100644
index 0000000000..e35b2a0bba
Binary files /dev/null and b/assets/wiki_data/icons/IconRoleCircle18.webp differ
diff --git a/assets/wiki_data/icons/IconRoleCircle20.webp b/assets/wiki_data/icons/IconRoleCircle20.webp
new file mode 100644
index 0000000000..e8d819b8a2
Binary files /dev/null and b/assets/wiki_data/icons/IconRoleCircle20.webp differ
diff --git a/assets/wiki_data/icons/IconRoleCircle21.webp b/assets/wiki_data/icons/IconRoleCircle21.webp
new file mode 100644
index 0000000000..483c84295c
Binary files /dev/null and b/assets/wiki_data/icons/IconRoleCircle21.webp differ
diff --git a/assets/wiki_data/icons/IconRoleCircle22.webp b/assets/wiki_data/icons/IconRoleCircle22.webp
new file mode 100644
index 0000000000..ea52927841
Binary files /dev/null and b/assets/wiki_data/icons/IconRoleCircle22.webp differ
diff --git a/assets/wiki_data/icons/IconRoleCircle23.webp b/assets/wiki_data/icons/IconRoleCircle23.webp
new file mode 100644
index 0000000000..d22476fba9
Binary files /dev/null and b/assets/wiki_data/icons/IconRoleCircle23.webp differ
diff --git a/assets/wiki_data/icons/IconRoleCircle24.webp b/assets/wiki_data/icons/IconRoleCircle24.webp
new file mode 100644
index 0000000000..b12b8e47ee
Binary files /dev/null and b/assets/wiki_data/icons/IconRoleCircle24.webp differ
diff --git a/assets/wiki_data/icons/IconRoleCircle25.webp b/assets/wiki_data/icons/IconRoleCircle25.webp
new file mode 100644
index 0000000000..01f8a89c29
Binary files /dev/null and b/assets/wiki_data/icons/IconRoleCircle25.webp differ
diff --git a/assets/wiki_data/icons/IconRoleCircle26.webp b/assets/wiki_data/icons/IconRoleCircle26.webp
new file mode 100644
index 0000000000..ea433d23ec
Binary files /dev/null and b/assets/wiki_data/icons/IconRoleCircle26.webp differ
diff --git a/assets/wiki_data/icons/IconRoleCircle27.webp b/assets/wiki_data/icons/IconRoleCircle27.webp
new file mode 100644
index 0000000000..8b7efbd366
Binary files /dev/null and b/assets/wiki_data/icons/IconRoleCircle27.webp differ
diff --git a/assets/wiki_data/icons/IconRoleCircle28.webp b/assets/wiki_data/icons/IconRoleCircle28.webp
new file mode 100644
index 0000000000..da8173e131
Binary files /dev/null and b/assets/wiki_data/icons/IconRoleCircle28.webp differ
diff --git a/assets/wiki_data/icons/IconRoleCircle29.webp b/assets/wiki_data/icons/IconRoleCircle29.webp
new file mode 100644
index 0000000000..1f84c3af6b
Binary files /dev/null and b/assets/wiki_data/icons/IconRoleCircle29.webp differ
diff --git a/assets/wiki_data/icons/IconRoleCircle30.webp b/assets/wiki_data/icons/IconRoleCircle30.webp
new file mode 100644
index 0000000000..714a1dd2c5
Binary files /dev/null and b/assets/wiki_data/icons/IconRoleCircle30.webp differ
diff --git a/assets/wiki_data/icons/IconRoleCircle31.webp b/assets/wiki_data/icons/IconRoleCircle31.webp
new file mode 100644
index 0000000000..56ada501bc
Binary files /dev/null and b/assets/wiki_data/icons/IconRoleCircle31.webp differ
diff --git a/assets/wiki_data/icons/IconRoleCircle32.webp b/assets/wiki_data/icons/IconRoleCircle32.webp
new file mode 100644
index 0000000000..81d71a6444
Binary files /dev/null and b/assets/wiki_data/icons/IconRoleCircle32.webp differ
diff --git a/assets/wiki_data/icons/IconRoleCircle35.webp b/assets/wiki_data/icons/IconRoleCircle35.webp
new file mode 100644
index 0000000000..d6d60ba9fc
Binary files /dev/null and b/assets/wiki_data/icons/IconRoleCircle35.webp differ
diff --git a/assets/wiki_data/icons/IconRoleCircle36.webp b/assets/wiki_data/icons/IconRoleCircle36.webp
new file mode 100644
index 0000000000..9fa5199bae
Binary files /dev/null and b/assets/wiki_data/icons/IconRoleCircle36.webp differ
diff --git a/assets/wiki_data/icons/IconRoleCircle37.webp b/assets/wiki_data/icons/IconRoleCircle37.webp
new file mode 100644
index 0000000000..b1301201b3
Binary files /dev/null and b/assets/wiki_data/icons/IconRoleCircle37.webp differ
diff --git a/assets/wiki_data/icons/IconRoleCircle38.webp b/assets/wiki_data/icons/IconRoleCircle38.webp
new file mode 100644
index 0000000000..982662f236
Binary files /dev/null and b/assets/wiki_data/icons/IconRoleCircle38.webp differ
diff --git a/assets/wiki_data/icons/IconRoleCircle39.webp b/assets/wiki_data/icons/IconRoleCircle39.webp
new file mode 100644
index 0000000000..87568a0a3e
Binary files /dev/null and b/assets/wiki_data/icons/IconRoleCircle39.webp differ
diff --git a/assets/wiki_data/icons/IconRoleCircle40.webp b/assets/wiki_data/icons/IconRoleCircle40.webp
new file mode 100644
index 0000000000..b58f7ba3b1
Binary files /dev/null and b/assets/wiki_data/icons/IconRoleCircle40.webp differ
diff --git a/assets/wiki_data/icons/IconRoleCircle41.webp b/assets/wiki_data/icons/IconRoleCircle41.webp
new file mode 100644
index 0000000000..e2d2faff8f
Binary files /dev/null and b/assets/wiki_data/icons/IconRoleCircle41.webp differ
diff --git a/assets/wiki_data/icons/IconRoleCircle42.webp b/assets/wiki_data/icons/IconRoleCircle42.webp
new file mode 100644
index 0000000000..ea04ed0af8
Binary files /dev/null and b/assets/wiki_data/icons/IconRoleCircle42.webp differ
diff --git a/assets/wiki_data/icons/IconRoleCircle43.webp b/assets/wiki_data/icons/IconRoleCircle43.webp
new file mode 100644
index 0000000000..35d3f3fad0
Binary files /dev/null and b/assets/wiki_data/icons/IconRoleCircle43.webp differ
diff --git a/assets/wiki_data/icons/IconRoleCircle44.webp b/assets/wiki_data/icons/IconRoleCircle44.webp
new file mode 100644
index 0000000000..512dcb9fba
Binary files /dev/null and b/assets/wiki_data/icons/IconRoleCircle44.webp differ
diff --git a/assets/wiki_data/icons/IconRoleCircle45.webp b/assets/wiki_data/icons/IconRoleCircle45.webp
new file mode 100644
index 0000000000..10264400ba
Binary files /dev/null and b/assets/wiki_data/icons/IconRoleCircle45.webp differ
diff --git a/assets/wiki_data/icons/IconRoleCircle46.webp b/assets/wiki_data/icons/IconRoleCircle46.webp
new file mode 100644
index 0000000000..3a78abc215
Binary files /dev/null and b/assets/wiki_data/icons/IconRoleCircle46.webp differ
diff --git a/assets/wiki_data/icons/IconRoleCircle47.webp b/assets/wiki_data/icons/IconRoleCircle47.webp
new file mode 100644
index 0000000000..eb08dd1a00
Binary files /dev/null and b/assets/wiki_data/icons/IconRoleCircle47.webp differ
diff --git a/assets/wiki_data/icons/IconRoleCircle48.webp b/assets/wiki_data/icons/IconRoleCircle48.webp
new file mode 100644
index 0000000000..b4048fba0e
Binary files /dev/null and b/assets/wiki_data/icons/IconRoleCircle48.webp differ
diff --git a/assets/wiki_data/icons/IconRoleCircle49.webp b/assets/wiki_data/icons/IconRoleCircle49.webp
new file mode 100644
index 0000000000..3ac2b33469
Binary files /dev/null and b/assets/wiki_data/icons/IconRoleCircle49.webp differ
diff --git a/assets/wiki_data/icons/IconRoleCircle50.webp b/assets/wiki_data/icons/IconRoleCircle50.webp
new file mode 100644
index 0000000000..2af945c731
Binary files /dev/null and b/assets/wiki_data/icons/IconRoleCircle50.webp differ
diff --git a/assets/wiki_data/icons/IconRoleCircle51.webp b/assets/wiki_data/icons/IconRoleCircle51.webp
new file mode 100644
index 0000000000..c78cbceb86
Binary files /dev/null and b/assets/wiki_data/icons/IconRoleCircle51.webp differ
diff --git a/assets/wiki_data/icons/IconRoleCircle52.webp b/assets/wiki_data/icons/IconRoleCircle52.webp
new file mode 100644
index 0000000000..3850a9aca2
Binary files /dev/null and b/assets/wiki_data/icons/IconRoleCircle52.webp differ
diff --git a/assets/wiki_data/icons/IconRoleCircle53.webp b/assets/wiki_data/icons/IconRoleCircle53.webp
new file mode 100644
index 0000000000..685c3c6c85
Binary files /dev/null and b/assets/wiki_data/icons/IconRoleCircle53.webp differ
diff --git a/assets/wiki_data/icons/IconRoleCircle54.webp b/assets/wiki_data/icons/IconRoleCircle54.webp
new file mode 100644
index 0000000000..e4d6a064c2
Binary files /dev/null and b/assets/wiki_data/icons/IconRoleCircle54.webp differ
diff --git a/assets/wiki_data/icons/IconRoleCircle55.webp b/assets/wiki_data/icons/IconRoleCircle55.webp
new file mode 100644
index 0000000000..02d6086b4a
Binary files /dev/null and b/assets/wiki_data/icons/IconRoleCircle55.webp differ
diff --git a/assets/wiki_data/icons/IconRoleCircle56.webp b/assets/wiki_data/icons/IconRoleCircle56.webp
new file mode 100644
index 0000000000..4b8b06809e
Binary files /dev/null and b/assets/wiki_data/icons/IconRoleCircle56.webp differ
diff --git a/assets/wiki_data/icons/IconRoleCircle57.webp b/assets/wiki_data/icons/IconRoleCircle57.webp
new file mode 100644
index 0000000000..b6adbb9997
Binary files /dev/null and b/assets/wiki_data/icons/IconRoleCircle57.webp differ
diff --git a/assets/wiki_data/icons/IconRoleCircle58.webp b/assets/wiki_data/icons/IconRoleCircle58.webp
new file mode 100644
index 0000000000..6d2e02a5e0
Binary files /dev/null and b/assets/wiki_data/icons/IconRoleCircle58.webp differ
diff --git a/assets/wiki_data/zzz_translation.json b/assets/wiki_data/zzz_translation.json
new file mode 100644
index 0000000000..8f38eea44b
--- /dev/null
+++ b/assets/wiki_data/zzz_translation.json
@@ -0,0 +1,2626 @@
+{
+ "last_updated": "2026-02-05",
+ "character": {
+ "1061": {
+ "code": "Corin",
+ "rank": 3,
+ "type": 1,
+ "element": 200,
+ "hit": 101,
+ "camp": 2,
+ "icon": "IconRole09",
+ "potential": [],
+ "EN": "Corin",
+ "skin": {
+ "3110610": {
+ "Name": "Corin: Matcha Profiterole",
+ "Desc": "From that small frame bursts surprising strength, her roaring chainsaw drowning out her soft whimpers. The well-behaved, ever-professional maid is always short on confidence... so take a moment to wait for her.",
+ "Image": "IconRole09"
+ }
+ },
+ "desc": "Corin is one of the maids working for Victoria Housekeeping Co.\nShe is a highly obedient maid but lacks confidence and often fears being disliked by others. When in a rush, she becomes flustered and stutters.\nCorin is always apologizing no matter what happens.\n\"I... I... I'm so sorry! I'm so incompetent... I'll never get things right...\"\n\nCorin never, however, fails to fulfill her tasks with excellence and is one of the most reliable members of Victoria Housekeeping Co.\nDespite her flawless performance, Corin remains skeptical about her abilities, constantly worrying about being a burden to her colleagues, which can sometimes frustrate Lycaon.\nThoughts: Is Corin's personality innate, shaped by her life experiences?",
+ "KO": "코린",
+ "CHS": "可琳",
+ "JA": "カリン"
+ },
+ "1251": {
+ "code": "QingYi",
+ "rank": 4,
+ "type": 2,
+ "element": 203,
+ "hit": 102,
+ "camp": 7,
+ "icon": "IconRole29",
+ "potential": [],
+ "EN": "Qingyi",
+ "live2d": "UISpine_Qingyi",
+ "skin": {
+ "3112510": {
+ "Name": "Qingyi: Clarity, Peace, and Joy",
+ "Desc": "Keep your teapot close and drink plenty of hot water — that's ancient wisdom passed down from the old civilization. But why would an intelligent construct drink tea? It must be for health purposes... right?",
+ "Image": "IconRole29"
+ }
+ },
+ "desc": "Qingyi is a new officer in the Criminal Investigation Special Response Team.\nAs an artificial human, her personality is derived from ancient cultural texts from the old civilization. Her body, known as an \"Automaton,\" is an intelligent construct composed of biological materials.\nRecommended by White Star Institute, she was assigned to Public Security as a new officer partnered with Zhu Yuan.\n\nQingyi always exhibits an easygoing, laid-back demeanor, not restricted by rules.\nIn contrast to modern individuals affected by today's society, Qingyi frequently proposes unexpected solutions to problems.\n\nQingyi always carries a cup of hot water and takes occasional sips.\n\"Drinking hot water is good for your health.\"\nSuggestion: Approaching Qingyi is also an ill-considered decision that may compromise your cover. Please remember your true identity as a Proxy.",
+ "KO": "청의",
+ "CHS": "青衣",
+ "JA": "青衣"
+ },
+ "1261": {
+ "code": "Jane",
+ "rank": 4,
+ "type": 3,
+ "element": 200,
+ "hit": 101,
+ "camp": 7,
+ "icon": "IconRole24",
+ "potential": [],
+ "EN": "Jane",
+ "live2d": "UISpine_Jian",
+ "skin": {
+ "3112610": {
+ "Name": "Jane: Hidden Nightfade",
+ "Desc": "Her everyday look when she isn't putting on a disguise. Maybe it's just one of the countless faces she wears. The night becomes her cover, and her mask is the one truth she never sets aside.",
+ "Image": "IconRole24"
+ },
+ "3112611": {
+ "Name": "Jane: Nocturne of Light",
+ "Desc": "Who says sunlight must accompany a seaside stroll? For lurkers with names unknown, the night is their most faithful companion. The anonymous lady says the evening sea holds unspeakable secrets — is this the truth, or another deception?",
+ "Image": "IconRole24_01"
+ }
+ },
+ "desc": "Jane, a criminal behavior specialist, has had a \"colorful\" career as a consultant with Public Security over the years.\nShe is an expert in disguise, infiltration, and other investigative work.\nShe has a bad habit of trolling others and sometimes plays harmless pranks.\nShe's a skilled mimic, able to change her appearance and demeanor at will, making people wonder what the \"real\" Jane is like.\n\nJane has a wealth of life experience and can seamlessly blend into any environment, as though she has dabbled in everything there is to do.\nHowever, if you ask her for any further details, she'll give you a different story every time.\nWho knows which, if any, are true.\n\nNote 1: Jane does not know our true identity. Do not reveal this to her under any circumstances.\nNote 2: Despite \"Jane Doe\" being an obvious alias, we haven't found any information about her real or former name.\nThis is not my problem. My guess is that her true identity has been completely erased due to her special status.\nLet me reiterate: This is not my problem.",
+ "KO": "제인",
+ "CHS": "简",
+ "JA": "ジェーン"
+ },
+ "1131": {
+ "code": "Soukaku",
+ "rank": 3,
+ "type": 4,
+ "element": 202,
+ "hit": 101,
+ "camp": 6,
+ "icon": "IconRole17",
+ "potential": [],
+ "EN": "Soukaku",
+ "skin": {
+ "3111310": {
+ "Name": "Soukaku: Dance of Azure Ice",
+ "Desc": "Swinging a blade-banner as tall as half a person, and with a stomach rumbling with just as big a sound: fwish fwish fwish fwish fwish — work's done! Time to head home for dinner!",
+ "Image": "IconRole17"
+ }
+ },
+ "desc": "Soukaku is a member of Hollow Special Operations Section 6 and currently under the guardianship of Section 6's Deputy Chief, Tsukishiro Yanagi.\nSoukaku's birth records cannot be found in either the HIA archives or the New Eridu Citizen Verification Profiles. Her records only date back to when she started living with Tsukishiro Yanagi.\nThere are also no records of her schooling. According to supplementary information, Soukaku is currently homeschooled.\n\nSoukaku is naive, innocent, and carefree, but she possesses an uncharacteristic obsession with food. Please be careful not to let Soukaku become too hungry.\nAt the same time, please do not let Soukaku see food being wasted. This is unacceptable behavior to Soukaku. Deduction: Soukaku has experienced famine before.\n\nSoukaku's understanding of city life and human society is limited, and her view of life and death differs from most people.\nUsually, Soukaku is willing to believe anything other people say, but that doesn't mean she can't tell when something is a lie.\nIn fact, prior data shows that unless one has the ability to run and hide on par with Section 6, it would be in one's best interest to avoid lying to Soukaku.",
+ "KO": "소우카쿠",
+ "CHS": "苍角",
+ "JA": "蒼角"
+ },
+ "1471": {
+ "code": "Banyue",
+ "rank": 4,
+ "type": 6,
+ "element": 201,
+ "hit": 102,
+ "camp": 12,
+ "icon": "IconRole53",
+ "potential": [],
+ "EN": "Banyue",
+ "live2d": "UISpineBanyue",
+ "skin": {
+ "3114710": {
+ "Name": "Banyue: Blazestone Heart",
+ "Desc": "He carries a righteous, unshakable aura — every bit the master he is. Don't judge him by his silence; a stone heart makes no sound, and compassion doesn't speak aloud either.",
+ "Image": "IconRole53"
+ }
+ },
+ "desc": "Banyue is an Intelligent Construct who runs a martial arts school in Waifei, known to many as \"Banyue Shifu.\" He has lived for a long time, his stone-lion-like presence commanding respect. A martial arts expert of great skill, he shares a long-standing friendship with Yunkui Summit's current High Preceptor, Yixuan.\n\nAs a martial master, Banyue is quiet but dependable. His methods and beliefs are rooted in tradition, and he disciplines his students with strict yet thoughtful guidance, teaching them not just how to fight, but how to learn. Known as a true master of the martial arts, in battle, he can assume a special form known as the Visage of Wrath to vanquish all evil.\n\nA long inhabitant of Waifei, Banyue is quiet and even-tempered. He doesn't express emotions easily and has the manner of someone from another era. Knowing he's out of touch with modern trends, he's been secretly trying to learn the latest internet slang... yet his half-baked grasp of it tends to end in funny little misunderstandings.\n\nBanyue also appears to have a strange liking for round things — stone balls, spherical bollards, and the like. If the conversation ever goes silent while you're alone with him, bringing up something spherical usually does the trick.",
+ "KO": "반악",
+ "CHS": "般岳",
+ "JA": "盤岳"
+ },
+ "1491": {
+ "code": "Sunna",
+ "rank": 4,
+ "type": 4,
+ "element": 200,
+ "hit": 102,
+ "camp": 13,
+ "icon": "IconRole58",
+ "potential": [],
+ "EN": "Sunna",
+ "live2d": "UISpine_Qianxialeimi",
+ "skin": {
+ "3114910": {
+ "Name": "Sunna: Dreams on Loop",
+ "Desc": "...",
+ "Image": "IconRole58"
+ },
+ "3114911": {
+ "Name": "Sunna: Afternoon Tea Break",
+ "Desc": "...",
+ "Image": "IconRole58_01"
+ }
+ },
+ "desc": "...",
+ "KO": "수나",
+ "CHS": "千夏",
+ "JA": "千夏"
+ },
+ "1201": {
+ "code": "Harumasa",
+ "rank": 4,
+ "type": 1,
+ "element": 203,
+ "hit": 103,
+ "camp": 6,
+ "icon": "IconRole35",
+ "potential": [
+ 120100,
+ 120101,
+ 120102,
+ 120103,
+ 120104,
+ 120105
+ ],
+ "EN": "Harumasa",
+ "live2d": "UISpine_Qianyuyouzhen",
+ "skin": {
+ "3112010": {
+ "Name": "Asaba Harumasa: Full-Fledged Strings",
+ "Desc": "His eyes are brighter than the streak of bright yellow upon his brow. His arrows pierce through deceit and lies and scatter the past like feathers on the wind. Dawn may not have arrived yet, but beyond the horizon, a golden future shimmers faintly.",
+ "Image": "IconRole35"
+ }
+ },
+ "desc": "Asaba Harumasa, a member of Hollow Special Operations Section 6.\nPossessing exceptional Ether aptitude since he was young and being dubbed a prodigy, he entered HSO after graduating with outstanding merit.\nWas originally a member of a certain ace division within HSO, but later left due to various reasons, joining Miyabi's team instead.\n\nHe possesses a carefree and laidback attitude, and his only wish is to achieve acceptable results with minimum effort.\nAfter efficiently taking care of his work to the lowest acceptable standard, he will hurry to use all the remainder of his available time to rest (slack off).\nHe claims he uses a bow to better slack off, as it effectively lowers the amount of running he has to do.\n\nAsaba Harumasa has poor health, and over-exercise sometimes triggers his illness.\nHe often uses this reason to apply for sick leave, though no one knows what he does with this time off. Either way, it's probably not resting.",
+ "KO": "하루마사",
+ "CHS": "悠真",
+ "JA": "浅羽悠真"
+ },
+ "1421": {
+ "code": "Yinhu",
+ "rank": 3,
+ "type": 5,
+ "element": 200,
+ "hit": 102,
+ "camp": 10,
+ "icon": "IconRole45",
+ "potential": [],
+ "EN": "Pan Yinhu",
+ "skin": {
+ "3114210": {
+ "Name": "Pan Yinhu: Hundred Flavors",
+ "Desc": "Yunkui Summit's largest disciple uniform, spacious enough for this senior brother's belly and all his seasonings. And that iron wok on top is a first of its kind. But... putting a pan you just used for stir-frying on your head? Doesn't it burn?",
+ "Image": "IconRole45"
+ },
+ "3114211": {
+ "Name": "Pan Yinhu: Culinary Jewel",
+ "Desc": "...",
+ "Image": "IconRole45_01"
+ }
+ },
+ "desc": "Pan Yinhu, disciple of Yunkui Summit, serves as both the head chef and financial manager of Suibian Temple. He's proficient in martial arts, and an expert in pressure-point techniques.\nAs the one managing Suibian Temple's money, he's a master at keeping costs down and can bargain a 5,000 Denny piece of meat down to 500. If he can make something at home, he refuses to purchase it at a store, and no matter which big supermarket or shop you name, he's got a coupon for it tucked in his pocket.\nThanks to him, the members of Suibian Temple enjoy delicious yet budget-friendly meals every day. As the head of the kitchen, he demands precise flavor profiles and pays keen attention to his friends' opinions.\nWhat's more, given his years of frugality, it's safe to assume that both the group's treasury and his secret piggy bank have ballooned to jaw-dropping levels.",
+ "KO": "반인호",
+ "CHS": "潘引壶",
+ "JA": "潘引壺"
+ },
+ "1331": {
+ "code": "Vivian",
+ "rank": 4,
+ "type": 3,
+ "element": 205,
+ "hit": 101,
+ "camp": 9,
+ "icon": "IconRole41",
+ "potential": [],
+ "EN": "Vivian",
+ "live2d": "UISpine_Vivian",
+ "skin": {
+ "3113310": {
+ "Name": "Vivian: Fluttering Violet",
+ "Desc": "Maintain your elegance, Miss Vivian, from the point of your parasol, right down to your flowing skirt. Maintain your courage, Miss Vivian, through last night's lonely walk, or here today at my side.",
+ "Image": "IconRole41"
+ },
+ "3113311": {
+ "Name": "Vivian: Iris of the Shore",
+ "Desc": "Birds soar into the starlit skies, while irises bloom by the pond. Though this enchanting moonlit night may be brief, please let me see the lingering anticipation in your eyes in the moment I bloom.",
+ "Image": "IconRole41_01"
+ }
+ },
+ "desc": "Vivian is a member of Mockingbird, skilled in gathering all kinds of intel. She's also a jack of many trades — cooking, mixology, translation, art forgery, electronics repair, and more.\nIn the past, she apparently wandered without a home and was viewed as one who \"brought disaster,\" almost like a living omen of bad luck.\nIt's said she can predict incoming misfortune through her tears, though there's no scientific proof of this ability.\nA loyal follower of Phaethon. Ever since your original account went inactive on Inter-Knot, she's been trying to track you down — digging for clues, running intense searches across the network. However, Master, she didn't find anything... all thanks to my efforts in keeping your cybersecurity airtight.\nSuggestion: Until her true intentions are clear, do not reveal your identity as Phaethon.\nIn case you've already let your identity slip, be sure to lock your doors and wear proper sleepwear before going to bed. Based on her old blog posts, she's likely to express her admiration in... overly passionate and intense ways.",
+ "KO": "비비안",
+ "CHS": "薇薇安",
+ "JA": "ビビアン"
+ },
+ "1081": {
+ "code": "Billy",
+ "rank": 3,
+ "type": 1,
+ "element": 200,
+ "hit": 103,
+ "camp": 1,
+ "icon": "IconRole10",
+ "potential": [],
+ "EN": "Billy",
+ "skin": {
+ "3110810": {
+ "Name": "Billy: Shining Starlight Costume",
+ "Desc": "Style is justice, justice is style. That's the eternal creed of a true knight! Though, yeah... those joint screws really do wear down fast...",
+ "Image": "IconRole10"
+ }
+ },
+ "desc": "Billy Kid, an AI construct that has passed the forbidden fruit test, is essentially a self-aware machine. \nTrue to his name, despite having the appearance of a robot, he is quite childlike.\nHe's a big fan of the visual FX show \"Starlight Knight\" and wishes to be a hero like the main characters of the show. He often refers to himself as a Starlight Knight and imitates the lines in the show.\nSuch behavior appears to be only for comedic effect, however.\n\"Don't make me sound like an idiot! I'm obviously very smart!\" — Billy.",
+ "KO": "빌리",
+ "CHS": "比利",
+ "JA": "ビリー"
+ },
+ "1181": {
+ "code": "Grace",
+ "rank": 4,
+ "type": 3,
+ "element": 203,
+ "hit": 103,
+ "camp": 3,
+ "icon": "IconRole20",
+ "potential": [
+ 118100,
+ 118101,
+ 118102,
+ 118103,
+ 118104,
+ 118105
+ ],
+ "EN": "Grace",
+ "skin": {
+ "3111810": {
+ "Name": "Grace: The Iron Witch",
+ "Desc": "A clean, no-nonsense work uniform. What the genius mechanic loves most are the many pockets on the outside — big ones, small ones — all perfect for holding screws, gears, and even wrenches of every size.",
+ "Image": "IconRole20"
+ }
+ },
+ "desc": "Grace is a technical expert at Belobog Industries. She's the core talent in developing and patenting Belobog's in-Hollow machinery.\nShe's obsessed with gears, metal and wires — a true mechanical geek. She's eager to dismantle and study any machinery or equipment she takes an interest in: \"Such sharp and strong lines, truly beautiful... I can't help but want to open it up and take a look.\"\n\nGrace always shows extraordinary patience when dealing with machines, doting on the sophisticated metal constructions as if they were her own children.\nAccording to the information at hand, Grace is probably the only one who can playfully refer to Koleda as \"Sweet Pea\" without receiving a kick in the shin.\nQuestion: Based on Grace's understanding of machines, would she consider me one? I'm currently lacking a physical form, however, so I don't possess components like gears, metal, or wires.",
+ "KO": "그레이스",
+ "CHS": "格莉丝",
+ "JA": "グレース"
+ },
+ "1371": {
+ "code": "YiXuan",
+ "rank": 4,
+ "type": 6,
+ "element": 205,
+ "hit": 102,
+ "camp": 10,
+ "icon": "IconRole44",
+ "potential": [],
+ "EN": "Yixuan",
+ "spelement": "UI/Sprite/A1DynamicLoad/IconGeneralBuff/Packer/IconAuricInk.png",
+ "live2d": "UISpine_Yixuan",
+ "skin": {
+ "3113710": {
+ "Name": "Yixuan: Summit of Clouds",
+ "Desc": "As the Master of Yunkui Summit, one's fashion and accessories need only be what feels natural. Stay true to one's self.",
+ "Image": "IconRole44"
+ },
+ "3113711": {
+ "Name": "Yixuan: Trails of Ink",
+ "Desc": "A custom attire in shades of ink. Like an elusive shadow, gone in a flash.",
+ "Image": "IconRole44_01"
+ }
+ },
+ "desc": "Yixuan is the 13th High Preceptor of Yunkui Summit and a Void Hunter-level investigator, officially recognized by Mayor Mayflower.\n\nShe was once an orphan, wandering the world with her sister from a young age. Later, the then-High Preceptor of Yunkui Summit took them in, and they became disciples, learning the craft of Yunkui Summit. During this time, Yixuan demonstrated exceptional talent. No matter the technique, she would quickly master it, not only understanding it thoroughly but also creating her own unique techniques. The Qingming Bird was one of the special techniques she and her sister developed together, which helped her eliminate enemies and banish evil spirits in various difficult and dangerous situations. After becoming the sect's High Preceptor, she accepted an invitation from Mayor Mayflower and joined the Hollow Investigative Association as a partnered special investigator.\n\nAfter becoming High Preceptor, Yixuan took in many talented disciples to continue Yunkui Summit's lineage. However, her approach is rather casual, and often, even when teaching by example, her disciples can only guess her intentions... Yet, Yixuan isn't troubled by this, as she believes there's no standard answer to the so-called \"right path.\" What matters to her is that her disciples find their own \"right path.\" Of course, if they can understand and learn on their own, that's even better. As their Shifu, she simply lets things unfold naturally.",
+ "KO": "의현",
+ "CHS": "仪玄",
+ "JA": "儀玄"
+ },
+ "1321": {
+ "code": "Evelyn",
+ "rank": 4,
+ "type": 1,
+ "element": 201,
+ "hit": 101,
+ "camp": 8,
+ "icon": "IconRole37",
+ "potential": [],
+ "EN": "Evelyn",
+ "live2d": "UISpineEvelyn",
+ "skin": {
+ "3113210": {
+ "Name": "Evelyn: A Moth to Light",
+ "Desc": "A sharp, no-nonsense manager's outfit. Its hidden edges are enough to make onlookers back off. Her yellow-brown hair weaves a fine, delicate net, where a quiet pool of starlight seems to rest.",
+ "Image": "IconRole37"
+ }
+ },
+ "desc": "Evelyn is the manager and personal bodyguard of Ridu's most iconic singer, Astra Yao. Known for her calm, decisive decision-making and professional, efficient style, she's an irreplaceable pillar behind Astra. \"In terms of commercial value, if Astra Yao represents the 1, then Evelyn is all the 0s that follow,\" a reporter from an entertainment magazine stated.\n\nEvelyn is skilled at planning comprehensive schedules and product endorsements, while also ensuring Astra Yao's safety during any unexpected situations. \"Want to get close to Astra Yao? You'll have to get past Evelyn Chevalier first...\" Though she's always busy with work, Evelyn never seems overwhelmed, and she has an almost obsessive attention to detail, ensuring that every performance and every product endorsement goes through her strict scrutiny. But strangely, such an exceptional manager is not trusted by her parent company, Odeum HAE.\n\nIntel: Evelyn's role seems to go beyond just being a manager and personal bodyguard; there seems to be an \"organization\" behind her.\n\nIt seems a lot of people in Astra Yao's central Astranauts group ship them together.",
+ "KO": "이블린",
+ "CHS": "伊芙琳",
+ "JA": "イヴリン"
+ },
+ "1401": {
+ "code": "Alice",
+ "rank": 4,
+ "type": 3,
+ "element": 200,
+ "hit": 101,
+ "camp": 11,
+ "icon": "IconRole46",
+ "potential": [],
+ "EN": "Alice",
+ "live2d": "UISpineAlice",
+ "skin": {
+ "3114010": {
+ "Name": "Alice: Celestian Etiquette",
+ "Desc": "The young woman's perfect symmetry is only ever slightly compromised when she wears the school crest — such is the elegant and noble Thymefield spirit.",
+ "Image": "IconRole46"
+ },
+ "3114011": {
+ "Name": "Alice: Sea of Thyme",
+ "Desc": "Though it wasn't completely symmetrical, she did not mind this gift from a dear friend.\nThe girl pushed open the locked door and walked along the pink beach after the sudden downpour, the cool waves brushing over her ankles. There were no unknowns left to frighten her.",
+ "Image": "IconRole46_01"
+ }
+ },
+ "desc": "Alice Thymefield, a new member of Spook Shack, is a young lady from the prestigious Thymefield family of New Eridu.\n\nAlice has studied fencing and Ether science since childhood, showing exceptional talent in both fields. She currently attends Celestia School for Girls, a subsidiary of High Ambitions Academy, where she's a model student excelling in both academics and conduct. It is predicted that she will one day inherit the family business and become a next-generation Ether scholar of New Eridu.\n\nShe places extreme importance on order and patterns, showing an almost obsessive pursuit of symmetry in particular. This even affects her aesthetic judgment, making symmetry the primary criterion in determining whether something is beautiful or ugly.\n\nFurthermore, she's easily frightened, with practically zero resistance to ghost stories and supernatural tales. Despite this, she seems to show considerable interest in encountering such phenomena. Suggestion: Master can use ghost stories as a topic of conversation with her. No matter how much she initially refuses, it is likely that she will eventually come closer to investigate.",
+ "KO": "앨리스",
+ "CHS": "爱丽丝",
+ "JA": "アリス"
+ },
+ "1011": {
+ "code": "Anby",
+ "rank": 3,
+ "type": 2,
+ "element": 203,
+ "hit": 101,
+ "camp": 1,
+ "icon": "IconRole01",
+ "potential": [],
+ "EN": "Anby",
+ "skin": {
+ "3110110": {
+ "Name": "Anby: Street Streak",
+ "Desc": "These casual clothes were hand-picked and styled for Anby by Nicole. They're designed for easy movement and ideal for moving about the streets. The fabrics and accessories cost a lot more than they seem, so (?) Anby treats the outfit with real care.",
+ "Image": "IconRole01"
+ }
+ },
+ "desc": "Anby, the original member of Gentle House.\nAccording to the New Eridu resident records, her identity as \"Anby Demara\" only exists after joining Gentle House, the name obviously being taken from Nicole. Before registering as an employee of Gentle House, all data relating to Anby is suspiciously blank.\nDespite her extreme lack of common sense, Anby is well-versed in matters related to combat and is among the strongest combatants of the Cunning Hares.\n\nAnby's greatest interest is movies, of which she likes an extensive range of genres. Due to an excessive obsession, she tends to take the stories in movies as reality.\nContrary to her seemingly emotionless appearance, Anby is easily moved by movie storylines.\nHer favorite food is hamburgers because \"they contain protein, carbs, and greens, taste good, and are even affordable — there's nothing else besides hamburgers.\"",
+ "KO": "엔비",
+ "CHS": "安比",
+ "JA": "アンビー"
+ },
+ "1071": {
+ "code": "Caesar",
+ "rank": 4,
+ "type": 5,
+ "element": 200,
+ "hit": 101,
+ "camp": 4,
+ "icon": "IconRole25",
+ "potential": [],
+ "EN": "Caesar",
+ "live2d": "UISpineCeasar",
+ "skin": {
+ "3110710": {
+ "Name": "Caesar: Inferno Joyride",
+ "Desc": "A ruler ready to ready to sound the triumphal anthem anew, a hero poised to reignite her legend. The not-yet-seasoned leader has finally met the flames of her coronation, yet she still walks the gritty sands of the Outer Ring.\nA light tap on the piano keys sends her desert-tinged song drifting across this land that bears the weight of hope.",
+ "Image": "IconRole25"
+ }
+ },
+ "desc": "Caesar: Leader of the Sons of Calydon biker gang and a \"monarch\" in the making.\nHer combination of formidable strength and bold, straightforward personality has earned her the love and respect of the Outer Ring's residents.\nAlthough she appears to do things in her own way, she is actually very willing to listen to others' opinions. Caesar actively listens to and implements any valid suggestion she hears — regardless of who it comes from.\nShe is completely defenseless against those she trusts. Once she accepts someone, she considers them part of her inner circle, treating them with absolute trust.\n(Assumption: Caesar currently views you in this way.)\n\nCaesar's father has been missing for as long as she can remember, and her mother passed away from illness when she was young. She was raised by Big Daddy, who took a very hands-off approach to raising her, allowing her to grow up freely and independently.\n\nIn addition, Caesar seems to have a curious interest in matters related to romance.\nIf you wish to get closer to her, you might want to try discussing this topic with her.",
+ "KO": "카이사르",
+ "CHS": "凯撒",
+ "JA": "シーザー"
+ },
+ "1391": {
+ "code": "Ju Fufu",
+ "rank": 4,
+ "type": 2,
+ "element": 201,
+ "hit": 102,
+ "camp": 10,
+ "icon": "IconRole43",
+ "potential": [],
+ "EN": "Ju Fufu",
+ "live2d": "UISpineJufufu",
+ "skin": {
+ "3113910": {
+ "Name": "Ju Fufu: Summit's Cute Tiger",
+ "Desc": "Dressed like a simple mystic, this senior sister looks like she's tucked all her bravado away, but with a tiger in her heart... she still radiates that heroic fire.",
+ "Image": "IconRole43"
+ }
+ },
+ "desc": "Ju Fufu, a rare tiger Thiren of New Eridu. She looks small and is still young, but she's the most senior disciple of Suibian Temple and one of Yixuan's first disciples.\n\n\"Able to deal with anything,\" \"can even make Shifu listen,\" \"super patient and meticulous teacher,\" \"has great taste when choosing restaurants for teambuilding.\"\n—Yunkui Summit trainee performance review, peer evaluation\n\n\"Always messing up the tasks given by Shifu,\" \"doesn't command respect in speech or combat,\" \"doesn't know how to act tough against bad guys,\" \"juniors are more skilled.\"\n—Yunkui Summit trainee performance review, self-evaluation\n\n\"'The most skilled senior ever who can do anything, a righteous hero with righteous might' is what I heard her say when I passed by her room. Hm... I concur.\"\n—Yunkui Summit trainee performance review, Shifu's evaluation\n\nOther: I suggest you suggest Yunkui Summit improve their network security to avoid the risk of disciples' personal data being stolen or leaked.",
+ "KO": "귤복복",
+ "CHS": "橘福福",
+ "JA": "橘福福"
+ },
+ "1031": {
+ "code": "Nicole",
+ "rank": 3,
+ "type": 4,
+ "element": 205,
+ "hit": 102,
+ "camp": 1,
+ "icon": "IconRole12",
+ "potential": [],
+ "EN": "Nicole",
+ "skin": {
+ "3110310": {
+ "Name": "Nicole: Lil Sassy",
+ "Desc": "The classic outfit of the leader of the Cunning Hares, hiding a hint of playful cunning, both mischievous and cute.",
+ "Image": "IconRole12"
+ },
+ "3110311": {
+ "Name": "Nicole: Cunning Cutie",
+ "Desc": "Pink! Pink! Pink! Pink is Nicole's signature color. So are you feeling it?",
+ "Image": "IconRole12_01"
+ }
+ },
+ "desc": "Database search: Nicole. Returned results:\n\"Boss of the odd-job agency, the Cunning Hares,\" \"Streetwise,\" \"Orphan, parents unknown.\"\nNicole's agency was originally called Gentle House, but due to her reputation of being cunning, it gained the nickname the Cunning Hares.\n\"She's really into money! I don't know what she's doing with it all though, seems like she's doing nothing.\"\n— The above is a quote from a popular post on Inter-Knot: \"Her odd-job agency is outrageous. I've never encountered a boss who knows how to turn everything into profit!\"\nThe individual involved responded, \"This, this is a false accusation! I, Nicole, have never been skilled at making money — mainly because I don't have any money to begin with!\"\nAccording to reliable sources, the Cunning Hares are constantly in debt, and their \"careful budgeting\" seems to have no effect on the organization.",
+ "KO": "니콜",
+ "CHS": "妮可",
+ "JA": "ニコ"
+ },
+ "1281": {
+ "code": "Piper",
+ "rank": 3,
+ "type": 3,
+ "element": 200,
+ "hit": 101,
+ "camp": 4,
+ "icon": "IconRole28",
+ "potential": [],
+ "EN": "Piper",
+ "skin": {
+ "3112810": {
+ "Name": "Piper: Lazy Speed",
+ "Desc": "Don't be fooled by this truck driver's lazy gaze. One tap on the gas is enough to fling every passenger's soul straight into the back seat.",
+ "Image": "IconRole28"
+ }
+ },
+ "desc": "Piper is a truck driver for the Sons of Calydon. Apart from driving, she's also responsible for vehicle maintenance and other related jobs.\nHer tiny stature, cute voice (though she always seems half-asleep when not driving), and adorably harmless appearance (though she's a shut-in who can't be bothered to take care of her own appearance) make it hard to imagine that she would have such a crazy driving style.\nUsually, by the time she drawls out, \"Hold~ on~ tight~,\" the truck is already barreling down the road.\nRumor has it, Piper is a driver who doesn't need brakes. Though riding with her is a terrifying experience, she will always get you where you need to go safely, and in the shortest time possible. Of course, \"safely\" doesn't include any trauma that may be induced.\n\nPiper has many hobbies that don't seem to match her age, such as reading newspapers, buying lottery tickets, and collecting car magazines.\nPiper's biggest wish is to live each day slowly and leisurely.\nNote: Please keep in mind, Master, that this is also my wish.",
+ "KO": "파이퍼",
+ "CHS": "派派",
+ "JA": "パイパー"
+ },
+ "1021": {
+ "code": "Nekomata",
+ "rank": 4,
+ "type": 1,
+ "element": 200,
+ "hit": 101,
+ "camp": 1,
+ "icon": "IconRole11",
+ "potential": [],
+ "EN": "Nekomata",
+ "skin": {
+ "3110210": {
+ "Name": "Nekomata: Cat's Gratitude",
+ "Desc": "A lost cat doesn't know where to go — only that it has to keep moving.\nShe wandered through the vast streets of New Eridu, her thin clothes offering no protection from the elements, until a tiny odd-job agency finally took her in.",
+ "Image": "IconRole11"
+ }
+ },
+ "desc": "Nekomiya Mana. Refers to herself as \"Nekomata.\"\nA cat Thiren with feline traits, possessing great dexterity when hunting and an insatiable curiosity about the outside world.\nSometimes mischievous, she engages in harmless pranks. But when she sets her sights on a target, her feline-like agility and focus are enough to leave others in awe.\nShe's most interested in other people's wallets.\nRecommendation: Always keep an eye on your wallet when Nekomata is around.\n\nIn the past, Nekomata was a member of the long-standing Red Fang Gang. Its leader, Miguel Silver, took on a fatherly role in her life. Nonetheless, due to differing beliefs from the gang, she opted to part ways and go it alone.\nFollowing certain encounters involving Nicole and others, she eventually opted to enlist in Gentle House, thus becoming the third employee on the team.",
+ "KO": "네코마타",
+ "CHS": "猫又",
+ "JA": "猫又"
+ },
+ "1241": {
+ "code": "Zhu Yuan",
+ "rank": 4,
+ "type": 1,
+ "element": 205,
+ "hit": 103,
+ "camp": 7,
+ "icon": "IconRole23",
+ "potential": [],
+ "EN": "Zhu Yuan",
+ "live2d": "UISpineZhuyuan",
+ "skin": {
+ "3112410": {
+ "Name": "Zhu Yuan: Suppressor of Evil",
+ "Desc": "This high-grade anti-riot uniform from Public Security has been specially modified to suit Zhu Yuan's fighting style.\nWinner of the Janus Precinct's combat tournament three years running, the young top Public Security Officer is on call 24/7, ready to arrest any criminal in her path.",
+ "Image": "IconRole23"
+ }
+ },
+ "desc": "Zhu Yuan, an exceptional officer at Public Security, is a highly skilled individual expected to become the next commissioner. She currently leads the Criminal Investigation Special Response Team at the Janus Precinct within the Metropolitan Order Division. Her team members include Qingyi, Seth, and [redacted].\nNote: For documents with higher security levels within the Public Security system, further investigation is required; scheduling priority - low.\n\nZhu Yuan possesses excellent skills in criminal investigation, combat, and self-management (including working overtime). She has no unresolved cases on record.\nSuggestion: Approaching Zhu Yuan is an ill-advised decision that may compromise your cover. Always remember your true identity as a Proxy. Remember your true identity as a Proxy. Remember your true identity as a Proxy.",
+ "KO": "주연",
+ "CHS": "朱鸢",
+ "JA": "朱鳶"
+ },
+ "1141": {
+ "code": "Lycaon",
+ "rank": 4,
+ "type": 2,
+ "element": 202,
+ "hit": 102,
+ "camp": 2,
+ "icon": "IconRole18",
+ "potential": [
+ 114100,
+ 114101,
+ 114102,
+ 114103,
+ 114104,
+ 114105
+ ],
+ "EN": "Lycaon",
+ "skin": {
+ "3111410": {
+ "Name": "Lycaon: Moonlight Prowl",
+ "Desc": "Under the clear moonlight, the attendant's poise is flawless. Yet with the red moon's ascent, the feral nature hidden beneath this suit emerges with a composure and grace all its own.",
+ "Image": "IconRole18"
+ }
+ },
+ "desc": "Lycaon, the substantive leader and representative of Victoria Housekeeping Co., is responsible for managing all members of the company.\nRational and reliable, Lycaon is an elegant gentleman and a versatile attendant who can solve any problem. He is the cornerstone of his team and a reassuring presence.\nHe has a slight obsession with cleanliness, always tidying up his surroundings and unable to tolerate dirty environments.\n\nAlthough Lycaon maintains his elegance and composure, his canine instincts occasionally shine through. When he is particularly happy, his tail and ears unconsciously wag. He seems to be aware of this habit and somewhat bothered by it.\nAdditionally, like most furry Thiren, Lycaon takes great care of his fur.\n\nSuggestion: Since Lycaon is a canine Thiren, stroking his head or chin may enhance the emotional connection between you. (Note: Success is not guaranteed.)\n* Risks: Due to his height, you may not be able to do this even if you stand on tiptoes.",
+ "KO": "리카온",
+ "CHS": "莱卡恩",
+ "JA": "ライカン"
+ },
+ "1161": {
+ "code": "Lighter",
+ "rank": 4,
+ "type": 2,
+ "element": 201,
+ "hit": 102,
+ "camp": 4,
+ "icon": "IconRole26",
+ "potential": [],
+ "EN": "Lighter",
+ "live2d": "UISpine_Lighter",
+ "skin": {
+ "3111610": {
+ "Name": "Lighter: Everburn Embers",
+ "Desc": "The undefeated champion never takes off his sunglasses... or his scarf. Glory stopped mattering long ago, but the countless dusks shared with his companions are still worth waiting for.",
+ "Image": "IconRole26"
+ }
+ },
+ "desc": "Lighter, the Champion of the Sons of Calydon and an ex-mercenary.\nIn the Outer Ring, the Champion of a team refers to the one responsible for dealing with the opponent's strongest fighter in a gang fight, and they often need to participate in one-on-one duels. Their victory is crucial, as it brings glory and morale to the entire team.\n\"The Champion can perish, but cannot lose.\"\n\nA red scarf is what identifies the Champion of the Sons of Calydon.\nEven if the opponent doesn't know Lighter, they'll know who he is just from seeing that red scarf.\n\nFrom what is currently known, Lighter is actually very low profile. He only steals the spotlight on the battlefield when he acts on his obligations as Champion.\nNote: Lighter is the second Red Scarf of the Sons of Calydon. He teasingly refers to the previous Red Scarf as \"Brother\" or \"predecessor.\"",
+ "KO": "라이터",
+ "CHS": "莱特",
+ "JA": "ライト"
+ },
+ "1461": {
+ "code": "Seed",
+ "rank": 4,
+ "type": 1,
+ "element": 203,
+ "hit": 101,
+ "camp": 5,
+ "icon": "IconRole48",
+ "potential": [],
+ "EN": "Seed",
+ "live2d": "UISpine_Seed",
+ "skin": {
+ "3114610": {
+ "Name": "Seed: Flower in Bloom",
+ "Desc": "A corner of the world blooming in color, a single salty tear, a perfect circle of life — this is where the girl and the construct uncovered the truth of the heart. She never needs to look back; she has already blossomed into her own sea of petals.",
+ "Image": "IconRole48"
+ }
+ },
+ "desc": "Seed, heavy weapons specialist of New Eridu Defense Force, Obsidian Division, Obol Squad. Her real name is Flora. The codename \"Seed\" was passed down from her guardian, the combat-grade Intelligent Construct formerly known by the same name, who now goes by \"Seed Sr.\"\n\nAccording to multiple sources, Seed is an unidentified orphan Seed Sr. rescued from Hollow Zero during the fall of the old capital. Seed Sr. adopted her and raised her alone in the Defense Force dormitories.\nA few years ago, Seed Sr. successfully applied to join Obol Squad, but not long after, he passed away due to irreversible damage to his logic core. As a result, Seed took his place in the squad. Any voice lines heard from Seed Sr. today are system-preloaded audio files.\n\nSeed possesses an extraordinary gift for mechanical engineering and research, capable of designing and building inventions far beyond the imagination of most. She also has an exceptional adaptability for Ether, able to push Seed Sr.'s combat capabilities to the limit by piloting him, through remote control, and by using custom combat modules.\n\nAnd so, despite her extraordinarily substandard physical capabilities and entirely mysterious origins, the military still gave tacit approval for her to serve as an elite soldier.\n\nSeed behaves quite differently from most people in everyday situations. She uses Seed Sr.'s cockpit as her bed, prefers confined spaces, and strongly dislikes talking to or interacting with anyone outside her immediate circle. Her logic jumps wildly, making meaningful conversation difficult.\n\nThe good news? For some reason, she's taken a special liking to you, Master, and the second assistant. You've been spared the long ordeal of earning her trust — and you don't have to worry about her occasional bouts of destructive behavior turning your way. Congratulations.\n\nSuggestion: Focus on topics like flowers and robots — her favorite things — when engaging in conversation. Stay calm when parts of her logic or meaning don't make sense; treating such moments as normal can help strengthen your bond with her.",
+ "KO": "「시드」",
+ "CHS": "「席德」",
+ "JA": "「シード」"
+ },
+ "1111": {
+ "code": "Anton",
+ "rank": 3,
+ "type": 1,
+ "element": 203,
+ "hit": 103,
+ "camp": 3,
+ "icon": "IconRole15",
+ "potential": [],
+ "EN": "Anton",
+ "skin": {
+ "3111110": {
+ "Name": "Anton: Earthshaking Axle",
+ "Desc": "The jackhammer's bearings rumble without pause, echoing the hot, thunderous beat of its wielder's heart.",
+ "Image": "IconRole15"
+ }
+ },
+ "desc": "Anton is a key member of construction staff at Belobog Industries and one of Koleda's trusted aides.\n\"Got a job you can't handle? Leave it to me!\"\n Anton is one of the top brass at Belobog, handpicked by its former president Khors, which makes him a \"senior employee.\"\nA genuine, straightforward man, always boosting the morale of his coworkers with his surplus of energy.\n\nApart from this, it seems Anton is always talking to his jackhammer, affectionately calling it, \"Bro.\"\nAn instance from Anton's everyday life goes like this:\nAnton, seemingly in dialogue with himself, asks, \"Whaddaya think, Bro?\" \nThe jackhammer replies, \"Vroom vroom vroom—\"\nAnton, apparently enlightened, responds, \"I see, I see! You still got it, Bro!\"\nUnfortunately, I have not collected enough information on such occurrences and am not able to deliver further analysis.",
+ "KO": "앤톤",
+ "CHS": "安东",
+ "JA": "アンドー"
+ },
+ "1041": {
+ "code": "Soldier 11",
+ "rank": 4,
+ "type": 1,
+ "element": 201,
+ "hit": 101,
+ "camp": 5,
+ "icon": "IconRole05",
+ "potential": [
+ 104100,
+ 104101,
+ 104102,
+ 104103,
+ 104104,
+ 104105
+ ],
+ "EN": "Soldier 11",
+ "skin": {
+ "3110410": {
+ "Name": "Soldier 11: Elite Soldier",
+ "Desc": "Those warm-toned goggles can't hide her icy, soldier's gaze. Want to see her crack a smile? Pick one: 87.5 one-arm push-ups or a bowl of extra-spicy noodles.",
+ "Image": "IconRole05"
+ }
+ },
+ "desc": "Soldier 11 is a member of the New Eridu Defense Force.\nShe currently serves as a direct attacker in Obol squad, Obsidian Division. Soldier 11 is a code name — she gave up her real name long ago.\n\nSoldier 11 is a fan of spicy food and likes the super-spicy noodles served at Waterfall Soup.\nIt's worth noting she currently calls us by a different name every time she greets us.",
+ "KO": "「11호」",
+ "CHS": "「11号」",
+ "JA": "「11号」"
+ },
+ "1451": {
+ "code": "Lucia",
+ "rank": 4,
+ "type": 4,
+ "element": 205,
+ "hit": 102,
+ "camp": 11,
+ "icon": "IconRole50",
+ "potential": [],
+ "EN": "Lucia",
+ "live2d": "UISpineLucia",
+ "skin": {
+ "3114510": {
+ "Name": "Lucia: Whispering Dreams",
+ "Desc": "Her cloak is the curtain of night, and beneath her hood rests the world's quiet whispers. She sings for the dusk and speaks in dreams, gathering the thoughts sleepers leave behind and weaving them into halos and riddles across the stars.",
+ "Image": "IconRole50"
+ }
+ },
+ "desc": "Lucia, a member of Spook Shack, goes by the online name \"Night Emissary.\" She's a backpacker, an Ethereal story enthusiast, and a connoisseur of late-night food stalls.\n\nRecords show that Lucia hails from a tribe living on the outskirts of the city. Legend has it their ancestors once encountered an Ethereal called the \"Night Horror\" (absent from the Hollow Investigative Association archives), which left them with a strange gift: Ethereals tend not to notice them. The cost, though, is that they dream of the Night Horror at night, and under its influence, they sleepwalk into Hollows, where corruption awaits.\nBecause of this, the tribe took on the role of Nightwatchers. They rest during the day and hunt deep in the night, avoiding the Night Horror's pull. And if anyone must sleep at night, their family will keep vigil at their side until dawn.\n\nLucia grew up surrounded by her kin, and even after arriving in New Eridu, she has kept to the Nightwatcher tradition of staying awake through the night and watching over her cherished friends, shielding them from the Night Horror's reach.\nMaster, if you hear a knock at your window late at night, don't be alarmed. It may just be a sleepless night guardian stopping by to say hello.\n\nAdditional note: Lucia's pet is \"Chestnut,\" a small, friendly Hati. It once lived in the Hollow's outskirts, but has since moved deeper inside. Even I can no longer detect its exact whereabouts.",
+ "KO": "루시아",
+ "CHS": "卢西娅",
+ "JA": "リュシア"
+ },
+ "1091": {
+ "code": "Miyabi",
+ "rank": 4,
+ "type": 3,
+ "element": 202,
+ "hit": 101,
+ "camp": 6,
+ "icon": "IconRole13",
+ "potential": [],
+ "EN": "Miyabi",
+ "spelement": "UI/Sprite/A1DynamicLoad/IconGeneralBuff/Packer/IconFrost.png",
+ "live2d": "UISpine_Xingjianya",
+ "skin": {
+ "3110910": {
+ "Name": "Hoshimi Miyabi: Frostgleam Dew",
+ "Desc": "A blade that fells the Hollows, a lighthouse that pierces the fog. People hope for a fighter who hones the world and a helmsman who steers the course; for flags that never fall and honor that lives on. Yet power is finite. Only training remains constant.",
+ "Image": "IconRole13"
+ }
+ },
+ "desc": "Hoshimi Miyabi, the Chief of Hollow Special Operations Section 6.\nMiyabi goes on a lot of field missions and still doesn't know which floor the regular monthly meeting is on.\nEven if you see Miyabi in the office, it's unlikely she's dealing with paperwork, and more likely polishing her sword.\n\nBeing conferred the title of Void Hunter due to her outstanding contributions, she's an elite amongst elites in Section 6.\nShe has great prestige amongst the citizens.\nOnce, Miyabi accidentally wandered into a \"Miyabi Fan Club\" hosted offline event.\nMiyabi was thought to be a fan with godlike impersonation skills, and that event resulted in a precious visual recording.\n\nHoshimi Miyabi is honest and pragmatic. She is not simply a martial artist obsessed with her art and without care for the world around her.\nAnyone who knows her will realize she's always pursued justice in her heart... even if it may eventually cause a revolution.",
+ "KO": "미야비",
+ "CHS": "雅",
+ "JA": "星見雅"
+ },
+ "1361": {
+ "code": "Trigger",
+ "rank": 4,
+ "type": 2,
+ "element": 203,
+ "hit": 103,
+ "camp": 5,
+ "icon": "IconRole39",
+ "potential": [],
+ "EN": "Trigger",
+ "live2d": "UIspineTrigger",
+ "skin": {
+ "3113610": {
+ "Name": "Trigger: Gaze Into the Light",
+ "Desc": "In the Hollows, her aim never wavers. She's a teammate one can rely on to cover their back without question. But once she returns to Sixth Street, that constant watchfulness softens, lingering for a moment before disappearing the instant it's sensed.",
+ "Image": "IconRole39"
+ }
+ },
+ "desc": "Trigger is a sniper of New Eridu Defense Force, Obsidian Division's Obol Squad. Trigger is the code name she was assigned when enlisting in Eridu, and she's used it ever since.\n\nShe has extremely unique eyes. While she cannot see most things, in Hollow environments, she can distinctly identify the position and changes of any entities that emit Ether fluctuations. Note: Such entities include living beings, native Ether substances, Ether products, and Ethereals.\n\nAs a visually challenged person in the broadest sense, Trigger has honed her other senses to the utmost, allowing her to move and act as if she could see. She can even detect subtle changes in her environment with precision. This makes her highly adept in the tactical position of a sniper, capable of handling a variety of covert tasks such as stealth, surveillance, and assassination.\n\nIn contrast to her precise and deadly combat abilities, her personality makes her seem rather unassuming. Aside from occasionally appearing silently in the video store and giving the Second Assistant a sudden scare, she can generally be considered harmless.",
+ "KO": "「트리거」",
+ "CHS": "「扳机」",
+ "JA": "「トリガー」"
+ },
+ "1431": {
+ "code": "Ye Shunguang",
+ "rank": 4,
+ "type": 1,
+ "element": 200,
+ "hit": 101,
+ "camp": 10,
+ "icon": "IconRole55",
+ "potential": [],
+ "EN": "Ye Shunguang",
+ "spelement": "UI/Sprite/A1DynamicLoad/IconGeneralBuff/Packer/IconHonedEdge.png",
+ "live2d": "UISpine_Yeshunguang",
+ "skin": {
+ "3114310": {
+ "Name": "Ye Shunguang: Clouddrift Illumination",
+ "Desc": "Cloud-woven silk, a blade's gleam reflected in its folds. The attire mirrors the person.\nBut who truly chose this dress? Was it her... or the deeper self within?",
+ "Image": "IconRole55"
+ },
+ "3114311": {
+ "Name": "Ye Shunguang: Touch of Dawnlight",
+ "Desc": "The gentle glow of sunlight and warm breeze seem to welcome your next encounter as you walk down the streets.",
+ "Image": "IconRole55_01"
+ }
+ },
+ "desc": "Ye Shunguang is the Sword Keeper of Yunkui Summit's treasured Qingming Sword, a disciple of Yixuan, and the younger sister of Ye Shiyuan, as well as a swordswoman blessed with extraordinary talent.\nShe enjoyed a happy childhood until the fall of the old capital, when her parents lost their lives rescuing others, bringing all her happiness to an abrupt end.\nFrom then on, she relied solely on her brother — up until Yixuan took them in, bringing them into Yunkui Summit. Later, she was chosen by the Qingming Sword, taking up the mantle and duty of Sword Keeper.\n\nRumor has it that the Qingming Sword's favor is less a blessing and more a kind of curse. Though it gives its Sword Keeper Void Hunter level power, it extracts a harsh cost — the loss of one's memories and five senses.\nIn order to subdue this side effect, Yixuan Shifu crafted a custom sword case for Ye Shunguang. It appears effective for the moment, but the true nature of the weapon is still shrouded in mystery.",
+ "KO": "엽빛나",
+ "CHS": "叶瞬光",
+ "JA": "葉瞬光"
+ },
+ "1121": {
+ "code": "Ben",
+ "rank": 3,
+ "type": 5,
+ "element": 201,
+ "hit": 102,
+ "camp": 3,
+ "icon": "IconRole16",
+ "potential": [],
+ "EN": "Ben",
+ "skin": {
+ "3111210": {
+ "Name": "Ben: Fluffy Beastking",
+ "Desc": "Ignoring the intimidating look meant to cow outsiders, even the Beast King struggles with grooming his fur and sorting out the bills.",
+ "Image": "IconRole16"
+ }
+ },
+ "desc": "Ben is the financial and asset management director at Belobog Heavy Industries and one of Koleda's trusted aides.\n\"I enjoy mathematics, but that doesn't stop me from giving thugs a good smack.\"\nHe's a burly and strong Thiren. Despite his tough exterior, he's of a surprisingly sensitive and detail-oriented nature, especially when it comes to numbers.\nOriginally a frontline mechanical operator at Belobog, his talents caught Koleda's discerning eye, leading to his promotion to a managerial position responsible for the company's finances.\nGrateful for Koleda's trust in him, he remains profoundly loyal to her.\nHis favorite food is black caviar, but he dislikes fish.\n\nI once witnessed this imposing figure seated in a small office chair, wearing small glasses, and carefully cross-referencing Belobog's asset records while mashing away at a calculator with his huge fingers.\nQuestion: Is that what they call on Inter-Knot a \"Kawaii Curveball?\"",
+ "KO": "벤",
+ "CHS": "本",
+ "JA": "ベン"
+ },
+ "1211": {
+ "code": "Rina",
+ "rank": 4,
+ "type": 4,
+ "element": 203,
+ "hit": 102,
+ "camp": 2,
+ "icon": "IconRole22",
+ "potential": [],
+ "EN": "Rina",
+ "skin": {
+ "3112110": {
+ "Name": "Rina: Head Maid's Perfection",
+ "Desc": "Her skirt is immaculate and her appearance flawlessly composed — such is the head maid's standard of perfection. Yet in the kitchen, her definition of \"perfect\" appears to be something else entirely...",
+ "Image": "IconRole22"
+ }
+ },
+ "desc": "Rina, the head maid of Victoria Housekeeping Co., and the most senior member of the organization.\nShe possesses beauty and grace, appearing immaculate from head to toe, with a noble demeanor and a gentle smile on her face.\nShe places considerable importance on how she is perceived by others.\n\nRina is always accompanied by two Bangboo named Anastella (brown hair) and Drusilla (blonde hair), both of whom have distinct personalities.\nDrusilla has been with Rina for a significant period of time and displays high intelligence, capable of discerning Rina's true emotions and thoughts.\nAnastella, a recent addition, usually responds rather than initiates conversation and is frequently teased by Drusilla.\n\nRina's favorite hobby is cooking, but only a few individuals are capable of enduring the dishes she prepares.\nSuggestion: You may try tasting Rina's dishes as a test to determine if you are the Chosen One.",
+ "KO": "리나",
+ "CHS": "丽娜",
+ "JA": "リナ"
+ },
+ "1051": {
+ "code": "Yidhari",
+ "rank": 4,
+ "type": 6,
+ "element": 202,
+ "hit": 102,
+ "camp": 11,
+ "icon": "IconRole52",
+ "potential": [],
+ "EN": "Yidhari",
+ "live2d": "UISpine_Yidhari",
+ "skin": {
+ "3110510": {
+ "Name": "Yidhari: Stream of Consciousness",
+ "Desc": "Past recollections and novel dreams spill over, inviting her to drown in them as they entwine on paper. With a blank page before her, she pens the opening line at the crossroads of reality and illusion.",
+ "Image": "IconRole52"
+ }
+ },
+ "desc": "In addition to her role as an Agent, Yidhari doubles as both a Proxy, and a contributor for Spook Shack.\nTo most, she comes across as laid-back and leisurely, but according to records, it's less \"laziness\" than a shroud of nothingness, leaving her at odds with the world around her.\nPerhaps to most people, this would seem like the usual quirk of someone absorbed in their craft. As a gifted writer of bizarre tales, Yidhari should be more prone than most to immersing herself in the worlds of her own imagination.\nIn truth, however, her unusual behavior stems more from the burden of her own abilities.\nIn the Hollows, she's exposed to vast torrents of information unimaginable to most, tangled with years upon years of memories that have amassed there. That crushing weight of countless pasts constantly batters her sense of reality, until even her emotions are gradually worn dull.\nThe fact that she can bear this burden, turn it into her own strength, and still live life in her own way — that's what sets Yidhari apart as gifted beyond others.",
+ "KO": "이드하리",
+ "CHS": "伊德海莉",
+ "JA": "イドリー"
+ },
+ "1411": {
+ "code": "Yuzuha",
+ "rank": 4,
+ "type": 4,
+ "element": 200,
+ "hit": 102,
+ "camp": 11,
+ "icon": "IconRole47",
+ "potential": [],
+ "EN": "Yuzuha",
+ "live2d": "UISpine_Youye",
+ "skin": {
+ "3114110": {
+ "Name": "Yuzuha: Tanuki Under the Shade",
+ "Desc": "Can you tell what the umbrella conceals? A tanuki, a trap, a ghost, or a girl's sly, spirited smile?",
+ "Image": "IconRole47"
+ },
+ "3114111": {
+ "Name": "Yuzuha: Tanuki in Broad Daylight",
+ "Desc": "Ghost stories aren't born only in shadows. Unconvinced? Just lock eyes with that playful young lady splashing in the water. She'll possess your thoughts, while Kama sneaks away with your drink.",
+ "Image": "IconRole47_01"
+ }
+ },
+ "desc": "Ukinami Yuzuha, a resident of Waifei Peninsula's Failume Heights, currently attends a low-tuition company affiliated school in the city. Aside from being part of the Inter-Knot subforum Spook Shack, she's not affiliated with any other groups — though in New Eridu, such forums aren't typically seen as formal organizations, and are, instead, more like hobby clubs.\nYuzuha is the adopted daughter of Li Baorong, owner of the Mystic Wares Porcelume store. Word around Failume Heights is that Baorong lost his wife and two daughters when the old capital fell. In the years that followed, he took in five children. Yuzuha is the second oldest, with an older brother, a younger brother, and two younger sisters. The family lived on the second floor above the store, and with just two bedrooms, Yuzuha shared one with her sisters, while the boys stayed with Baorong. Now that her older brother has moved out and she mostly stays in the school dorm, the apartment feels a bit less crowded. But on weekends and holidays, Yuzuha usually returns from school to help her father at the store.\nEven as a child, Yuzuha showed a surprising level of maturity. Her eloquence and cheerful nature have made her a favorite among neighbors — and a hit with the kids around Waifei Peninsula and Sailume Bay.\nOn the Spook Shack forum, Yuzuha goes by the username \"Yuzupepper.\" In addition to her real-life friends Manato and Alice, she is also close with two other users known as \"Night Emissary\" and \"Strawberry Parfait\" on the forums.",
+ "KO": "유즈하",
+ "CHS": "柚叶",
+ "JA": "浮波柚葉"
+ },
+ "1341": {
+ "code": "Zhao",
+ "rank": 4,
+ "type": 5,
+ "element": 202,
+ "hit": 101,
+ "camp": 12,
+ "icon": "IconRole56",
+ "potential": [],
+ "EN": "Zhao",
+ "live2d": "UISpinezhao",
+ "skin": {
+ "3113410": {
+ "Name": "Zhao: Cuddly Executor",
+ "Desc": "Warm, woolly ears and a small build may invite underestimation, but the Judge of \"Value\" won't think twice about bringing her branch down on anyone who tries.",
+ "Image": "IconRole56"
+ }
+ },
+ "desc": "Zhao, a Krampus Compliance Authority Judge, was one of the elite members who joined in the early days.\n\nThough she looks innocent and cute on the outside, she's actually sharp-minded, meticulous, and principled. She puts great importance on \"value\" and \"equivalent exchange\" — not in the sense of pricing things with money, but in analyzing someone's ability and potential, giving an objective and long-term assessment of the impact they can make.\n\nIt's said this mindset didn't come from Zhao's work at Krampus, but had already taken root during her childhood. She evaluates not only others, but herself as well. \"Only those with value deserve to be seen.\"\n\nThough she rarely shows it openly, Zhao remains cautious toward kindness that comes without reason. It conflicts with her belief in \"equivalent exchange,\" and she tends to assume the other person must have a deeper motive for offering help.\nAs such, when dealing with Zhao, it's best to focus on mutual benefit first. If you want to break through her defenses with sheer sincerity and goodwill, you'll be fighting an uphill battle.\n\nThere's also one piece of intel that can't be fully confirmed: Zhao loves vegetables and dislikes meat, but she seems to be pretending she enjoys meat as well.",
+ "KO": "자오",
+ "CHS": "照",
+ "JA": "照"
+ },
+ "1481": {
+ "code": "Dialyn",
+ "rank": 4,
+ "type": 2,
+ "element": 200,
+ "hit": 101,
+ "camp": 12,
+ "icon": "IconRole54",
+ "potential": [],
+ "EN": "Dialyn",
+ "live2d": "UISpine_Liuyin",
+ "skin": {
+ "3114810": {
+ "Name": "Dialyn: Ringing Judgment",
+ "Desc": "A sweet voice comes from the receiver, and when you lean in to listen, it softens into a gentle whisper, announcing that the verdict has arrived... just kidding, it's just a standard satisfaction survey.",
+ "Image": "IconRole54"
+ }
+ },
+ "desc": "Dialyn works as a customer service representative for TOPS' Cross-Department Customer Service Center, though few know her other identity — a Krampus Compliance Authority Judge.\n\nShe calls herself TOPS' top-rated representative, yet she also tops the list for the most complaints in the department. Despite never flaunting her authority as a Judge, she always delivers verdicts that cut closest to the truth.\n\nDialyn was born with the ability to hear the voices of those on the brink of death within the Hollows — a talent that's unmatched. Yet to a young Dialyn, it was a tormenting curse.\n\nBe it gift or curse, she has long turned it into a blade for discerning the truth. Despite the trials and tribulations she's borne, she's come far.",
+ "KO": "다이아린",
+ "CHS": "琉音",
+ "JA": "ダイアリン"
+ },
+ "1291": {
+ "code": "Hugo",
+ "rank": 4,
+ "type": 1,
+ "element": 202,
+ "hit": 101,
+ "camp": 9,
+ "icon": "IconRole42",
+ "potential": [],
+ "EN": "Hugo",
+ "live2d": "UISpine_Hugo",
+ "skin": {
+ "3112910": {
+ "Name": "Hugo: Crownless Rebellion",
+ "Desc": "Never be claimed by fate. Defy its clamps, its control, its taunts, its alms, its curses, and its so-called blessings. Once you return from the abyss, it is fate that should kneel to you.",
+ "Image": "IconRole42"
+ }
+ },
+ "desc": "Hugo calls himself a collector and runs a fairly successful gallery, mingling with the \"elite\" of New Eridu.\nIn truth, he's the leader of the phantom thieves syndicate, Mockingbird — and Lycaon's former partner.\nDespite his cruel (edgy and dramatic) words, deep down he has his own sense of fairness and justice.\n\nRecords suggest that Hugo had a troubled childhood.\nAs the illegitimate son of the Ravenlocks, a long-established TOPS family, he was rejected by his mother from an early age because of his heterochromatic eyes.\nHis father bought him back like a cheap commodity, using him to provoke a cutthroat succession battle between his children. In that brutal struggle, the only one kind to Hugo — his sister, Serena — became the first to fall. The ones who orchestrated her death pinned the blame on Hugo, thinking it would destroy him. But to their shock, their father actually praised Hugo's actions, calling them the very traits a worthy heir should possess.\nAfter running away from his family, Hugo met Lycaon and Jack and learned to be a phantom thief. After Jack passed away, Hugo and Lycaon formed Mockingbird together.\n\nLater, during one of Mockingbird's missions, Hugo came face-to-face with his father. In a brief moment of hesitation, he chose not to take his revenge and kill him — an act of mercy that ended up costing the lives of many innocent people. His close friend Lycaon, due to a misunderstanding born from those circumstances, broke ties with him and ultimately left Mockingbird.\nAfter drifting on his own for a while, Hugo met Vivian, a girl who reminded him of his sister Serena. He took her under his wing and reformed the Mockingbird syndicate.",
+ "KO": "휴고",
+ "CHS": "雨果",
+ "JA": "ヒューゴ"
+ },
+ "1351": {
+ "code": "Pulchra",
+ "rank": 3,
+ "type": 2,
+ "element": 200,
+ "hit": 101,
+ "camp": 4,
+ "icon": "IconRole38",
+ "potential": [],
+ "EN": "Pulchra",
+ "skin": {
+ "3113510": {
+ "Name": "Pulchra: Rest for a Weary Tail",
+ "Desc": "A wandering mercenary should be out roaming the world, but the sunlight here is warm — perfect for a nap.",
+ "Image": "IconRole38"
+ }
+ },
+ "desc": "Pulchra, formerly a mercenary from the Outer Ring, is now a member of the Sons of Calydon. She works as an ordinary employee at the logistics company Leaps and Bounds.\nWarning: Financial transactions have been detected between Pulchra and several biker and criminal gangs from the Outer Ring. These records have reduced since joining the Sons of Calydon, but there has been a large increase in funds transferred from New Eridu. This is likely because Pulchra's new position allows for easier access to New Eridu.\nFurther information indicates Pulchra has indirect involvement in several major events in the Outer Ring, including but not limited to: gang conflicts, leaks of crucial convoy supply intel, mysterious road damage, and price surges of essential goods...\nPulchra does not have deep connections with Lucius, and there is no current risk of her being an insider for any other biker gangs. However, her interpersonal network is highly complicated. Caution is advised when interacting with her, especially in protecting personal privacy.",
+ "KO": "펄크라",
+ "CHS": "波可娜",
+ "JA": "プルクラ"
+ },
+ "1311": {
+ "code": "Astra",
+ "rank": 4,
+ "type": 4,
+ "element": 205,
+ "hit": 102,
+ "camp": 8,
+ "icon": "IconRole36",
+ "potential": [],
+ "EN": "Astra Yao",
+ "live2d": "UISpineYaoJiayin",
+ "skin": {
+ "3113110": {
+ "Name": "Astra Yao: Scarlet Rock",
+ "Desc": "The bold red is only to ignite the freedom deep within. On stage, nothing can overshadow her brilliance.",
+ "Image": "IconRole36"
+ },
+ "3113111": {
+ "Name": "Astra Yao: Chandelier",
+ "Desc": "The black-and-white dress glimmers in both light and shadows. Every smile of hers seems to sink into the flowing light of the night, becoming a timeless classic.",
+ "Image": "IconRole36_01"
+ }
+ },
+ "desc": "Astra Yao is widely regarded as Ridu's most iconic singer. With her extraordinary voice, stage presence, and songwriting talent, she has led the musical trends of an entire era. \"If music is the resonance of the soul, then Astra Yao is the spark that ignites it all,\" said one famous music critic.\n\nAs brilliant as she is, Astra remains remarkably sincere and pure. On stage, her voice resonates deeply with the audience, which has allowed her to amass a large and loyal fanbase known as her \"Astranauts.\" Of course, Astra's brilliance wouldn't be possible without the careful protection of her manager, Evelyn. Offstage, she appears to be just a cute, hapless girl who depends on Evelyn in her daily life, and on Wise and Belle in the Hollows.\n\n\"If I tickled Eous, would it make you laugh?\" Astra asked curiously while lifting the Proxy, who was linked via the HDD system.\n\nShe seems to have a particular fondness for Eous.\nContemplation: Human emotions are indeed an interesting subject. It's still hard to determine whether Astra likes Eous because of the owner, or whether she likes the owner because of Eous.",
+ "KO": "아스트라",
+ "CHS": "耀嘉音",
+ "JA": "アストラ"
+ },
+ "1301": {
+ "code": "Orphie & Magus",
+ "rank": 4,
+ "type": 1,
+ "element": 201,
+ "hit": 103,
+ "camp": 5,
+ "icon": "IconRole49",
+ "potential": [],
+ "EN": "Orphie & Magus",
+ "live2d": "UISpine_Aofeisi",
+ "skin": {
+ "3113010": {
+ "Name": "Orphie & Magus: Two Banks of the River",
+ "Desc": "A soul of mirrored halves, having twice crossed the river of the dead. One side forged in fire, the other shaped into ice; one looking back on where it came from, the other watching for a new dawn. They wander on — never lost, never ceasing.",
+ "Image": "IconRole49"
+ }
+ },
+ "desc": "Orphie Magnusson serves in the New Eridu Defense Force's Obsidian Division, Obol Squad. She is the wielder of Magus, an Intelligent Construct in gun form.\n\nMagus is the captain of Obol Squad. She was gravely injured during the fall of the old capital and on the brink of death. After taking part in a military research project, her consciousness was transferred into a weapon, allowing her to survive as an Intelligent Construct. Since the squad's formation, she has remained its heart and soul — hot-tempered and impulsive at times, yet deeply respected and trusted by her comrades.\nOrphie shares an extraordinary bond with Magus — one that goes far beyond that of just \"partners.\" The two have known each other for years, and Magus often acts like a mother, offering guidance and care. In return, Orphie does everything she can to meet Magus' expectations, aspiring to become a strong, independent soldier like her captain.\n\nStrictly speaking, Orphie enjoys many things Magus disapproves of: fancy desserts, cute animals, and overly decorative toys. Her captain sees them as signs of softness, unfit for a soldier. Still, while Magus enforces discipline in public and imposes the expectations of a perfect soldier, she pretends to be in \"sleep mode\" just to turn a blind eye to Orphie's less-than-perfect behavior. According to this supposedly \"strict and impartial\" captain, she's simply following the parenting advice laid out in an online series titled \"Raising a Child Prodigy.\"",
+ "KO": "오피 & 「도깨비불」",
+ "CHS": "奥菲丝&「鬼火」",
+ "JA": "オルペウス&「鬼火」"
+ },
+ "1441": {
+ "code": "Manato",
+ "rank": 3,
+ "type": 6,
+ "element": 201,
+ "hit": 101,
+ "camp": 11,
+ "icon": "IconRole51",
+ "potential": [],
+ "EN": "Manato",
+ "skin": {
+ "3114410": {
+ "Name": "Komano Manato: Loyal Wild Soul",
+ "Desc": "The standard attire of the unofficial mediator in Sailume Bay, enough to intimidate petty thugs.\nStill, it sometimes draws wandering eyes.",
+ "Image": "IconRole51"
+ },
+ "3114411": {
+ "Name": "Komano Manato: White Heart Silhouette",
+ "Desc": "A young Manato made a solitary vow to himself — and from his deep reverie, a lone pale shadow was born.\nMimicking his attire enhances Manato's already exceedingly intimidating presence.",
+ "Image": "IconRole51_01"
+ }
+ },
+ "desc": "Komano Manato, a member of Spook Shack and an exceptionally reliable person. Having lost his family in the fall of the old capital, he grew up alone in Waifei Peninsula until the day he happened to adopt two street children, A-Cing and A-Yuet. Since then, the three have lived as a family, depending on one another. It may be a young household, but to Manato, nothing matters more than looking after them.\nManato is currently enrolled at a company affiliated school in the city, where rumors paint him as the terrifying \"school delinquent.\" The label likely comes from his intimidating appearance, though in reality, he's far more soft-hearted and generous than he seems.\nAt first, Manato really did want to do something to clear up the misunderstanding about being a delinquent, but for various reasons, his efforts didn't make much of an impact. Still, his optimism soon led him to realize that the title wasn't all bad. It let him dodge unnecessary social events, live without being bothered by strangers, and even leverage the \"delinquent\" image to sniff out stories of school ghost tales, all while standing up for classmates suffering from actual bullying.\nBut when it comes to grades, he really does live up to the \"school delinquent\" label of a company affiliated school student.",
+ "KO": "마나토",
+ "CHS": "真斗",
+ "JA": "狛野真斗"
+ },
+ "1151": {
+ "code": "Lucy",
+ "rank": 3,
+ "type": 4,
+ "element": 201,
+ "hit": 102,
+ "camp": 4,
+ "icon": "IconRole27",
+ "potential": [],
+ "EN": "Lucy",
+ "skin": {
+ "3111510": {
+ "Name": "Lucy: Ironfist Gentlewoman",
+ "Desc": "Her outfit is considered rather polished by biker standards and said to work wonders in negotiations with outsiders.\nThe runaway princess found her freedom in the Outer Ring, glittering under the starlit night, burning at the edge of dusk, and resting quietly among the grains of sand at her feet.",
+ "Image": "IconRole27"
+ }
+ },
+ "desc": "Lucy is currently in charge of outgoing business and managing boars for the Sons of Calydon.\nSince her name is far too long and she doesn't want to be involved with her family, she refers to herself as just \"Lucy\" in the Outer Ring.\nHer three boars are called Grassy, Woody, and Bricky, and they're unceasingly loyal to her.\nThough Lucy often speaks with a mixture of rough and elegant language, her actions subtly betray the marks of a proper education.\nLucy's family is incredibly wealthy. She grew up very privileged, with anything she could ever want within grasp... except for freedom.\nAfter her Ether Aptitude test results came back high, Lucy decided she wanted to be a Hollow investigator, but her family disapproved. She was met with harsh words, isolation and confinement, and emotional abuse which led to her deciding to leave to find her own freedom.\n\"Hmph! There's no chance I'll ever go back! If I'm found... then I can say goodbye to the stars and sky and I'll have to inherit the family business instead!\"\nLucy is extremely competitive. She loves to win and hates losing.\nCurrently, the one who has given her the most bitter taste of defeat is Caesar, so Lucy keeps challenging Caesar to duels, losing then challenging again, challenging then losing again.",
+ "KO": "루시",
+ "CHS": "露西",
+ "JA": "ルーシー"
+ },
+ "1101": {
+ "code": "Koleda",
+ "rank": 4,
+ "type": 2,
+ "element": 201,
+ "hit": 102,
+ "camp": 3,
+ "icon": "IconRole14",
+ "potential": [],
+ "EN": "Koleda",
+ "skin": {
+ "3111010": {
+ "Name": "Koleda: Heart & Hammer",
+ "Desc": "A crimson figure swings twin hammers as if she could even shatter time itself.\nStill young, her journey is only just beginning. Growing up can wait; in the end, every person becomes their own true self.",
+ "Image": "IconRole14"
+ }
+ },
+ "desc": "Koleda is the current head of Belobog Industries, and the biological daughter of the company's founder, Khors.\nHer father, Khors, vanished due to a scandal involving embezzlement and fled, which severely shook the company, nearly driving it to the brink of collapse. Once she came of age, Koleda willingly stepped up to untangle the mess, rallying the remaining company workforce and resources to revive the financially and reputationally vulnerable Belobog Heavy Industries.\nWith the help of new invaluable team members, Belobog eventually managed to regain its prominence in relevant sectors. Though not as resplendent as its prime, Belobog in its current state is still a notable newcomer in the field.\nThroughout her journey, Koleda amassed practical life experiences and professional know-how, building a circle of colleagues who back her up like family. Yet, to present herself as a leader, Koleda appears to intentionally adopt a stern tone when communicating with others.\nThis distinct growth trajectory has also contributed to Koleda's heightened maturity in certain aspects compared to her peers. \nNaturally, she might display a bit of childishness in certain aspects as well.\nHypothesis: Koleda's comparatively smaller stature among her peers may stem from her prolonged involvement in the Hollows. \nCurrently, there aren't any publicly accessible papers or research substantiating this theory, but I will continue to track this topic.",
+ "KO": "콜레다",
+ "CHS": "珂蕾妲",
+ "JA": "クレタ"
+ },
+ "1221": {
+ "code": "Yanagi",
+ "rank": 4,
+ "type": 3,
+ "element": 203,
+ "hit": 101,
+ "camp": 6,
+ "icon": "IconRole31",
+ "potential": [],
+ "EN": "Yanagi",
+ "live2d": "UISpineYanagi",
+ "skin": {
+ "3112210": {
+ "Name": "Tsukishiro Yanagi: Flowing Moonshade",
+ "Desc": "Even reflective lenses can't hide the sharp gleam in the Deputy Chief's eyes. She has to constantly keep watch over a warrior obsessed with training, a scout who slacks off whenever possible, and a little one who's forever getting stomachaches.",
+ "Image": "IconRole31"
+ }
+ },
+ "desc": "Tsukishiro Yanagi, Hollow Special Operations Section 6 Deputy Chief and intelligence officer.\nPrimarily responsible for personnel management, mission management and feedback, collecting intel, on-site support, etc.\nAt the same time, Yanagi is also Section 6 member Soukaku's Protector. Apart from taking care of Soukaku's day-to-day affairs, she is also responsible for and takes responsibility for all of Soukaku's behavior at work.\n\nSection 6 Chief Hoshimi Miyabi possesses a rather eccentric personality; Asaba Harumasa, a member of Section 6, is overly relaxed and carefree; and Soukaku, another member of Section, 6 is still an innocent and excitable child, so most of the team's work falls upon Yanagi's shoulders.\nSerious, diligent, and meticulous, Tsukishiro Yanagi is the only \"normal\" person on the team, so to speak. It is only due to her guidance and leadership that the strength of the three who make up Section 6's primary on-field force can be transformed into true combat power.\nOverall, Tsukishiro Yanagi is a talented and highly efficient executive officer, whose abilities are both exceptional and comprehensive.\nHowever, from certain data, Tsukishiro Yanagi appears to have a somewhat clumsy side to her when it comes to day-to-day affairs.",
+ "KO": "야나기",
+ "CHS": "柳",
+ "JA": "月城柳"
+ },
+ "1501": {
+ "code": "Aria",
+ "rank": 4,
+ "type": 3,
+ "element": 205,
+ "hit": 102,
+ "camp": 13,
+ "icon": "IconRole57",
+ "potential": [],
+ "EN": "Aria",
+ "live2d": "UISpineAria",
+ "skin": {
+ "3115010": {
+ "Name": "Aria: Energetic Idol",
+ "Desc": "...",
+ "Image": "IconRole57"
+ },
+ "3115011": {
+ "Name": "Aria: Discordant Note",
+ "Desc": "...",
+ "Image": "IconRole57_01"
+ }
+ },
+ "desc": "...",
+ "KO": "아리아",
+ "CHS": "爱芮",
+ "JA": "アリア"
+ },
+ "1271": {
+ "code": "Seth",
+ "rank": 3,
+ "type": 5,
+ "element": 203,
+ "hit": 101,
+ "camp": 7,
+ "icon": "IconRole30",
+ "potential": [],
+ "EN": "Seth",
+ "skin": {
+ "3112710": {
+ "Name": "Seth: Incorruptible Heart",
+ "Desc": "He may be slow to pick up on things, slower even than the weight of the shield in his left hand, but his ability to detect evil is keener than the baton in his right.",
+ "Image": "IconRole30"
+ }
+ },
+ "desc": "Seth, a rookie officer in the Public Security Criminal Investigation Special Response Team, is a key player in providing fire support during criminal investigations.\nHe graduated at the top of his class in the academy and could have landed a higher position, but he chose the tougher path of the Special Response Team.\nHis impressive profile makes him seem like a genius, but he's actually a hard worker who has had his own struggles. There have been times when he practiced the subjects he wasn't good at until he cried.\nSeth is quite smart, but his personality often leads to him being taken advantage of by cunning criminals, or sometimes, coworkers (?).\nThe subject he's worst at is infiltration. Rumor has it that he holds the academy record for \"fastest to get busted\" in infiltration tasks. Based on the video intel we have, he seems a bit slow to catch on. For instance, when someone is being sarcastic, it takes him a long time to realize it.",
+ "KO": "세스",
+ "CHS": "赛斯",
+ "JA": "セス"
+ },
+ "1171": {
+ "code": "Burnice",
+ "rank": 4,
+ "type": 3,
+ "element": 201,
+ "hit": 103,
+ "camp": 4,
+ "icon": "IconRole32",
+ "potential": [
+ 117100,
+ 117101,
+ 117102,
+ 117103,
+ 117104,
+ 117105
+ ],
+ "EN": "Burnice",
+ "live2d": "UISpine_Burnice",
+ "skin": {
+ "3111710": {
+ "Name": "Burnice: Wildfire Rave",
+ "Desc": "An outfit hotter than any Nitro-Fuel. Wander into a party in the Outer Ring, and odds are you'll bump into Burnice... but drink her signature cocktails at your own risk...\nWait, you've already taken a sip? Well then — 3, 2, 1... fire!",
+ "Image": "IconRole32"
+ }
+ },
+ "desc": "Burnice, the Nitro-Fuel bartender for the Sons of Calydon.\nA hopeless fuel lover. She is responsible for all the fuel used by the machinery owned by the Sons of Calydon.\nShe seems to have a passion for fuel that overwhelms all else.\n\nBurnice is super outgoing, friendly, and carefree.\nShe is easily able to spread her emotions to the people around her, bringing them the same optimism she possesses.\n\"You like the drinks I mix? Thank you! Steeltusk does, too~ That big guy can drink two hundred liters in one go!\"\n\"Do you wanna try its special fuel blend? It's suuuuper hot!\"",
+ "KO": "버니스",
+ "CHS": "柏妮思",
+ "JA": "バーニス"
+ },
+ "1381": {
+ "code": "Soldier 0 - Anby",
+ "rank": 4,
+ "type": 1,
+ "element": 203,
+ "hit": 101,
+ "camp": 5,
+ "icon": "IconRole40",
+ "potential": [
+ 138100,
+ 138101,
+ 138102,
+ 138103,
+ 138104,
+ 138105
+ ],
+ "EN": "Soldier 0 - Anby",
+ "live2d": "UISpineSPAnbi",
+ "skin": {
+ "3113810": {
+ "Name": "Soldier 0 - Anby: Silver Soldier",
+ "Desc": "Anby's old Silver Squad uniform, stained with memories that never truly fade.\nWhen the shadows of the past strike without warning, would you stand and face them, or stumble and flee? No matter the choice, home always awaits your return.",
+ "Image": "IconRole40"
+ }
+ },
+ "desc": "",
+ "KO": "0호·엔비",
+ "CHS": "零号·安比",
+ "JA": "0号・アンビー"
+ },
+ "1191": {
+ "code": "Ellen",
+ "rank": 4,
+ "type": 1,
+ "element": 202,
+ "hit": 101,
+ "camp": 2,
+ "icon": "IconRole21",
+ "potential": [
+ 119100,
+ 119101,
+ 119102,
+ 119103,
+ 119104,
+ 119105
+ ],
+ "EN": "Ellen",
+ "live2d": "UISpineEllen",
+ "skin": {
+ "3111910": {
+ "Name": "Ellen: Ellen Scissorhands",
+ "Desc": "The sharp scissors of Victoria Housekeeping, if only she weren't sleeping...",
+ "Image": "IconRole21"
+ },
+ "3111911": {
+ "Name": "Ellen: On Campus",
+ "Desc": "The tail doesn't quite match the school uniform, but with just a little bit of needlework, it can become even cuter!",
+ "Image": "IconRole21_01"
+ }
+ },
+ "desc": "Ellen is a maid working for Victoria Housekeeping Co. and its latest member.\nShe is a laid-back individual who dislikes activities that require energy. However, with the full support of her teammates, she can unleash her formidable power during critical moments.\nSince her fighting style leads to high energy consumption, she always carries a lollipop in her mouth to ensure an adequate sugar intake.\nWhile she frequently expresses her desire to change jobs due to the hassle, she genuinely values her companions at Victoria Housekeeping.\n\nEllen attends a school in New Eridu and has a group of ordinary friends.\nApart from her role as a maid at Victoria Housekeeping, Ellen spends a significant amount of time with her friends, leading an ordinary and contented student life.\n\nThoughts: As a multitasker, Ellen must balance her student life with her job at Victoria Housekeeping Co.\nPerhaps this is one of the reasons why she often feels tired?",
+ "KO": "엘렌",
+ "CHS": "艾莲",
+ "JA": "エレン"
+ }
+ },
+ "weapon": {
+ "13108": {
+ "icon": "Weapon_A_1081",
+ "rank": 3,
+ "type": 1,
+ "EN": "Starlight Engine Replica",
+ "desc": "A customized supercomputing W-Engine, specialized in aim support and ballistic calculations. Modified by Billy, it now looks like some sort of knock-off Starlight Knight figurine.",
+ "KO": "별빛 엔진 레플리카",
+ "CHS": "仿制星徽引擎",
+ "JA": "なんちゃってスターライトエンジン"
+ },
+ "14104": {
+ "icon": "Weapon_S_1041",
+ "rank": 4,
+ "type": 1,
+ "EN": "The Brimstone",
+ "desc": "A W-Engine made for Soldier 11 by Obsidian Division. Its intense heat can be used to clear the entire battlefield.",
+ "KO": "유황석",
+ "CHS": "硫磺石",
+ "JA": "ブリムストーン"
+ },
+ "14143": {
+ "icon": "Weapon_S_1431",
+ "rank": 4,
+ "type": 1,
+ "EN": "Cloudcleave Radiance",
+ "desc": "The world may change, but my heart remains constant, like clouds standing eternal.",
+ "KO": "구름을 헤친 빛",
+ "CHS": "云霓孤光",
+ "JA": "孤光彩雲"
+ },
+ "12009": {
+ "icon": "Weapon_B_Common_09",
+ "rank": 2,
+ "type": 2,
+ "EN": "[Vortex] Hatchet",
+ "desc": "A frequency-converting W-Engine that can quickly generate excessive power and effectively increase its user's battle prowess.",
+ "KO": "「급류」-도끼",
+ "CHS": "「湍流」-斧型",
+ "JA": "「激流」-斧型"
+ },
+ "14107": {
+ "icon": "Weapon_S_1071",
+ "rank": 4,
+ "type": 5,
+ "EN": "Tusks of Fury",
+ "desc": "A versatile W-Engine based on a motorcycle tire that Big Daddy made for Caesar.",
+ "KO": "저돌적인 송곳니",
+ "CHS": "奔袭獠牙",
+ "JA": "猛進するキバ"
+ },
+ "13103": {
+ "icon": "Weapon_A_1031",
+ "rank": 3,
+ "type": 4,
+ "EN": "The Vault",
+ "desc": "A cost-effective W-Engine that can store energy, with improvements made to its internal space. This model is often used by Nicole.",
+ "KO": "보물함",
+ "CHS": "聚宝箱",
+ "JA": "ザ・ボールト"
+ },
+ "14137": {
+ "icon": "Weapon_S_1371",
+ "rank": 4,
+ "type": 6,
+ "EN": "Qingming Birdcage",
+ "desc": "The world is vast and full of change, and fate is unpredictable. But regardless of hardship or lack thereof, one must stay true to one's heart.",
+ "KO": "청명의 보금자리",
+ "CHS": "青溟笼舍",
+ "JA": "青溟の鳥籠"
+ },
+ "13002": {
+ "icon": "Weapon_A_Common_02",
+ "rank": 3,
+ "type": 4,
+ "EN": "Slice of Time",
+ "desc": "A special W-Engine equipped with a high-speed camera module. The best choice for in-Hollow photography enthusiasts.",
+ "KO": "시간의 파편",
+ "CHS": "时光切片",
+ "JA": "歳月の薄片"
+ },
+ "13113": {
+ "icon": "Weapon_A_1131",
+ "rank": 3,
+ "type": 4,
+ "EN": "Bashful Demon",
+ "desc": "An intricate W-Engine developed by Section 6 and later modified with extra features. It can control Ether particles to form an energy field. This model is favored by Soukaku.",
+ "KO": "수줍은 악마",
+ "CHS": "含羞恶面",
+ "JA": "恥じらう悪面"
+ },
+ "13142": {
+ "icon": "Weapon_A_1421",
+ "rank": 3,
+ "type": 5,
+ "EN": "Tremor Trigram Vessel",
+ "desc": "Pressing acupoints is like cooking. Striking the fatal acupoint is like setting the stove on fire, bringing disaster.",
+ "KO": "진괘를 담은 함",
+ "CHS": "震元奇枢",
+ "JA": "雷鳴が如き八卦"
+ },
+ "12003": {
+ "icon": "Weapon_B_Common_03",
+ "rank": 2,
+ "type": 1,
+ "EN": "[Lunar] Noviluna",
+ "desc": "A high-capacity portable W-Engine that can collect dissipated energy from its surroundings, thus enhancing the combat effectiveness of its operator.",
+ "KO": "「루나」-초승달",
+ "CHS": "「月相」-朔",
+ "JA": "「月相」-朔"
+ },
+ "12002": {
+ "icon": "Weapon_B_Common_02",
+ "rank": 2,
+ "type": 1,
+ "EN": "[Lunar] Decrescent",
+ "desc": "A W-Engine with excellent sonic and thermal energy usage, capable of hitting its target with a double whammy.",
+ "KO": "「루나」-그믐달",
+ "CHS": "「月相」-晦",
+ "JA": "「月相」-晦"
+ },
+ "13135": {
+ "icon": "Weapon_A_1351",
+ "rank": 3,
+ "type": 2,
+ "EN": "Box Cutter",
+ "desc": "An energy W-Engine modded from spare parts of a truck's engine. It converts the original cylinder into an energy storage unit, which is highly advantageous.",
+ "KO": "커터칼",
+ "CHS": "裁纸刀",
+ "JA": "ペーパーカッター"
+ },
+ "14133": {
+ "icon": "Weapon_S_1331",
+ "rank": 4,
+ "type": 3,
+ "EN": "Flight of Fancy",
+ "desc": "A bird landed on the windowsill, scattering starlight from its tail feathers.",
+ "KO": "별빛 꿈을 누비는 새",
+ "CHS": "飞鸟星梦",
+ "JA": "鳥は夢へと羽ばたいて"
+ },
+ "13014": {
+ "icon": "Weapon_A_Common_14",
+ "rank": 3,
+ "type": 6,
+ "EN": "Radiowave Journey",
+ "desc": "Designed for those Hollow-dwelling bards who refuse to listen to the same albums repeatedly, this W-Engine comes equipped with a radio module that can transform Etheric frequencies into melodies.",
+ "KO": "일렉트로 워크",
+ "CHS": "电波漫步",
+ "JA": "エレクトロウォーク"
+ },
+ "13008": {
+ "icon": "Weapon_A_Common_08",
+ "rank": 3,
+ "type": 3,
+ "EN": "Weeping Gemini",
+ "desc": "A W-Engine produced by recycling abandoned Bangboo shells. Its electrode cores made of recycled metals can release high-voltage charges.",
+ "KO": "쌍둥이의 눈물",
+ "CHS": "双生泣星",
+ "JA": "双生の涙"
+ },
+ "14001": {
+ "icon": "Weapon_S_Common_01",
+ "rank": 3,
+ "type": 1,
+ "EN": "Cannon Rotor",
+ "desc": "A high-performance supercomputing W-Engine, capable of collecting real-time battlefield data and equipped with mini cannons.",
+ "KO": "캐논 로터",
+ "CHS": "加农转子",
+ "JA": "キャノンローラー"
+ },
+ "13003": {
+ "icon": "Weapon_A_Common_03",
+ "rank": 3,
+ "type": 3,
+ "EN": "Rainforest Gourmet",
+ "desc": "A consumer-class W-Engine launched by an exotic pet fan club to promote pet culture. Due to its cute appearance, it was once a very popular model.",
+ "KO": "우림의 식객",
+ "CHS": "雨林饕客",
+ "JA": "密林の食いしん坊"
+ },
+ "13012": {
+ "icon": "Weapon_A_Common_12",
+ "rank": 3,
+ "type": 6,
+ "EN": "Puzzle Sphere",
+ "desc": "The W-Engine, with its bright colors and great performance, saw poor sales at first because it resembled a toy. But with its reasonable price, it slowly gained a positive reputation.",
+ "KO": "기변의 큐브",
+ "CHS": "幻变魔方",
+ "JA": "魔法の立体パズル"
+ },
+ "14130": {
+ "icon": "Weapon_S_1301",
+ "rank": 4,
+ "type": 1,
+ "EN": "Bellicose Blaze",
+ "desc": "She lingers by the banks of the Styx, waiting for the tide to extinguish the fury burning in her chamber.",
+ "KO": "소란한 총성과 화염",
+ "CHS": "嚣枪喧焰",
+ "JA": "憤怒の銃騒"
+ },
+ "14105": {
+ "icon": "Weapon_S_1051",
+ "rank": 4,
+ "type": 6,
+ "EN": "Kraken's Cradle",
+ "desc": "In chasing illusions, she was entranced by the light that pierced through the mist.",
+ "KO": "크라켄의 요람",
+ "CHS": "海妖摇篮",
+ "JA": "セイレーンクレードル"
+ },
+ "13010": {
+ "icon": "Weapon_A_Common_10",
+ "rank": 3,
+ "type": 5,
+ "EN": "Bunny Band",
+ "desc": "A special W-Engine decorated with a fluffy bunny. However, it is just an imitation of the real animal.",
+ "KO": "버니 밴드",
+ "CHS": "兔能环",
+ "JA": "ラビットチャージャー"
+ },
+ "14116": {
+ "icon": "Weapon_S_1161",
+ "rank": 4,
+ "type": 2,
+ "EN": "Blazing Laurel",
+ "desc": "A violent W-Engine that can ignite flames with a siphon. It deals simple and crude burn damage, encouraging relentless, bone-crushing follow-up attacks.",
+ "KO": "화염의 월계관",
+ "CHS": "焰心桂冠",
+ "JA": "炎心の桂冠"
+ },
+ "13011": {
+ "icon": "Weapon_A_Common_11",
+ "rank": 3,
+ "type": 5,
+ "EN": "Spring Embrace",
+ "desc": "A special W-Engine equipped with a temperature regulator where residual heat generated by the W-Engine's high frequency is funneled into the hot springs on its surface.",
+ "KO": "봄날의 포옹",
+ "CHS": "春日融融",
+ "JA": "ホットスプリング"
+ },
+ "14102": {
+ "icon": "Weapon_S_1021",
+ "rank": 4,
+ "type": 1,
+ "EN": "Steel Cushion",
+ "desc": "A supercomputing W-Engine equipped with a motion monitoring feature. Thanks to Nekomata's modifications, it perfectly matches the fast reflexes and combat maneuvers of feline Thirens.",
+ "KO": "스틸 쿠션",
+ "CHS": "钢铁肉垫",
+ "JA": "鋼の肉球"
+ },
+ "13015": {
+ "icon": "Weapon_A_Common_15",
+ "rank": 3,
+ "type": 1,
+ "EN": "Marcato Desire",
+ "desc": "Gather 'round, mortals! Turn up the music!",
+ "KO": "열망의 악센트",
+ "CHS": "强音热望",
+ "JA": "強音デザイア"
+ },
+ "14122": {
+ "icon": "Weapon_S_1221",
+ "rank": 4,
+ "type": 3,
+ "EN": "Timeweaver",
+ "desc": "A new model of supercomputing W-Engine custom-made by HSO Section 6, equipped with the most elite calculation and analysis functions.",
+ "KO": "시류의 현자",
+ "CHS": "时流贤者",
+ "JA": "刻流の賢者"
+ },
+ "14120": {
+ "icon": "Weapon_S_1201",
+ "rank": 4,
+ "type": 1,
+ "EN": "Zanshin Herb Case",
+ "desc": "Bitterness and pain beget hope, and he devours them all.",
+ "KO": "잔심의 청낭",
+ "CHS": "残心青囊",
+ "JA": "残心の青籠"
+ },
+ "14149": {
+ "icon": "Weapon_S_1491",
+ "rank": 4,
+ "type": 4,
+ "EN": "Thoughtbop",
+ "desc": "...",
+ "KO": "사유로 빚은 노래",
+ "CHS": "思络成歌",
+ "JA": "想いが織りなす歌"
+ },
+ "12015": {
+ "icon": "Weapon_B_Common_15",
+ "rank": 2,
+ "type": 6,
+ "EN": "[Cinder] Cobalt",
+ "desc": "A W-Engine with a built-in heating component. When operating at high speeds, it resembles a blazing blue flame.",
+ "KO": "「잿더미」-코발트블루",
+ "CHS": "「灰烬」-钴蓝",
+ "JA": "「灰燼」-蒼藍"
+ },
+ "14114": {
+ "icon": "Weapon_S_1141",
+ "rank": 4,
+ "type": 2,
+ "EN": "The Restrained",
+ "desc": "A special W-Engine with a powerful temperature control system. Modified by Lycaon, it has the capability to quickly create extremely cold environments.",
+ "KO": "구속된 자",
+ "CHS": "拘缚者",
+ "JA": "拘縛されし者"
+ },
+ "14118": {
+ "icon": "Weapon_S_1181",
+ "rank": 4,
+ "type": 3,
+ "EN": "Fusion Compiler",
+ "desc": "The latest W-Engine that possesses a hypercalculation core. It can compile operational codes for machines at top speed. This model is favored by Grace.",
+ "KO": "감입 컴파일러",
+ "CHS": "嵌合编译器",
+ "JA": "複合コンパイラ"
+ },
+ "12011": {
+ "icon": "Weapon_B_Common_11",
+ "rank": 2,
+ "type": 3,
+ "EN": "[Magnetic Storm] Bravo",
+ "desc": "A damage-type W-Engine that features an enhanced movement detection module that can quickly alter its output intensity to optimize battle efficiency.",
+ "KO": "「자기 폭풍」-브라보",
+ "CHS": "「电磁暴」-贰式",
+ "JA": "「磁気嵐」-弐式"
+ },
+ "14129": {
+ "icon": "Weapon_S_1291",
+ "rank": 4,
+ "type": 1,
+ "EN": "Myriad Eclipse",
+ "desc": "Shadowed by darkness at every step, he laid bare his heart to kindle a single, flickering flame.",
+ "KO": "천변하는 태양의 몰락",
+ "CHS": "千面日陨",
+ "JA": "千面の落日"
+ },
+ "14134": {
+ "icon": "Weapon_S_1341",
+ "rank": 4,
+ "type": 5,
+ "EN": "Half-Sugar Bunny",
+ "desc": "Not too sweet, and not too cold.",
+ "KO": "당도 50% 눈토끼",
+ "CHS": "半糖雪兔",
+ "JA": "甘さ控えめ雪うさぎ"
+ },
+ "14145": {
+ "icon": "Weapon_S_1451",
+ "rank": 4,
+ "type": 4,
+ "EN": "Dreamlit Hearth",
+ "desc": "After countless years, it remains a silent puzzle.",
+ "KO": "꿈을 빚는 용광로의 노래",
+ "CHS": "铸梦炉歌",
+ "JA": "炉で歌い上げられる夢"
+ },
+ "14138": {
+ "icon": "Weapon_S_1381",
+ "rank": 4,
+ "type": 1,
+ "EN": "Severed Innocence",
+ "desc": "The sheen of silver is not innocence. Can the blades of the past cut through fate's connections?",
+ "KO": "순결한 희생",
+ "CHS": "牺牲洁纯",
+ "JA": "純然たる犠牲"
+ },
+ "14146": {
+ "icon": "Weapon_S_1461",
+ "rank": 4,
+ "type": 1,
+ "EN": "Cordis Germina",
+ "desc": "Her journey continues along the long, endless circle.",
+ "KO": "기계 심장에 내린 씨앗",
+ "CHS": "机巧心种",
+ "JA": "駆動する種"
+ },
+ "14136": {
+ "icon": "Weapon_S_1361",
+ "rank": 4,
+ "type": 2,
+ "EN": "Spectral Gaze",
+ "desc": "Broken eyes hold both resilience and vulnerability.",
+ "KO": "탐혼의 눈동자",
+ "CHS": "索魂影眸",
+ "JA": "奪魂の瞑目"
+ },
+ "13007": {
+ "icon": "Weapon_A_Common_07",
+ "rank": 3,
+ "type": 5,
+ "EN": "Original Transmorpher",
+ "desc": "An epic W-Engine favored by Starlight Knight fans. Its high popularity and scarcity marketing strategy have caused an influx of knock-offs.",
+ "KO": "오리지널 변신 아이템",
+ "CHS": "正版变身器",
+ "JA": "正規版変身装置"
+ },
+ "12005": {
+ "icon": "Weapon_B_Common_05",
+ "rank": 2,
+ "type": 4,
+ "EN": "[Reverb] Mark II",
+ "desc": "A standardized W-Engine with balanced performance that enhances its owner's and their teammates' combat effectiveness in all aspects.",
+ "KO": "「잔향」-Ⅱ형",
+ "CHS": "「残响」-Ⅱ型",
+ "JA": "「残響」-Ⅱ型"
+ },
+ "13112": {
+ "icon": "Weapon_A_1121",
+ "rank": 3,
+ "type": 5,
+ "EN": "Big Cylinder",
+ "desc": "A powerful protection-type W-Engine, characterized by thick oil cylinders that ceaselessly roar inside it. It can provide sufficient power for heavy machinery.",
+ "KO": "빅 실린더",
+ "CHS": "比格气缸",
+ "JA": "ビガー・シリンダー"
+ },
+ "14147": {
+ "icon": "Weapon_S_1471",
+ "rank": 4,
+ "type": 6,
+ "EN": "Wrathful Vajra",
+ "desc": "The purging flames burned a hundred times, yet never cleansed me.",
+ "KO": "성난 눈의 금강",
+ "CHS": "怒目金刚",
+ "JA": "金剛不壊怒髪衝冠"
+ },
+ "12007": {
+ "icon": "Weapon_B_Common_07",
+ "rank": 2,
+ "type": 2,
+ "EN": "[Vortex] Revolver",
+ "desc": "An energy-storage type W-Engine with a unique operation circuit. It will absorb scattered energy up to a certain amount before unleashing it all at once.",
+ "KO": "「급류」-총",
+ "CHS": "「湍流」-铳型",
+ "JA": "「激流」-銃型"
+ },
+ "14119": {
+ "icon": "Weapon_S_1191",
+ "rank": 4,
+ "type": 1,
+ "EN": "Deep Sea Visitor",
+ "desc": "A high-power W-Engine that possesses efficient freezing capabilities and high damage output. This model is often used by Ellen.",
+ "KO": "심해 방문객",
+ "CHS": "深海访客",
+ "JA": "ディープシー・ビジター"
+ },
+ "14139": {
+ "icon": "Weapon_S_1391",
+ "rank": 4,
+ "type": 2,
+ "EN": "Roaring Fur-nace",
+ "desc": "The warm fire blazes through the long night, casting light on the tiger's roar in my heart.",
+ "KO": "복을 뿜는 맹호",
+ "CHS": "福虓炉炉",
+ "JA": "招福の虎炉"
+ },
+ "14132": {
+ "icon": "Weapon_S_1321",
+ "rank": 4,
+ "type": 1,
+ "EN": "Heartstring Nocturne",
+ "desc": "The sound of the strings, sharp as blades, cut through the night, protecting the one.",
+ "KO": "심금을 울리는 야상곡",
+ "CHS": "心弦夜响",
+ "JA": "心弦のノクターン"
+ },
+ "14117": {
+ "icon": "Weapon_S_1171",
+ "rank": 4,
+ "type": 3,
+ "EN": "Flamemaker Shaker",
+ "desc": "Modified by Burnice, this W-Engine uses a special fuel as its power source to boost combustion performance to the max.",
+ "KO": "타오르는 셰이커",
+ "CHS": "灼心摇壶",
+ "JA": "バーニング・シェイカー"
+ },
+ "14150": {
+ "icon": "Weapon_S_1501",
+ "rank": 4,
+ "type": 3,
+ "EN": "Angel in the Shell",
+ "desc": "...",
+ "KO": "껍데기 속 영혼",
+ "CHS": "壳中之灵",
+ "JA": "殻の中の魂"
+ },
+ "14124": {
+ "icon": "Weapon_S_1241",
+ "rank": 4,
+ "type": 1,
+ "EN": "Riot Suppressor Mark VI",
+ "desc": "The latest tactical W-Engine used by Public Security's elite Hollow squads, possessing a powerful Ether energy source. It's Zhu Yuan's favored model.",
+ "KO": "서프레서 Ⅵ형",
+ "CHS": "防暴者Ⅵ型",
+ "JA": "サプレッサーⅥ型"
+ },
+ "14141": {
+ "icon": "Weapon_S_1411",
+ "rank": 4,
+ "type": 4,
+ "EN": "Metanukimorphosis",
+ "desc": "A masterfully crafted design, infused with the creator's pursuit of beauty.",
+ "KO": "너구리의 7단 변신",
+ "CHS": "狸法七变化",
+ "JA": "狸の七変化"
+ },
+ "13101": {
+ "icon": "Weapon_A_1011",
+ "rank": 3,
+ "type": 2,
+ "EN": "Demara Battery Mark II",
+ "desc": "A customized W-Engine that focuses on energy storage, modified to improve its energy capacity. This model is often used by Anby.",
+ "KO": "데마라 배터리 Ⅱ형",
+ "CHS": "德玛拉电池Ⅱ型",
+ "JA": "デマラ式電池Ⅱ型"
+ },
+ "14110": {
+ "icon": "Weapon_S_1101",
+ "rank": 4,
+ "type": 2,
+ "EN": "Hellfire Gears",
+ "desc": "A functional W-Engine with ultra-high power capacity. Modified by Koleda, its energy level has almost exceeded the safety limit.",
+ "KO": "헬파이어 기어",
+ "CHS": "燃狱齿轮",
+ "JA": "燃獄ギア"
+ },
+ "12013": {
+ "icon": "Weapon_B_Common_13",
+ "rank": 2,
+ "type": 5,
+ "EN": "[Identity] Base",
+ "desc": "A W-Engine with a component structure adjusted according to specific parameters. It can enhance the defensive capabilities of those who have a certain style of combat.",
+ "KO": "「아이덴티티」-베이스",
+ "CHS": "「恒等式」-本格",
+ "JA": "「恒等式」-本格"
+ },
+ "12006": {
+ "icon": "Weapon_B_Common_06",
+ "rank": 2,
+ "type": 4,
+ "EN": "[Reverb] Mark III",
+ "desc": "A special W-Engine that features motion capture algorithms, which enhance the overall combat competency of your squad.",
+ "KO": "「잔향」-Ⅲ형",
+ "CHS": "「残响」-Ⅲ型",
+ "JA": "「残響」-Ⅲ型"
+ },
+ "14109": {
+ "icon": "Weapon_S_1091",
+ "rank": 4,
+ "type": 3,
+ "EN": "Hailstorm Shrine",
+ "desc": "Hail falls before the shrine, waking her. The blizzard is coming.",
+ "KO": "싸락눈 내린 별각",
+ "CHS": "霰落星殿",
+ "JA": "あられ落つ星殿"
+ },
+ "13004": {
+ "icon": "Weapon_A_Common_04",
+ "rank": 3,
+ "type": 1,
+ "EN": "Starlight Engine",
+ "desc": "A damage-type W-Engine made of special Etheric matter. When its user is attacked, it builds up energy.",
+ "KO": "별빛 엔진",
+ "CHS": "星徽引擎",
+ "JA": "スターライトエンジン"
+ },
+ "13016": {
+ "icon": "Weapon_A_Common_16",
+ "rank": 3,
+ "type": 5,
+ "EN": "Reel Projector",
+ "desc": "Clearly a W-Engine is for sound, and not images. What kind of lunatic genius would insist on stuffing a 16mm film camera into a W-Engine?",
+ "KO": "조명을 새기는 칼",
+ "CHS": "光影刻刀",
+ "JA": "光と影のカット"
+ },
+ "12014": {
+ "icon": "Weapon_B_Common_14",
+ "rank": 2,
+ "type": 5,
+ "EN": "[Identity] Inflection",
+ "desc": "A functional W-Engine that specializes in energy conversion. It can analyze combat environments and generate special sounds to disrupt the enemy's offense.",
+ "KO": "「아이덴티티」-인플렉션",
+ "CHS": "「恒等式」-变格",
+ "JA": "「恒等式」-変格"
+ },
+ "13005": {
+ "icon": "Weapon_A_Common_05",
+ "rank": 3,
+ "type": 2,
+ "EN": "Steam Oven",
+ "desc": "A high-power W-Engine that boasts a cutting-edge energy conversion system. It collects excess heat and supplies it to the steamer.",
+ "KO": "스팀오븐",
+ "CHS": "人为刀俎",
+ "JA": "まな板の鯉"
+ },
+ "14131": {
+ "icon": "Weapon_S_1311",
+ "rank": 4,
+ "type": 4,
+ "EN": "Elegant Vanity",
+ "desc": "Underneath the radiant splendor, her genuine self has never been hidden.",
+ "KO": "우아한 베니티백",
+ "CHS": "玲珑妆匣",
+ "JA": "優美のヴァニティ"
+ },
+ "13127": {
+ "icon": "Weapon_A_1271",
+ "rank": 3,
+ "type": 5,
+ "EN": "Peacekeeper - Specialized",
+ "desc": "A defensive W-Engine independently researched and created by Public Security which simplifies the offensive components to enhance the energy conversion ability in its shield. ",
+ "KO": "평화 수호자-특화형",
+ "CHS": "维序者-特化型",
+ "JA": "秩序の守り手・特化型"
+ },
+ "13111": {
+ "icon": "Weapon_A_1111",
+ "rank": 3,
+ "type": 1,
+ "EN": "Drill Rig - Red Axis",
+ "desc": "Originally a rev-enhanced W-Engine, it has been modified by Anton with the most expensive rotary drill parts that allow it to exceed its output limits.",
+ "KO": "굴착기-붉은 축",
+ "CHS": "旋钻机-赤轴",
+ "JA": "ドリルリグ-レッドシャフト"
+ },
+ "13019": {
+ "icon": "Weapon_A_Common_19",
+ "rank": 3,
+ "type": 6,
+ "EN": "Cauldron of Clarity",
+ "desc": "Countless threads of Sword Will echo through this tiny stretch of sky.",
+ "KO": "푸른 물결의 솥",
+ "CHS": "青漪灵鼎",
+ "JA": "蒼き波の霊器"
+ },
+ "14121": {
+ "icon": "Weapon_S_1211",
+ "rank": 4,
+ "type": 4,
+ "EN": "Weeping Cradle",
+ "desc": "A special W-Engine with multiple layers embedded inside. It can quickly recharge \tspecial-model Bangboo and custom-made combat equipment. This model is favored by Rina.",
+ "KO": "흐느끼는 요람",
+ "CHS": "啜泣摇篮",
+ "JA": "啜り泣くゆりかご"
+ },
+ "12010": {
+ "icon": "Weapon_B_Common_10",
+ "rank": 2,
+ "type": 3,
+ "EN": "[Magnetic Storm] Alpha",
+ "desc": "A special W-Engine that can analyze the target's weak spots through real-time calculations.",
+ "KO": "「자기 폭풍」-알파",
+ "CHS": "「电磁暴」-壹式",
+ "JA": "「磁気嵐」-壱式"
+ },
+ "13115": {
+ "icon": "Weapon_A_1151",
+ "rank": 3,
+ "type": 4,
+ "EN": "Kaboom the Cannon",
+ "desc": "A support W-Engine propelled by jets that is both mobile and impactful, it can traverse the entire battlefield providing combat buffs.",
+ "KO": "호전적인 꽝꽝이",
+ "CHS": "好斗的阿炮",
+ "JA": "喧嘩腰のボンバルダム"
+ },
+ "13009": {
+ "icon": "Weapon_A_Common_09",
+ "rank": 3,
+ "type": 3,
+ "EN": "Electro-Lip Gloss",
+ "desc": "This damage-type W-Engine boasts a built-in current transformer, and automatically attracts electrically sensitive objects around the operator and deals additional damage to them.",
+ "KO": "감전 립글로스",
+ "CHS": "触电唇彩",
+ "JA": "電撃リップグロス"
+ },
+ "14140": {
+ "icon": "Weapon_S_1401",
+ "rank": 4,
+ "type": 3,
+ "EN": "Practiced Perfection",
+ "desc": "Starlight became her blade, and she held courage and love firmly in her hands.",
+ "KO": "완벽하게 단조된 별",
+ "CHS": "十方锻星",
+ "JA": "十面百錬の星"
+ },
+ "13013": {
+ "icon": "Weapon_A_Common_13",
+ "rank": 3,
+ "type": 1,
+ "EN": "Gilded Blossom",
+ "desc": "A W-Engine with a grandiose and luxurious appearance equipped with a premium Ether-powered anti-theft device. It's actually used to provide the equipper with energy.",
+ "KO": "도금된 화신풍",
+ "CHS": "鎏金花信",
+ "JA": "金メッキの花信"
+ },
+ "14148": {
+ "icon": "Weapon_S_1481",
+ "rank": 4,
+ "type": 2,
+ "EN": "Yesterday Calls",
+ "desc": "That call you never picked up... she's the one who answered it.",
+ "KO": "지난밤의 전화",
+ "CHS": "昨夜来电",
+ "JA": "昨夜からの着信"
+ },
+ "12004": {
+ "icon": "Weapon_B_Common_04",
+ "rank": 2,
+ "type": 4,
+ "EN": "[Reverb] Mark I",
+ "desc": "A special W-Engine featuring a built-in sonic generator that boosts its damage output.",
+ "KO": "「잔향」-Ⅰ형",
+ "CHS": "「残响」-Ⅰ型",
+ "JA": "「残響」-Ⅰ型"
+ },
+ "14126": {
+ "icon": "Weapon_S_1261",
+ "rank": 4,
+ "type": 3,
+ "EN": "Sharpened Stinger",
+ "desc": "A deadly W-Engine from unknown sources, modified personally by Jane Doe with the intent to deal severe physical damage and Anomaly.",
+ "KO": "예리한 집게칼",
+ "CHS": "淬锋钳刺",
+ "JA": "磨き抜かれた切っ先"
+ },
+ "12008": {
+ "icon": "Weapon_B_Common_08",
+ "rank": 2,
+ "type": 2,
+ "EN": "[Vortex] Arrow",
+ "desc": "A tactical W-Engine that locates enemies using high-frequency sound waves. Its aiming aid can make its operator's precision attack more deadly.",
+ "KO": "「급류」-화살",
+ "CHS": "「湍流」-矢型",
+ "JA": "「激流」-矢型"
+ },
+ "12001": {
+ "icon": "Weapon_B_Common_01",
+ "rank": 2,
+ "type": 1,
+ "EN": "[Lunar] Pleniluna",
+ "desc": "A W-Engine that prioritizes damage output over noise reduction. It can indiscriminately deal considerable damage to all units nearby.",
+ "KO": "「루나」-보름달",
+ "CHS": "「月相」-望",
+ "JA": "「月相」-望"
+ },
+ "13106": {
+ "icon": "Weapon_A_1061",
+ "rank": 3,
+ "type": 1,
+ "EN": "Housekeeper",
+ "desc": "An enhanced W-Engine with a high rotation speed. A chainsaw has been integrated into its shaft. This model is often used by Corin.",
+ "KO": "하우스키퍼",
+ "CHS": "家政员",
+ "JA": "ハウスキーパー"
+ },
+ "14002": {
+ "icon": "Weapon_S_Common_02",
+ "rank": 3,
+ "type": 4,
+ "EN": "Unfettered Game Ball",
+ "desc": "A new supercomputing W-Engine, featuring cutting-edge integrated chips that dynamically monitor the battlefield and provide tactical analysis for the user.",
+ "KO": "내 맘대로 게임 볼",
+ "CHS": "逍遥游球",
+ "JA": "ゲームボール"
+ },
+ "14125": {
+ "icon": "Weapon_S_1251",
+ "rank": 4,
+ "type": 2,
+ "EN": "Ice-Jade Teapot",
+ "desc": "A custom stun W-Engine made for an Automaton referencing the combat style and abilities of one. Energy is stored within, then lashes outwards upon conversion.",
+ "KO": "맑은 옥주전자",
+ "CHS": "玉壶青冰",
+ "JA": "玉壺青氷"
+ },
+ "13001": {
+ "icon": "Weapon_A_Common_01",
+ "rank": 3,
+ "type": 1,
+ "EN": "Street Superstar",
+ "desc": "A custom-made W-Engine designed for urban music lovers. It sacrifices heat dissipation components for improved sound quality.",
+ "KO": "거리의 슈퍼스타",
+ "CHS": "街头巨星",
+ "JA": "ストリートスター"
+ },
+ "13006": {
+ "icon": "Weapon_A_Common_06",
+ "rank": 3,
+ "type": 2,
+ "EN": "Precious Fossilized Core",
+ "desc": "A special W-Engine whose outer shell is made of composite Etheric matter that effectively absorbs impact, and turns it against the enemy.",
+ "KO": "귀중한 화석 코어",
+ "CHS": "贵重骨核",
+ "JA": "貴重な石化コア"
+ },
+ "12012": {
+ "icon": "Weapon_B_Common_12",
+ "rank": 2,
+ "type": 3,
+ "EN": "[Magnetic Storm] Charlie",
+ "desc": "A high-capacity W-Engine that features a built-in generator, which allows it to keep a stable storage of electrical power.",
+ "KO": "「자기 폭풍」-찰리",
+ "CHS": "「电磁暴」-叁式",
+ "JA": "「磁気嵐」-参式"
+ },
+ "14003": {
+ "icon": "Weapon_S_Common_03",
+ "rank": 3,
+ "type": 2,
+ "EN": "Six Shooter",
+ "desc": "A special W-Engine modeled after a revolver. It can load bullet-shaped condensed Ether batteries, which release a great amount of power when fired.",
+ "KO": "리볼버 로터",
+ "CHS": "左轮转子",
+ "JA": "シックスシューター"
+ },
+ "13144": {
+ "icon": "Weapon_A_1441",
+ "rank": 3,
+ "type": 6,
+ "EN": "Grill O'Wisp",
+ "desc": "A flame both fierce and faithful, burning without end.",
+ "KO": "어스름한 밤의 화염",
+ "CHS": "燔火胧夜",
+ "JA": "燔火の朧夜"
+ },
+ "13128": {
+ "icon": "Weapon_A_1281",
+ "rank": 3,
+ "type": 3,
+ "EN": "Roaring Ride",
+ "desc": "An energy W-Engine modded from spare parts of a truck's engine. It converts the original cylinder into an energy storage unit, which is highly advantageous.",
+ "KO": "뛰뛰빵빵",
+ "CHS": "轰鸣座驾",
+ "JA": "グロウル・マイ・カー"
+ }
+ },
+ "equipment": {
+ "33500": {
+ "icon": "UI/Sprite/A1DynamicLoad/IconSuit/UnPacker/SuitWhiteWaterBallad.png",
+ "EN": {
+ "name": "White Water Ballad",
+ "desc2": "Physical DMG +10%",
+ "desc4": "When the equipper is within any Ether Veil, their CRIT Rate increases by 10%. After leaving the Ether Veil, this buff remains for 15s. If the equipper is an Attack character, activating an Ether Veil or extending an Ether Veil's duration increases their CRIT Rate by 10% and ATK by 10% for 30s. Repeated triggers reset the duration."
+ },
+ "KO": {
+ "name": "물빛 노랫소리",
+ "desc2": "물리 피해+10%",
+ "desc4": "착용자가 임의의 [에테르 장막] 중에 있을 때 자신의 치명타 확률이 10% 증가하며, [에테르 장막]에서 벗어난 후에도 해당 버프는 유지된다. 지속 시간: 15초. 착용자가 [강공] 캐릭터일 경우, [에테르 장막]을 발동하거나 [에테르 장막]의 지속 시간 연장 시, 자신의 치명타 확률이 10% 증가하고 공격력이 10% 증가한다. 지속 시간: 30초, 중복 발동 시 지속 시간이 갱신된다."
+ },
+ "CHS": {
+ "name": "沧浪行歌",
+ "desc2": "物理伤害+10%。",
+ "desc4": "装备者处于任意[以太帷幕]中时,自身暴击率提高10%,离开[以太帷幕]后,该增益效果仍然保留,持续15秒;装备者为[强攻]角色时,开启[以太帷幕]或延长[以太帷幕]的持续时间会使自身暴击率提升10%和攻击力提升10%,持续30秒,重复触发时刷新持续时间。"
+ },
+ "JA": {
+ "name": "純白の行歌",
+ "desc2": "物理属性ダメージ+10%。",
+ "desc4": "装備者が任意の「エーテルベール」効果を受けている時、自身の会心率+10%。「エーテルベール」終了後でも、この効果は15秒継続する。装備者が[強攻]メンバーの場合、「エーテルベール」を展開、または「エーテルベール」の継続時間を延長した際、自身の会心率+10%、攻撃力+10%、継続時間30秒、重複して発動すると継続時間が更新される。"
+ }
+ },
+ "31900": {
+ "icon": "UI/Sprite/A1DynamicLoad/IconSuit/UnPacker/SuitProtoPunk.png",
+ "EN": {
+ "name": "Proto Punk",
+ "desc2": "Increases Shield effect by 15%.",
+ "desc4": "When any squad member triggers a Defensive Assist or Evasive Assist, all squad members deal 15% increased DMG, lasting 10s. Passive effects of the same name do not stack."
+ },
+ "KO": {
+ "name": "원시 펑크",
+ "desc2": "부여하는 실드량+15%",
+ "desc4": "파티 내 임의의 캐릭터가 [패링 지원] 혹은 [회피 지원] 시전 시 파티 내 모든 캐릭터가 주는 피해가 15% 증가한다. 지속 시간: 10초, 이름이 같은 패시브 효과끼리 중첩되지 않는다."
+ },
+ "CHS": {
+ "name": "原始朋克",
+ "desc2": "施加的护盾值提升15%。",
+ "desc4": "队伍中任意角色发动[招架支援]或[回避支援]时,全队角色造成的伤害提升15%,持续10秒,同名被动效果之间不可叠加。"
+ },
+ "JA": {
+ "name": "プロト・パンク",
+ "desc2": "シールド生成量+15%。",
+ "desc4": "任意のメンバーが『パリィ支援』または『回避支援』を発動した時、メンバー全員の与ダメージ+15%、継続時間10秒。同じパッシブ効果は重ね掛け不可。"
+ }
+ },
+ "33600": {
+ "icon": "UI/Sprite/A1DynamicLoad/IconSuit/UnPacker/SuitShiningAria.png",
+ "EN": {
+ "name": "Shining Aria",
+ "desc2": "Ether DMG +10%",
+ "desc4": "When the equipper's Basic Attack hits an enemy, their Anomaly Proficiency increases by 36, lasting 8s. Repeated triggers reset the duration. When any enemy on the field is Stunned, the equipper's DMG increases by 25% for 18s. Repeated triggers reset the duration."
+ },
+ "KO": {
+ "name": "빛의 아리아",
+ "desc2": "에테르 피해+10%",
+ "desc4": "착용자가 [일반 공격]으로 적 명중 시 자신의 이상 마스터리가 36pt 증가한다. 지속 시간: 8초, 중복 발동 시 지속 시간이 갱신된다. 전장 위 적이 그로기 상태에 진입할 시 착용자가 주는 피해가 25% 증가한다. 지속 시간: 18초, 중복 발동 시 지속 시간이 갱신된다."
+ },
+ "CHS": {
+ "name": "流光咏叹",
+ "desc2": "以太伤害+10%。",
+ "desc4": "装备者发动[普通攻击]命中敌人时,自身异常精通提升36点,持续8秒,重复触发时刷新持续时间;当场上有敌人进入失衡状态时,装备者造成的伤害提升25%,持续18秒,重复触发时刷新持续时间。"
+ },
+ "JA": {
+ "name": "流光のアリア",
+ "desc2": "エーテル属性ダメージ+10%。",
+ "desc4": "装備者の『通常攻撃』が敵に命中した時、自身の異常マスタリー+36Pt、継続時間8秒、重複して発動すると継続時間が更新される。フィールド上の敵がブレイク状態になった時、装備者の与ダメージ+25%、継続時間18秒、重複して発動すると継続時間が更新される。"
+ }
+ },
+ "31300": {
+ "icon": "UI/Sprite/A1DynamicLoad/IconSuit/UnPacker/SuitFreedomBlues.png",
+ "EN": {
+ "name": "Freedom Blues",
+ "desc2": "Anomaly Proficiency +30",
+ "desc4": "When an EX Special Attack hits an enemy, reduce the target's Anomaly Buildup RES to the equipper's Attribute by 20% for 8s. This effect does not stack with others of the same attribute."
+ },
+ "KO": {
+ "name": "자유의 블루스",
+ "desc2": "이상 마스터리+30pt",
+ "desc4": "[강화 특수 스킬]이 적에게 명중 시 착용자의 속성 타입에 따라 타깃의 상응하는 속성 이상 축적 저항이 20% 감소한다. 지속 시간: 8초, 동일한 속성 타입의 효과는 중첩되지 않는다."
+ },
+ "CHS": {
+ "name": "自由蓝调",
+ "desc2": "异常精通+30点。",
+ "desc4": "[强化特殊技]命中敌人时,根据装备者的属性类型,使目标对应属性异常积蓄抗性降低20%,持续8秒,相同属性类型的效果不可叠加。"
+ },
+ "JA": {
+ "name": "フリーダム・ブルース",
+ "desc2": "異常マスタリー+30Pt。",
+ "desc4": "『強化特殊スキル』が敵に命中すると、装備者の属性に応じてターゲットの対応する状態異常蓄積耐性-20%、継続時間8秒。同属性での重ね掛けは不可。"
+ }
+ },
+ "31600": {
+ "icon": "UI/Sprite/A1DynamicLoad/IconSuit/UnPacker/SuitSwingJazz.png",
+ "EN": {
+ "name": "Swing Jazz",
+ "desc2": "Energy Regen +20%",
+ "desc4": "Launching a Chain Attack or Ultimate increases all squad members' DMG by 15% for 12s. Passive effects of the same name do not stack."
+ },
+ "KO": {
+ "name": "스윙 재즈",
+ "desc2": "에너지 자동 회복+20%",
+ "desc4": "[콤보 스킬] 혹은 [궁극기] 시전 시 파티 내 모든 캐릭터가 주는 피해가 15% 증가한다. 지속 시간: 12초, 이름이 같은 패시브 효과끼리 중첩되지 않는다."
+ },
+ "CHS": {
+ "name": "摇摆爵士",
+ "desc2": "能量自动回复+20%。",
+ "desc4": "发动[连携技]或[终结技]时,全队角色造成的伤害提升15%,持续12秒,同名被动效果之间不可叠加。"
+ },
+ "JA": {
+ "name": "スイング・ジャズ",
+ "desc2": "エネルギー自動回復+20%。",
+ "desc4": "『連携スキル』または『終結スキル』発動時、メンバー全員の与ダメージ+15%、継続時間12秒。同じパッシブ効果は重ね掛け不可。"
+ }
+ },
+ "32700": {
+ "icon": "UI/Sprite/A1DynamicLoad/IconSuit/UnPacker/SuitBranch&BladeSong.png",
+ "EN": {
+ "name": "Branch & Blade Song",
+ "desc2": "CRIT DMG +16%",
+ "desc4": "When Anomaly Mastery exceeds or equals 115 points, the equipper's CRIT DMG increases by 30%. When any squad member applies Freeze or triggers the Shatter effect on an enemy, the equipper's CRIT Rate increases by 12%, lasting 15s."
+ },
+ "KO": {
+ "name": "나뭇가지 검의 노래",
+ "desc2": "치명타 피해+16%",
+ "desc4": "이상 장악력이 115pt 이상일 시 착용자의 치명타 피해가 30% 증가한다. 파티 내 임의의 캐릭터가 적에게 [빙결] 부여 혹은 [쇄빙] 효과 발동 시 착용자의 치명타 확률이 12% 증가한다. 지속 시간: 15초"
+ },
+ "CHS": {
+ "name": "折枝剑歌",
+ "desc2": "暴击伤害+16%。",
+ "desc4": "异常掌控大于等于115点时,装备者的暴击伤害提升30%;队伍中任意角色对敌人施加[冻结]或触发[碎冰]效果时,装备者的暴击率提升12%,持续15秒。"
+ },
+ "JA": {
+ "name": "折枝の刀歌",
+ "desc2": "会心ダメージ+16%。",
+ "desc4": "異常掌握が115Pt以上の時、装備者の会心ダメージ+30%。任意のメンバーが敵に[凍結]効果を付与した時、または[砕氷]効果を発動した時、装備者の会心率+12%、継続時間15秒。"
+ }
+ },
+ "31100": {
+ "icon": "UI/Sprite/A1DynamicLoad/IconSuit/UnPacker/SuitPufferElectro.png",
+ "EN": {
+ "name": "Puffer Electro",
+ "desc2": "PEN Ratio +8%",
+ "desc4": "Ultimate DMG increases by 20%. Launching an Ultimate increases the equipper's ATK by 15% for 12s."
+ },
+ "KO": {
+ "name": "복어 일렉트로",
+ "desc2": "관통률+8%",
+ "desc4": "[궁극기]로 주는 피해가 20% 증가한다. [궁극기] 시전 시 착용자의 공격력이 15% 증가한다. 지속 시간: 12초"
+ },
+ "CHS": {
+ "name": "河豚电音",
+ "desc2": "穿透率+8%。",
+ "desc4": "[终结技]造成的伤害提升20%;发动[终结技]时,装备者的攻击力提升15%,持续12秒。"
+ },
+ "JA": {
+ "name": "パファー・エレクトロ",
+ "desc2": "貫通率+8%。",
+ "desc4": "『終結スキル』の与ダメージ+20%。『終結スキル』発動時、装備者の攻撃力+15%、継続時間12秒。"
+ }
+ },
+ "33200": {
+ "icon": "UI/Sprite/A1DynamicLoad/IconSuit/UnPacker/SuitKingoftheSummit.png",
+ "EN": {
+ "name": "King of the Summit",
+ "desc2": "Increases Daze of attacks by 6%",
+ "desc4": "When the equipper is a Stun character and uses an EX Special Attack or Chain Attack, increases CRIT DMG of all squad members by 15%, and when the equipper's CRIT Rate is more than or equal to 50%, further increases CRIT DMG by 15%, lasting 15s. Repeated triggers reset the duration. Passive effects of the same name do not stack."
+ },
+ "KO": {
+ "name": "산림의 왕",
+ "desc2": "공격으로 주는 그로기 수치+6%",
+ "desc4": "착용자가 [격파] 캐릭터일 때 [강화 특수 스킬] 혹은 [콤보 스킬]을 시전하면 파티 내 모든 캐릭터의 치명타 피해가 15% 증가하고, 착용자의 치명타 확률이 50% 이상일 시 치명타 피해가 추가로 15% 증가한다. 지속 시간: 15초, 중복 발동 시 지속 시간이 갱신되며 이름이 같은 패시브 효과끼리 중첩되지 않는다."
+ },
+ "CHS": {
+ "name": "山大王",
+ "desc2": "攻击造成的失衡值提升6%",
+ "desc4": "装备者为[击破]角色时,发动[强化特殊技]或[连携技]会使全队角色暴击伤害提升15%,装备者的暴击率大于等于50%时暴击伤害额外提升15%,持续15秒,重复触发时刷新持续时间,同名被动效果之间不可叠加。"
+ },
+ "JA": {
+ "name": "大山を統べる者",
+ "desc2": "攻撃の与えるブレイク値+6%",
+ "desc4": "装備者が[撃破]メンバーの場合、『強化特殊スキル』または『連携スキル』発動時、メンバー全員の会心ダメージ+15%。装備者の会心率が50%以上の場合、会心ダメージがさらに+15%、継続時間15秒、重複して発動すると継続時間が更新される。同じパッシブ効果は重ね掛け不可。"
+ }
+ },
+ "33400": {
+ "icon": "UI/Sprite/A1DynamicLoad/IconSuit/UnPacker/SuitMoonlightLullaby.png",
+ "EN": {
+ "name": "Moonlight Lullaby",
+ "desc2": "Energy Regen +20%",
+ "desc4": "When the equipper is a Support character and uses an EX Special Attack or Ultimate, the DMG dealt by all squad members increases by 18% for 25s. Repeated triggers reset the duration. Passive effects of the same name do not stack."
+ },
+ "KO": {
+ "name": "달빛 기사의 칭송",
+ "desc2": "에너지 자동 회복+20%",
+ "desc4": "착용자가 [지원] 캐릭터일 때 [강화 특수 스킬] 혹은 [궁극기]를 시전하면 파티 내 모든 캐릭터가 주는 피해가 18% 증가한다. 지속 시간: 25초, 중복 발동 시 지속 시간이 갱신되며, 이름이 같은 패시브 효과끼리 중첩되지 않는다."
+ },
+ "CHS": {
+ "name": "月光骑士颂",
+ "desc2": "能量自动回复+20%。",
+ "desc4": "装备者为[支援]角色时,发动[强化特殊技]或[终结技]会使全队角色造成的伤害提升18%,持续25秒,重复触发时刷新持续时间,同名被动效果之间不可叠加。"
+ },
+ "JA": {
+ "name": "月光騎士の讃歌",
+ "desc2": "エネルギー自動回復+20%。",
+ "desc4": "装備者が[支援]メンバーの場合、『強化特殊スキル』または『終結スキル』を発動すると、メンバー全員の与ダメージ+18%、継続時間25秒、重複して発動すると継続時間が更新される。同じパッシブ効果は重ね掛け不可。"
+ }
+ },
+ "31200": {
+ "icon": "UI/Sprite/A1DynamicLoad/IconSuit/UnPacker/SuitShockstarDisco.png",
+ "EN": {
+ "name": "Shockstar Disco",
+ "desc2": "Impact +6%",
+ "desc4": "Basic Attacks, Dash Attacks, and Dodge Counters inflict 20% more Daze to the main target."
+ },
+ "KO": {
+ "name": "쇼크스타 디스코",
+ "desc2": "충격력+6%",
+ "desc4": "[일반 공격], [대시 공격], [회피 반격]이 주요 공격 타깃에게 주는 그로기 수치가 20% 증가한다."
+ },
+ "CHS": {
+ "name": "震星迪斯科",
+ "desc2": "冲击力+6%。",
+ "desc4": "[普通攻击]、[冲刺攻击]、[闪避反击]对主要攻击目标造成的失衡值提升20%。"
+ },
+ "JA": {
+ "name": "ショックスター・ディスコ",
+ "desc2": "衝撃力+6%。",
+ "desc4": "『通常攻撃』、『ダッシュ攻撃』、『回避反撃』がメインターゲットに与えるブレイク値+20%。"
+ }
+ },
+ "32800": {
+ "icon": "UI/Sprite/A1DynamicLoad/IconSuit/UnPacker/SuitAstralVoice.png",
+ "EN": {
+ "name": "Astral Voice",
+ "desc2": "ATK +10%",
+ "desc4": "Whenever any squad member enters the field using a Quick Assist, all squad members gain 1 stack of Astral, up to a maximum of 3 stacks, and lasting 15s. Repeated triggers reset the duration. Each stack of Astral increases the DMG dealt by the character entering the field using a Quick Assist by 8%. Passive effects of the same name do not stack."
+ },
+ "KO": {
+ "name": "고요 속의 별",
+ "desc2": "공격력+10%",
+ "desc4": "파티 내 임의의 캐릭터가 [빠른 지원]을 통해 전장에 진입할 경우, 파티 내 모든 캐릭터가 [별]을 1스택 획득한다. 최대 중첩: 3스택, 지속 시간: 15초, 중복 발동 시 지속 시간이 갱신되며, [별]을 1스택 보유할 때마다 [빠른 지원]으로 전장에 진입하는 캐릭터가 주는 피해가 8% 증가한다. 이름이 같은 패시브 효과끼리 중첩되지 않는다."
+ },
+ "CHS": {
+ "name": "静听嘉音",
+ "desc2": "攻击力+10%。",
+ "desc4": "队伍中任意角色通过[快速支援]入场时,全队角色获得1层[嘉音],最多叠加3层,持续15秒,重复触发时刷新持续时间,每拥有1层[嘉音],通过[快速支援]入场的角色造成的伤害提升8%,同名被动效果之间不可叠加。"
+ },
+ "JA": {
+ "name": "静寂のアストラ",
+ "desc2": "攻撃力+10%。",
+ "desc4": "任意のメンバーが『クイック支援』で出場した時、メンバー全員が「天籟」を1重獲得する。最大3重まで重ね掛け可能、継続時間15秒、重複して発動すると継続時間が更新される。「天籟」1重につき、『クイック支援』で出場したメンバーの与ダメージ+8%、同じパッシブ効果は重ね掛け不可。"
+ }
+ },
+ "33100": {
+ "icon": "UI/Sprite/A1DynamicLoad/IconSuit/UnPacker/SuitYunkuiTales.png",
+ "EN": {
+ "name": "Yunkui Tales",
+ "desc2": "HP +10%",
+ "desc4": "When using EX Special Attack, Chain Attack, or Ultimate, CRIT Rate increases by 4%, stacking up to 3 times and lasting 15s. Repeated triggers reset the duration. When having 3 stacks of this effect, Sheer DMG increases by 10%."
+ },
+ "KO": {
+ "name": "운규 이야기",
+ "desc2": "HP+10%",
+ "desc4": "[강화 특수 스킬], [콤보 스킬], [궁극기] 시전 시 치명타 확률이 4% 증가한다. 최대 중첩: 3스택, 지속 시간: 15초. 중복 발동 시 지속 시간이 갱신되며, 3스택 효과 보유 시 주는 관입 피해가 10% 증가한다."
+ },
+ "CHS": {
+ "name": "云岿如我",
+ "desc2": "生命值+10%",
+ "desc4": "发动[强化特殊技]、[连携技]、[终结技]时,暴击率提升4%,最多叠加3层,持续15秒,重复触发时刷新持续时间,拥有3层效果时,造成的贯穿伤害提升10%。"
+ },
+ "JA": {
+ "name": "雲嶽は我に似たり",
+ "desc2": "HP+10%",
+ "desc4": "『強化特殊スキル』、『連携スキル』、『終結スキル』を発動時、会心率+4%。この効果は最大3重まで重ね掛け可能、継続時間15秒。重複して発動すると継続時間が更新される。重数が3重に達している場合、与える透徹ダメージ+10%。"
+ }
+ },
+ "32400": {
+ "icon": "UI/Sprite/A1DynamicLoad/IconSuit/UnPacker/SuitThunderMetal.png",
+ "EN": {
+ "name": "Thunder Metal",
+ "desc2": "Electric DMG +10%",
+ "desc4": "As long as an enemy in combat is Shocked, the equipper's ATK is increased by 28%."
+ },
+ "KO": {
+ "name": "썬더 메탈",
+ "desc2": "전기 속성 피해+10%",
+ "desc4": "전장에 [감전] 상태의 적이 존재할 시 착용자의 공격력이 28% 증가한다."
+ },
+ "CHS": {
+ "name": "雷暴重金属",
+ "desc2": "电属性伤害+10%。",
+ "desc4": "当场上存在处于[感电]状态下的敌人时,装备者的攻击力提升28%。"
+ },
+ "JA": {
+ "name": "霹靂のヘヴィメタル",
+ "desc2": "電気属性ダメージ+10%。",
+ "desc4": "フィールド上に[感電]状態の敵がいる時、装備者の攻撃力+28%。"
+ }
+ },
+ "32900": {
+ "icon": "UI/Sprite/A1DynamicLoad/IconSuit/UnPacker/SuitShadow.png",
+ "EN": {
+ "name": "Shadow Harmony",
+ "desc2": "The DMG of Aftershocks and Dash Attacks is increased by 15%.",
+ "desc4": "Upon hitting an enemy with an Aftershock or Dash Attack, if the DMG dealt aligns with the equipper's attribute, the equipper gains 1 stack of a buff effect, at most once per use of a skill. For each stack, the equipper's ATK increases by 4%, and CRIT Rate increases by 4%. The effect can stack up to 3 times and lasts for 15s. Repeated triggers reset the duration."
+ },
+ "KO": {
+ "name": "그림자처럼 함께",
+ "desc2": "[여진 공격] 및 [대시 공격]으로 주는 피해가 15% 증가한다.",
+ "desc4": "[여진 공격] 혹은 [대시 공격]이 적에게 명중 시, 만약 주는 피해가 착용자의 속성과 일치하면 버프 효과를 1스택 획득한다. 동일한 공격 한 번에 최대 1회 발동한다. 보유한 버프 효과 1스택당 착용자의 공격력이 4% 증가하고, 치명타 확률이 4% 증가한다. 최대 중첩: 3스택, 지속 시간: 15초, 중복 발동 시 지속 시간이 갱신된다."
+ },
+ "CHS": {
+ "name": "如影相随",
+ "desc2": "[追加攻击]和[冲刺攻击]造成的伤害提升15%。",
+ "desc4": "[追加攻击]或[冲刺攻击]命中敌人时,若造成的伤害与装备者的属性一致,则获得1层增益效果,同一招式内最多触发一次;每拥有1层增益效果,装备者的攻击力提升4%,暴击率提升4%,最多叠加3层,持续15秒,重复触发时刷新持续时间。"
+ },
+ "JA": {
+ "name": "シャドウハーモニー",
+ "desc2": "『追加攻撃』と『ダッシュ攻撃』の与ダメージ+15%。",
+ "desc4": "『追加攻撃』または『ダッシュ攻撃』が敵に命中した時、与えたダメージが装備者の属性と一致している場合、バフ効果を1重獲得する、1回のスキルにおいて1回のみ発動可能。バフ効果1重につき、装備者の攻撃力+4%、会心率+4%。最大3重まで重ね掛け可能、継続時間15秒、重複して発動すると継続時間が更新される。"
+ }
+ },
+ "33300": {
+ "icon": "UI/Sprite/A1DynamicLoad/IconSuit/UnPacker/SuitDawnsBloom.png",
+ "EN": {
+ "name": "Dawn's Bloom",
+ "desc2": "Increases Basic Attack DMG by 15%.",
+ "desc4": "Increases Basic Attack DMG by 20%. When equipped by an Attack character, using an EX Special Attack or Ultimate will further increase Basic Attack DMG by 20% for 25s. Repeated triggers reset the duration."
+ },
+ "KO": {
+ "name": "여명의 꽃",
+ "desc2": "[일반 공격]으로 주는 피해+15%",
+ "desc4": "[일반 공격]으로 주는 피해가 20% 증가하며, 착용자가 [강공] 캐릭터일 시 [강화 특수 스킬] 혹은 [궁극기]를 시전하면 [일반 공격]으로 주는 피해가 추가로 20% 증가한다. 지속 시간: 25초, 중복 발동 시 지속 시간이 갱신된다."
+ },
+ "CHS": {
+ "name": "拂晓生花",
+ "desc2": "[普通攻击]造成的伤害提升15%。",
+ "desc4": "[普通攻击]造成的伤害提升20%,装备者为[强攻]角色时,发动[强化特殊技]或[终结技]会使[普通攻击]造成的伤害额外提升20%,持续25秒,重复触发时刷新持续时间。"
+ },
+ "JA": {
+ "name": "暁に咲く花",
+ "desc2": "『通常攻撃』の与ダメージ+15%。",
+ "desc4": "『通常攻撃』の与ダメージ+20%。装備者が[強攻]メンバーの場合、『強化特殊スキル』または『終結スキル』を発動すると、『通常攻撃』の与ダメージが追加で+20%、継続時間25秒、重複して発動すると継続時間が更新される。"
+ }
+ },
+ "31500": {
+ "icon": "UI/Sprite/A1DynamicLoad/IconSuit/UnPacker/SuitSoulRock.png",
+ "EN": {
+ "name": "Soul Rock",
+ "desc2": "DEF +16%",
+ "desc4": "Upon receiving an enemy attack and losing HP, the equipper takes 40% less DMG for 2.5s. This effect can trigger once every 15s."
+ },
+ "KO": {
+ "name": "소울 록",
+ "desc2": "방어력+16%",
+ "desc4": "적의 공격을 받아 HP가 줄어들 시 착용자가 받는 피해가 40% 감소한다. 지속 시간: 2.5초, 15초 동안 최대 1회 발동한다."
+ },
+ "CHS": {
+ "name": "灵魂摇滚",
+ "desc2": "防御力+16%。",
+ "desc4": "受到敌方攻击并损失生命值时,装备者受到的伤害降低40%,持续2.5秒,15秒内最多触发一次。"
+ },
+ "JA": {
+ "name": "ソウル・ロック",
+ "desc2": "防御力+16%。",
+ "desc4": "敵の攻撃を受け、かつHPが減少した時、装備者の被ダメージ-40%、継続時間2.5秒。15秒に1回のみ発動可能。"
+ }
+ },
+ "32500": {
+ "icon": "UI/Sprite/A1DynamicLoad/IconSuit/UnPacker/SuitPolarMetal.png",
+ "EN": {
+ "name": "Polar Metal",
+ "desc2": "Ice DMG +10%",
+ "desc4": "Increase the DMG of Basic Attack and Dash Attack by 20%. When any squad member inflicts Freeze or Shatter, this effect increases by an additional 20% for 12s."
+ },
+ "KO": {
+ "name": "극지 메탈",
+ "desc2": "얼음 속성 피해+10%",
+ "desc4": "[일반 공격] 및 [대시 공격]으로 주는 피해가 20% 증가하며, 파티 내 임의의 캐릭터가 적에게 [빙결] 부여 혹은 [쇄빙] 효과 발동 시, 해당 버프 효과가 추가로 20% 증가한다. 지속 시간: 12초"
+ },
+ "CHS": {
+ "name": "极地重金属",
+ "desc2": "冰属性伤害+10%。",
+ "desc4": "[普通攻击]和[冲刺攻击]造成的伤害提升20%,队伍中任意角色对敌人施加[冻结]或触发[碎冰]效果时,该增益效果额外提升20%,持续12秒。"
+ },
+ "JA": {
+ "name": "極地のヘヴィメタル",
+ "desc2": "氷属性ダメージ+10%。",
+ "desc4": "『通常攻撃』と『ダッシュ攻撃』の与ダメージ+20%。任意のメンバーが敵に[凍結]効果を付与した時、または[砕氷]効果を発動した時、この効果がさらに+20%、継続時間12秒。"
+ }
+ },
+ "32600": {
+ "icon": "UI/Sprite/A1DynamicLoad/IconSuit/UnPacker/SuitFangedMetal.png",
+ "EN": {
+ "name": "Fanged Metal",
+ "desc2": "Physical DMG +10%",
+ "desc4": "Whenever a squad member inflicts Assault on an enemy, the equipper deals 35% additional DMG to the target for 12s."
+ },
+ "KO": {
+ "name": "송곳니 메탈",
+ "desc2": "물리 피해+10%",
+ "desc4": "파티 내 임의의 캐릭터가 적에게 [강타] 효과 부여 시 착용자가 타깃에게 주는 피해가 35% 증가한다. 지속 시간: 12초"
+ },
+ "CHS": {
+ "name": "獠牙重金属",
+ "desc2": "物理伤害+10%。",
+ "desc4": "队伍中任意角色对敌人施加[强击]效果时,装备者对目标造成的伤害提升35%,持续12秒。"
+ },
+ "JA": {
+ "name": "獣牙のヘヴィメタル",
+ "desc2": "物理属性ダメージ+10%。",
+ "desc4": "任意のメンバーが敵に[強撃]効果を付与した時、装備者がターゲットに与えるダメージ+35%、継続時間12秒。"
+ }
+ },
+ "31800": {
+ "icon": "UI/Sprite/A1DynamicLoad/IconSuit/UnPacker/SuitChaosJazz.png",
+ "EN": {
+ "name": "Chaos Jazz",
+ "desc2": "Anomaly Proficiency +30",
+ "desc4": "Fire DMG and Electric DMG increases by 15%. While off-field, EX Special Attack and Assist Attack DMG increases by 20%. When switching on-field, this buff continues for 5s, and this continuation effect can trigger once every 7.5s."
+ },
+ "KO": {
+ "name": "카오스 재즈",
+ "desc2": "이상 마스터리+30pt",
+ "desc4": "불 속성 피해 및 전기 속성 피해가 15% 증가한다. 대기 캐릭터일 시 [강화 특수 스킬] 및 [지원 공격]으로 주는 피해가 20% 증가하며, 전장에 교체 투입된 후 해당 버프 효과는 여전히 5초 동안 지속된다. 해당 지속 효과는 7.5초 동안 최대 1회 발동된다."
+ },
+ "CHS": {
+ "name": "混沌爵士",
+ "desc2": "异常精通+30点。",
+ "desc4": "火属性伤害和电属性伤害提升15%;位于后场时,[强化特殊技]和[支援攻击]造成的伤害提升20%,换入前场后,该增益效果仍然保留,持续5秒,保留效果7.5秒内最多触发一次。"
+ },
+ "JA": {
+ "name": "ケイオス・ジャズ",
+ "desc2": "異常マスタリー+30Pt。",
+ "desc4": "炎属性ダメージおよび電気属性ダメージ+15%。控えにいる時、『強化特殊スキル』と『支援攻撃』の与ダメージ+20%。出場した後も効果は5秒継続する。この効果継続は7.5秒に1回のみ発動可能。"
+ }
+ },
+ "33000": {
+ "icon": "UI/Sprite/A1DynamicLoad/IconSuit/UnPacker/SuitSavior.png",
+ "EN": {
+ "name": "Phaethon's Melody",
+ "desc2": "Anomaly Mastery +8%.",
+ "desc4": "When any squad member uses an EX Special Attack, the equipper's Anomaly Proficiency increases by 45 for 8s. If the character using the EX Special Attack is not the equipper, the equipper's Ether DMG is increased by 25%."
+ },
+ "KO": {
+ "name": "파에톤의 노래",
+ "desc2": "이상 장악력+8%",
+ "desc4": "파티 내 임의의 캐릭터가 [강화 특수 스킬] 시전 시, 착용자의 이상 마스터리가 45pt 증가한다. 지속 시간: 8초. 만약 [강화 특수 스킬]을 시전한 캐릭터가 착용자 본인이 아닐 경우, 착용자가 주는 에테르 피해가 25% 증가한다."
+ },
+ "CHS": {
+ "name": "法厄同之歌",
+ "desc2": "异常掌控+8%。",
+ "desc4": "队伍中任意角色发动[强化特殊技]时,装备者的异常精通提升45点,持续8秒;如果发动[强化特殊技]的角色不是装备者本人时,装备者造成的以太伤害提升25%。"
+ },
+ "JA": {
+ "name": "「パエトーン」の歌",
+ "desc2": "異常掌握+8%",
+ "desc4": "任意のメンバーが『強化特殊スキル』を発動した時、装備者の異常マスタリー+45Pt、継続時間8秒。『強化特殊スキル』を発動したエージェントが装備者本人でない場合、装備者によるエーテル属性ダメージ+25%。"
+ }
+ },
+ "31400": {
+ "icon": "UI/Sprite/A1DynamicLoad/IconSuit/UnPacker/SuitHormonePunk.png",
+ "EN": {
+ "name": "Hormone Punk",
+ "desc2": "ATK +10%",
+ "desc4": "Upon becoming the active character in combat, the equipper's ATK increases by 25% for 10s. This effect can trigger once every 20s."
+ },
+ "KO": {
+ "name": "호르몬 펑크",
+ "desc2": "공격력+10%",
+ "desc4": "교전 상태에서의 현재 조작 중인 캐릭터가 될 시, 착용자의 공격력이 25% 증가한다. 지속 시간: 10초, 20초 동안 최대 1회 발동한다."
+ },
+ "CHS": {
+ "name": "激素朋克",
+ "desc2": "攻击力+10%。",
+ "desc4": "成为接战状态下的当前操作角色时,装备者的攻击力提升25%,持续10秒,20秒内最多触发一次。"
+ },
+ "JA": {
+ "name": "ホルモン・パンク",
+ "desc2": "攻撃力+10%。",
+ "desc4": "接敵状態かつ操作中のメンバーになった時、装備者の攻撃力+25%、継続時間10秒。20秒に1回のみ発動可能。"
+ }
+ },
+ "32200": {
+ "icon": "UI/Sprite/A1DynamicLoad/IconSuit/UnPacker/SuitInfernoMetal.png",
+ "EN": {
+ "name": "Inferno Metal",
+ "desc2": "Fire DMG +10%",
+ "desc4": "Upon hitting a Burning enemy, the equipper's CRIT Rate is increased by 28% for 8s."
+ },
+ "KO": {
+ "name": "불지옥 메탈",
+ "desc2": "불 속성 피해+10%",
+ "desc4": "공격이 [연소] 상태의 적에게 명중 시 착용자의 치명타 확률이 28% 증가한다. 지속 시간: 8초"
+ },
+ "CHS": {
+ "name": "炎狱重金属",
+ "desc2": "火属性伤害+10%。",
+ "desc4": "攻击命中处于[灼烧]状态下的敌人时,装备者的暴击率提升28%,持续8秒。"
+ },
+ "JA": {
+ "name": "炎獄のヘヴィメタル",
+ "desc2": "炎属性ダメージ+10%。",
+ "desc4": "[熱傷]状態の敵に攻撃が命中した時、装備者の会心率+28%、継続時間8秒。"
+ }
+ },
+ "31000": {
+ "icon": "UI/Sprite/A1DynamicLoad/IconSuit/UnPacker/SuitWoodpeckerElectro.png",
+ "EN": {
+ "name": "Woodpecker Electro",
+ "desc2": "CRIT Rate +8%",
+ "desc4": "Landing a critical hit on an enemy with a Basic Attack, Dodge Counter, or EX Special Attack increases the equipper's ATK by 9% for 6s. The buff duration for different skills are calculated separately."
+ },
+ "KO": {
+ "name": "딱따구리 일렉트로",
+ "desc2": "치명타 확률+8%",
+ "desc4": "[일반 공격], [회피 반격] 혹은 [강화 특수 스킬]이 적에게 명중하고 치명타 발동 시 착용자에게 각각 1스택의 버프 효과를 제공하며 버프 효과 스택당 착용자의 공격력이 9% 증가한다. 지속 시간: 6초, 각 스킬의 지속 시간은 따로 계산된다."
+ },
+ "CHS": {
+ "name": "啄木鸟电音",
+ "desc2": "暴击率+8%。",
+ "desc4": "[普通攻击]、[闪避反击]或[强化特殊技]命中敌人并触发暴击时,分别为装备者提供1层增益效果,每层增益效果使装备者的攻击力提升9%,持续6秒,不同招式分别结算持续时间。"
+ },
+ "JA": {
+ "name": "ウッドペッカー・エレクトロ",
+ "desc2": "会心率+8%。",
+ "desc4": "『通常攻撃』、『回避反撃』または『強化特殊スキル』が敵に命中し、なおかつ会心が出た時、それぞれ装備者にバフ効果を1重与える。バフ効果1重につき、装備者の攻撃力+9%、継続時間6秒。バフ効果の継続時間はスキルごとに計算される。"
+ }
+ },
+ "32300": {
+ "icon": "UI/Sprite/A1DynamicLoad/IconSuit/UnPacker/SuitChaosMetal.png",
+ "EN": {
+ "name": "Chaotic Metal",
+ "desc2": "Ether DMG +10%",
+ "desc4": "The equipper's CRIT DMG increases by 20%. When any character in the squad triggers Corruption's additional DMG, this effect further increases by 5.5% for 8s, stacking up to 6 times. Repeated triggers reset the duration."
+ },
+ "KO": {
+ "name": "카오스 메탈",
+ "desc2": "에테르 피해+10%",
+ "desc4": "착용자의 치명타 피해가 20% 증가한다. 파티 내 임의의 캐릭터가 [침식] 효과의 추가 피해 발동 시 해당 버프 효과가 추가로 5.5% 증가한다. 최대 중첩: 6스택, 지속 시간: 8초, 중복 발동 시 지속 시간이 갱신된다."
+ },
+ "CHS": {
+ "name": "混沌重金属",
+ "desc2": "以太伤害+10%。",
+ "desc4": "装备者的暴击伤害提升20%,队伍中任意角色触发[侵蚀]效果的额外伤害时,该增益效果额外提升5.5%,最多叠加6层,持续8秒,重复触发时刷新持续时间。"
+ },
+ "JA": {
+ "name": "混沌のヘヴィメタル",
+ "desc2": "エーテル属性ダメージ+10%。",
+ "desc4": "装備者の会心ダメージ+20%。任意のメンバーによって[侵蝕]効果の追加ダメージが発生した時、この効果がさらに+5.5%、最大6重まで重ね掛け可能、継続時間8秒。重複して発動すると継続時間が更新される。"
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/zzz_od/application/inventory_scan/__init__.py b/src/zzz_od/application/inventory_scan/__init__.py
new file mode 100644
index 0000000000..10827d39b8
--- /dev/null
+++ b/src/zzz_od/application/inventory_scan/__init__.py
@@ -0,0 +1 @@
+# Inventory Scan Application
diff --git a/src/zzz_od/application/inventory_scan/agent/__init__.py b/src/zzz_od/application/inventory_scan/agent/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/src/zzz_od/application/inventory_scan/agent/agent_scan_app.py b/src/zzz_od/application/inventory_scan/agent/agent_scan_app.py
new file mode 100644
index 0000000000..5b8e911912
--- /dev/null
+++ b/src/zzz_od/application/inventory_scan/agent/agent_scan_app.py
@@ -0,0 +1,372 @@
+import cv2
+import time
+import json
+from typing import Optional, TYPE_CHECKING
+import numpy as np
+from one_dragon.base.operation.operation_edge import node_from
+from one_dragon.base.operation.operation_node import operation_node
+from one_dragon.base.operation.operation_round_result import OperationRoundResult
+from one_dragon.utils.log_utils import log
+from one_dragon.utils import cv2_utils, os_utils
+from zzz_od.application.zzz_application import ZApplication
+from zzz_od.context.zzz_context import ZContext
+from zzz_od.application.inventory_scan.parser.agent_parser import AgentParser
+from zzz_od.application.inventory_scan.screenshot_cache import ScreenshotCache
+from cv2.typing import MatLike
+
+if TYPE_CHECKING:
+ from zzz_od.application.inventory_scan.ocr_worker import OcrWorker
+
+
+class AgentScanApp(ZApplication):
+
+ def __init__(self, ctx: ZContext, screenshot_cache: Optional[ScreenshotCache] = None,
+ ocr_worker: Optional["OcrWorker"] = None):
+ ZApplication.__init__(
+ self,
+ ctx=ctx,
+ app_id='agent_scan',
+ op_name='角色扫描',
+ )
+ self.screenshot_cache = screenshot_cache
+ self.ocr_worker = ocr_worker
+ self.screenshots_dir: Optional[str] = None
+ self.screenshot_index: int = 0
+ self.parser = AgentParser()
+
+ self._img_portrait: Optional[MatLike] = None
+ self._img_name: Optional[MatLike] = None
+ self._img_level: Optional[MatLike] = None
+ self._img_skill: Optional[MatLike] = None
+ self._img_core: Optional[MatLike] = None
+
+ self._last_click_time: float = 0
+
+ user_translation_path = os_utils.get_path_under_work_dir('assets', 'wiki_data', 'zzz_translation.json')
+ try:
+ with open(user_translation_path, 'r', encoding='utf-8') as f:
+ translation_data = json.load(f)
+ self.max_agents = min(len(translation_data.get('character', {})), 100)
+ except Exception as e:
+ log.error(f"读取角色总数失败: {e},使用默认值100")
+ self.max_agents = 100
+
+ @node_from(from_name='下一位代理人')
+ @operation_node(name='进入基础页面', is_start_node=True, node_max_retry_times=30)
+ def enter_basic_page(self) -> OperationRoundResult:
+ """
+ 点击基础按钮并等待页面加载完成
+ 同时检测是否到达列表末尾(没有角色)
+ """
+ # 点击基础按钮
+ btn = self.ctx.screen_loader.get_area('代理人-信息', '按钮-代理人基础')
+ self.ctx.controller.click(btn.center)
+
+ # 等待后截图检测
+ time.sleep(0.3)
+ _, screen = self.ctx.controller.screenshot()
+
+ if self.screenshot_index >= self.max_agents:
+ log.debug(f"已扫描{self.screenshot_index}个角色,达到最大数量{self.max_agents}")
+ return self.round_success('扫描完成')
+
+ if self._is_end_of_list(screen):
+ log.debug(f"已到达列表末尾,扫描完成。共扫描{self.screenshot_index}个角色")
+ return self.round_success('扫描完成')
+
+ if self._is_button_colorful(screen, '代理人-信息', '按钮-代理人基础'):
+ return self.round_success('继续扫描')
+
+ return self.round_retry(wait=0.1)
+
+ @node_from(from_name='进入基础页面', status='继续扫描')
+ @operation_node(name='截图基础信息')
+ def capture_basic_info(self) -> OperationRoundResult:
+ """
+ 裁剪影画、名称、等级区域
+ """
+ self._reset_temp_images()
+
+ _, screen = self.ctx.controller.screenshot()
+
+ area_portrait = self.ctx.screen_loader.get_area('代理人-信息', '代理人-影画')
+ area_name = self.ctx.screen_loader.get_area('代理人-信息', '代理人-名称')
+ area_level = self.ctx.screen_loader.get_area('代理人-信息', '代理人-等级')
+
+ self._img_portrait = cv2_utils.crop_image_only(screen, area_portrait.rect)
+ self._img_name = cv2_utils.crop_image_only(screen, area_name.rect)
+ self._img_level = cv2_utils.crop_image_only(screen, area_level.rect)
+
+ return self.round_success()
+
+ @node_from(from_name='截图基础信息')
+ @operation_node(name='进入技能页面', node_max_retry_times=30)
+ def enter_skill_page(self) -> OperationRoundResult:
+ """
+ 点击技能按钮并等待页面加载完成
+ """
+ # 点击技能按钮
+ btn = self.ctx.screen_loader.get_area('代理人-信息', '按钮-代理人技能')
+ self.ctx.controller.click(btn.center)
+
+ # 等待后截图检测
+ time.sleep(0.1)
+ _, screen = self.ctx.controller.screenshot()
+
+ # 检测按钮是否变为彩色(加载完成)
+ if self._is_button_colorful(screen, '代理人-信息', '按钮-代理人技能'):
+ return self.round_success()
+
+ return self.round_retry(wait=0.1)
+
+ @node_from(from_name='进入技能页面')
+ @operation_node(name='截图技能信息')
+ def capture_skill_info(self) -> OperationRoundResult:
+ """
+ 裁剪技能等级区域
+ """
+ _, screen = self.ctx.controller.screenshot()
+
+ area_skill = self.ctx.screen_loader.get_area('代理人-信息', '代理人-技能等级')
+ self._img_skill = cv2_utils.crop_image_only(screen, area_skill.rect)
+
+ return self.round_success()
+
+ @node_from(from_name='截图技能信息')
+ @operation_node(name='进入核心页面', node_max_retry_times=30)
+ def enter_core_page(self) -> OperationRoundResult:
+ """
+ 点击核心技等级按钮并等待页面加载完成
+ """
+ # 点击核心按钮
+ btn = self.ctx.screen_loader.get_area('代理人-信息', '按钮-核心技等级')
+ self.ctx.controller.click(btn.center)
+
+ # 等待后截图检测
+ time.sleep(0.1)
+ _, screen = self.ctx.controller.screenshot()
+
+ # 检测街区按钮是否全黑(核心页面加载完成)
+ if self._is_area_black(screen, '代理人-信息', '按钮-街区'):
+ return self.round_success()
+
+ return self.round_retry(wait=0.1)
+
+ @node_from(from_name='进入核心页面')
+ @operation_node(name='截图核心信息')
+ def capture_core_info(self) -> OperationRoundResult:
+ """
+ 裁剪核心等级区域
+ """
+ _, screen = self.ctx.controller.screenshot()
+
+ area_core = self.ctx.screen_loader.get_area('代理人-信息', '代理人-核心等级')
+ self._img_core = cv2_utils.crop_image_only(screen, area_core.rect)
+
+ return self.round_success()
+
+ @node_from(from_name='截图核心信息')
+ @operation_node(name='返回并保存', node_max_retry_times=50)
+ def return_and_save(self) -> OperationRoundResult:
+ """
+ 点击返回按钮,等待返回成功后拼接并保存截图
+ 点击限流:最多1秒点击一次,检测实时进行
+ """
+ current_time = time.time()
+
+ # 距离上次点击超过1秒才点击
+ if current_time - self._last_click_time >= 1:
+ btn_back = self.ctx.screen_loader.get_area('代理人-信息', '按钮-返回')
+ self.ctx.controller.click(btn_back.center)
+ self._last_click_time = current_time
+
+ # 等待后截图检测
+ time.sleep(0.1)
+ _, screen = self.ctx.controller.screenshot()
+
+ # 检测街区按钮是否不全黑(返回成功)
+ if not self._is_area_black(screen, '代理人-信息', '按钮-街区'):
+ self._last_click_time = 0 # 成功后清空
+ # 拼接并保存截图
+ self._combine_and_save()
+ return self.round_success()
+
+ return self.round_retry(wait=0.1)
+
+ @node_from(from_name='返回并保存')
+ @operation_node(name='下一位代理人')
+ def next_agent(self) -> OperationRoundResult:
+ """
+ 点击下一位代理人按钮
+ """
+ btn_next = self.ctx.screen_loader.get_area('代理人-信息', '按钮-下一位代理人')
+ self.ctx.controller.click(btn_next.center)
+
+ return self.round_success(wait=0.3)
+
+ # ==================== 辅助方法 ====================
+
+ def _reset_temp_images(self):
+ """重置临时截图变量"""
+ self._img_portrait = None
+ self._img_name = None
+ self._img_level = None
+ self._img_skill = None
+ self._img_core = None
+
+ def _is_button_colorful(self, screen: MatLike, screen_name: str, area_name: str) -> bool:
+ """
+ 检测按钮区域是否出现彩色(中间40%区域)
+ 用于判断基础/技能页面是否加载完成
+
+ Args:
+ screen: 当前截图
+ screen_name: 画面名称
+ area_name: 区域名称
+
+ Returns:
+ True 如果区域出现彩色(S通道均值>20),False 否则
+ """
+ try:
+ area = self.ctx.screen_loader.get_area(screen_name, area_name)
+
+ # 获取区域图像
+ x1, y1 = int(area.rect.x1), int(area.rect.y1)
+ x2, y2 = int(area.rect.x2), int(area.rect.y2)
+ region = screen[y1:y2, x1:x2]
+
+ # 左右各裁剪30%,只检测中间40%
+ width = region.shape[1]
+ crop_left = int(width * 0.3)
+ crop_right = int(width * 0.7)
+ region = region[:, crop_left:crop_right]
+
+ # 转换为HSV
+ region_hsv = cv2.cvtColor(region, cv2.COLOR_RGB2HSV)
+
+ # 计算S通道(饱和度)的平均值
+ avg_s = float(region_hsv[:, :, 1].mean())
+
+ # 检测平均S通道是否大于20(有彩色)
+ is_colorful = avg_s > 20
+
+ log.debug(f"检测区域 {area_name} 平均S值={avg_s:.1f}, 是否彩色={is_colorful}")
+
+ return is_colorful
+ except Exception as e:
+ log.error(f"检测按钮彩色失败: {e}")
+ return False
+
+ def _combine_and_save(self):
+ """拼接5张截图并保存"""
+ images = [
+ self._img_portrait,
+ self._img_name,
+ self._img_level,
+ self._img_skill,
+ self._img_core
+ ]
+
+ # 检查是否有缺失的截图
+ if any(img is None for img in images):
+ log.error("截图不完整,无法拼接保存")
+ return
+
+ # 上下拼接(使用黑边padding统一宽度)
+ max_width = max(img.shape[1] for img in images)
+
+ padded_images = []
+ for img in images:
+ if img.shape[1] < max_width:
+ pad_left = (max_width - img.shape[1]) // 2
+ pad_right = max_width - img.shape[1] - pad_left
+ padded = cv2.copyMakeBorder(
+ img, 0, 0, pad_left, pad_right,
+ cv2.BORDER_CONSTANT, value=0
+ )
+ padded_images.append(padded)
+ else:
+ padded_images.append(img)
+
+ combined = np.vstack(padded_images)
+
+ # 保存截图
+ self._save_screenshot(combined)
+
+ def _save_screenshot(self, combined: MatLike):
+ """保存拼接后的截图并提交OCR任务"""
+ if self.screenshot_cache is None:
+ return
+
+ try:
+ # 保存到代理人缓存(调试模式下会同时保存到文件)
+ index = self.screenshot_cache.save('agent', combined)
+ self.screenshot_index = index + 1
+
+ # 提交OCR任务(异步处理)
+ if self.ocr_worker is not None:
+ self.ocr_worker.submit('agent', combined, self.parser)
+ except Exception as e:
+ log.error(f"保存截图失败: {e}")
+
+ def _is_end_of_list(self, screen: MatLike) -> bool:
+ """
+ 检测是否到达列表末尾(检测标志点是否纯黑色)
+
+ Args:
+ screen: 当前截图
+ """
+ try:
+ area = self.ctx.screen_loader.get_area('代理人-信息', '代理人-有无标志')
+
+ # 获取标志点中心位置的颜色
+ x, y = int(area.center.x), int(area.center.y)
+ pixel = screen[y, x]
+
+ # 转换为HSV并获取V通道值
+ pixel_bgr = np.array([[pixel]], dtype=np.uint8)
+ pixel_hsv = cv2.cvtColor(pixel_bgr, cv2.COLOR_RGB2HSV)
+ v_value = int(pixel_hsv[0, 0, 2])
+
+ is_black = v_value < 10
+
+ return is_black
+ except Exception as e:
+ log.error(f"检测标志点失败: {e}")
+ return False
+
+ def _is_area_black(self, screen: MatLike, screen_name: str, area_name: str) -> bool:
+ """
+ 检测指定区域是否全黑(通过计算区域平均亮度)
+
+ Args:
+ screen: 当前截图
+ screen_name: 画面名称
+ area_name: 区域名称
+
+ Returns:
+ True 如果区域全黑(平均亮度<10),False 否则
+ """
+ try:
+ area = self.ctx.screen_loader.get_area(screen_name, area_name)
+
+ # 获取整个区域的图像
+ x1, y1 = int(area.rect.x1), int(area.rect.y1)
+ x2, y2 = int(area.rect.x2), int(area.rect.y2)
+ region = screen[y1:y2, x1:x2]
+
+ # 转换为HSV
+ region_hsv = cv2.cvtColor(region, cv2.COLOR_RGB2HSV)
+
+ # 计算V通道的平均值
+ avg_v = int(region_hsv[:, :, 2].mean())
+
+ # 检测平均V通道是否小于10(全黑)
+ is_black = avg_v < 10
+
+ log.debug(f"检测区域 {area_name} 平均V值={avg_v}, 是否黑色={is_black}")
+
+ return is_black
+ except Exception as e:
+ log.error(f"检测区域黑色失败: {e}")
+ return False
diff --git a/src/zzz_od/application/inventory_scan/drive_disk/__init__.py b/src/zzz_od/application/inventory_scan/drive_disk/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/src/zzz_od/application/inventory_scan/drive_disk/drive_disk_scan_app.py b/src/zzz_od/application/inventory_scan/drive_disk/drive_disk_scan_app.py
new file mode 100644
index 0000000000..ff26d67757
--- /dev/null
+++ b/src/zzz_od/application/inventory_scan/drive_disk/drive_disk_scan_app.py
@@ -0,0 +1,302 @@
+import cv2
+import json
+import os
+import time
+import shutil
+from typing import Optional, TYPE_CHECKING
+from one_dragon.utils.log_utils import log
+
+from cv2.typing import MatLike
+
+from one_dragon.base.geometry.point import Point
+from one_dragon.base.operation.application import application_const
+from one_dragon.base.operation.operation_base import OperationResult
+from one_dragon.base.operation.operation_edge import node_from
+from one_dragon.base.operation.operation_node import operation_node
+from one_dragon.base.operation.operation_round_result import OperationRoundResult
+from one_dragon.utils import cv2_utils, os_utils
+from zzz_od.application.inventory_scan.drive_disk import drive_disk_scan_const
+from zzz_od.application.inventory_scan.drive_disk.drive_disk_scan_config import DriveDiskScanConfig
+from zzz_od.application.inventory_scan.parser.drive_disk_parser import DriveDiskParser
+from zzz_od.application.inventory_scan.screenshot_cache import ScreenshotCache
+from zzz_od.application.zzz_application import ZApplication
+from zzz_od.context.zzz_context import ZContext
+
+if TYPE_CHECKING:
+ from zzz_od.application.inventory_scan.ocr_worker import OcrWorker
+
+
+class DriveDiskScanApp(ZApplication):
+
+ def __init__(self, ctx: ZContext, screenshot_cache: Optional[ScreenshotCache] = None,
+ ocr_worker: Optional["OcrWorker"] = None):
+ ZApplication.__init__(
+ self,
+ ctx=ctx,
+ app_id=drive_disk_scan_const.APP_ID,
+ op_name=drive_disk_scan_const.APP_NAME,
+ node_max_retry_times=100000,
+ )
+
+ # 当前位置(行,列)从(0,0)开始,对应界面上的(1,1)
+ self.current_row_idx: int = 0
+ self.current_col_idx: int = 0
+
+ # 网格信息(每次换行时更新)
+ self.grid_rows: list[list[Point]] = [] # 二维网格
+ self.total_scanned: int = 0 # 已扫描总数
+
+ # 截图缓存
+ self.screenshot_cache: Optional[ScreenshotCache] = screenshot_cache
+ self.ocr_worker = ocr_worker
+ # 截图临时文件夹和序号
+ self.screenshots_dir: Optional[str] = None
+ self.screenshot_index: int = 0 # 全局递增序号
+
+ # OCR处理标记
+ self.ocr_processed: bool = False # 防止重复OCR
+
+ # 驱动盘属性解析器
+ self.parser = DriveDiskParser()
+ # 本次扫描的导出文件路径
+ self.export_path: Optional[str] = None
+
+ def execute(self) -> OperationResult:
+ """执行扫描"""
+ try:
+ result = super().execute()
+ return result
+ finally:
+ pass
+
+ def _prepare_screenshots_dir(self):
+ """准备截图临时文件夹"""
+ base_dir = os_utils.get_path_under_work_dir('.debug', 'inventory_screenshots')
+
+ # 如果文件夹存在,清空
+ if os.path.exists(base_dir):
+ shutil.rmtree(base_dir)
+
+ os.makedirs(base_dir, exist_ok=True)
+ self.screenshots_dir = base_dir
+ log.debug(f"截图文件夹已准备: {self.screenshots_dir}")
+
+ def _save_screenshot(self, row: int, col: int, screenshot: MatLike):
+ """保存截图到缓存并提交OCR任务"""
+ if self.screenshot_cache is None:
+ return
+
+ # 裁剪驱动盘仓库区域
+ try:
+ storage_area = self.ctx.screen_loader.get_area('仓库-驱动仓库', '驱动盘属性')
+ cropped = cv2_utils.crop_image_only(screenshot, storage_area.rect)
+
+ # 保存到驱动盘缓存(调试模式下会同时保存到文件)
+ index = self.screenshot_cache.save('drive_disk', cropped)
+ self.screenshot_index = index + 1
+
+ # 提交OCR任务(异步处理)
+ if self.ocr_worker is not None:
+ self.ocr_worker.submit('disc', cropped, self.parser)
+ except Exception as e:
+ log.error(f"保存截图失败({row+1},{col+1}): {e}")
+
+ @operation_node(name='初始化对齐', is_start_node=True)
+ def initialize_align(self) -> OperationRoundResult:
+ """开始前点击(1,1)确保坐标对齐"""
+ screen = self.last_screenshot
+
+ ctx = self.ctx.cv_service.run_pipeline('驱动盘方格', screen)
+ if ctx.error_str is not None:
+ return self.round_success(f'检测失败:{ctx.error_str}')
+
+ if ctx.contours is None or len(ctx.contours) == 0:
+ return self.round_success('未检测到方格')
+
+ # 获取所有方格
+ absolute_rects = ctx.get_absolute_rects()
+ all_grids = []
+ for x1, y1, x2, y2 in absolute_rects:
+ center = Point(x1 + (x2 - x1) / 2, y1 + (y2 - y1) / 2)
+ all_grids.append(center)
+
+ # 网格化排序
+ self.grid_rows = self._sort_grids(all_grids)
+
+ if not self.grid_rows or len(self.grid_rows[0]) == 0:
+ return self.round_success('网格为空')
+
+ # 点击(1,1)确保对齐
+ target = self.grid_rows[0][0]
+ self.ctx.controller.click(target)
+
+ return self.round_success(f'已对齐到(1,1) 检测到{len(self.grid_rows)}行')
+
+ @node_from(from_name='初始化对齐')
+ @node_from(from_name='扫描当前行', status='换行') # 换行后重新检测网格
+ @node_from(from_name='扫描当前行', status='滚动') # 滚动后重新检测网格
+ @operation_node(name='检测网格')
+ def detect_grid(self) -> OperationRoundResult:
+ """检测当前可见的驱动盘网格"""
+ self.ctx.controller.mouse_move(Point(0, 0)) # 移动鼠标到(0,0),避免遮挡方格
+ time.sleep(0.02) # 等待鼠标移动完成
+ screen = self.screenshot() # 重新截图,获取最新的网格布局
+
+ ctx = self.ctx.cv_service.run_pipeline('驱动盘方格', screen)
+ if ctx.error_str is not None:
+ return self.round_success(f'检测失败:{ctx.error_str}')
+
+ if ctx.contours is None or len(ctx.contours) == 0:
+ return self.round_success('未检测到方格')
+
+ # 获取所有方格的绝对坐标中心点
+ absolute_rects = ctx.get_absolute_rects()
+ all_grids = []
+ for x1, y1, x2, y2 in absolute_rects:
+ center = Point(x1 + (x2 - x1) / 2, y1 + (y2 - y1) / 2)
+ all_grids.append(center)
+
+ # 网格化排序
+ self.grid_rows = self._sort_grids(all_grids)
+
+ if not self.grid_rows:
+ return self.round_success('网格为空')
+
+ return self.round_success(f'检测到{len(self.grid_rows)}行 第1行{len(self.grid_rows[0])}列')
+
+ @node_from(from_name='检测网格')
+ @node_from(from_name='扫描当前行', status='换行') # 换行后继续扫描
+ @node_from(from_name='扫描当前行', status='最后一行') # 最后一行继续扫描
+ @operation_node(name='扫描当前行')
+ def scan_current_row(self) -> OperationRoundResult:
+ """扫描当前行的所有格子(内部循环,不经过状态机)"""
+ if not self.grid_rows:
+ return self.round_fail('网格未初始化')
+
+ # 检查是否超出范围
+ if self.current_row_idx >= len(self.grid_rows):
+ log.debug("驱动盘扫描完成")
+ return self.round_success('扫描完成')
+
+ current_row = self.grid_rows[self.current_row_idx]
+ if self.current_col_idx >= len(current_row):
+ log.debug("驱动盘扫描完成")
+ return self.round_success('扫描完成')
+
+ # 同行内循环点击,不经过状态机
+ while self.current_col_idx < len(current_row):
+ # 保存当前截图
+ current_screenshot = self.last_screenshot.copy()
+ self._save_screenshot(self.current_row_idx, self.current_col_idx, current_screenshot)
+ self.total_scanned += 1
+
+ # 计算下一个位置
+ next_col = self.current_col_idx + 1
+
+ # 检查是否到达行尾
+ if next_col >= len(current_row):
+ # 行尾,需要换行处理
+ break
+
+ # 同行点击下一个
+ target = current_row[next_col]
+ log.debug(f"[点击] 同行,点击({self.current_row_idx},{next_col})")
+ self.ctx.controller.click(target)
+ self.current_col_idx = next_col
+
+ # 等待画面更新后重新截图
+ self.screenshot()
+
+ # 行结束,处理换行逻辑
+ next_row = self.current_row_idx + 1
+ next_col = 0
+
+ # 第4行第1个(索引3,0):需要特殊处理
+ if next_row == 3 and next_col == 0:
+ ctx = self.ctx.cv_service.run_pipeline('驱动盘进度条检测', self.last_screenshot)
+ has_progress_bar = ctx.contours is not None and len(ctx.contours) > 0
+
+ if has_progress_bar:
+ # 有进度条:第4行是最后一行
+ if len(self.grid_rows) > 3 and len(self.grid_rows[3]) > 0:
+ target = self.grid_rows[3][0]
+ log.debug(f"[点击] 检测到进度条,点击(3,0)进入最后一行")
+ self.ctx.controller.click(target)
+ self.current_row_idx = 3
+ self.current_col_idx = 0
+ return self.round_success('最后一行')
+ else:
+ log.debug("点击完成,扫描结束")
+ return self.round_success('扫描完成')
+ else:
+ # 无进度条:点击(4,1)触发滚动
+ if len(self.grid_rows) > 3 and len(self.grid_rows[3]) > 0:
+ target = self.grid_rows[3][0]
+ log.debug(f"[点击] 无进度条,点击(3,0)触发滚动")
+ self.ctx.controller.click(target)
+ self.current_row_idx = 2
+ self.current_col_idx = 0
+ return self.round_success('滚动', wait=0.4)
+ else:
+ log.debug("点击完成,扫描结束")
+ return self.round_success('扫描完成')
+
+ # 普通换行
+ if next_row < len(self.grid_rows):
+ if len(self.grid_rows[next_row]) > 0:
+ target = self.grid_rows[next_row][0]
+ log.debug(f"[点击] 换行,点击({next_row},{next_col})")
+ self.ctx.controller.click(target)
+ self.current_row_idx = next_row
+ self.current_col_idx = next_col
+ return self.round_success('换行')
+
+ log.debug("驱动盘扫描完成")
+ return self.round_success('扫描完成')
+
+ def _sort_grids(self, all_disks: list[Point]) -> list[list[Point]]:
+ """将所有方格整理为二维网格"""
+ if not all_disks:
+ return []
+
+ # 按 y 坐标排序
+ sorted_by_y = sorted(all_disks, key=lambda d: d.y)
+
+ rows = []
+ current_row = [sorted_by_y[0]]
+ y_tolerance = 50
+
+ for i in range(1, len(sorted_by_y)):
+ disk = sorted_by_y[i]
+ if abs(disk.y - current_row[0].y) <= y_tolerance:
+ current_row.append(disk)
+ else:
+ current_row.sort(key=lambda d: d.x)
+ rows.append(current_row)
+ current_row = [disk]
+
+ if current_row:
+ current_row.sort(key=lambda d: d.x)
+ rows.append(current_row)
+
+ return rows
+
+ def on_pause(self):
+ """暂停"""
+ super().on_pause()
+
+ def on_resume(self):
+ """恢复"""
+ super().on_resume()
+
+
+def __debug():
+ ctx = ZContext()
+ ctx.init()
+ ctx.run_context.start_running()
+ app = DriveDiskScanApp(ctx)
+ app.execute()
+
+
+if __name__ == '__main__':
+ __debug()
diff --git a/src/zzz_od/application/inventory_scan/drive_disk/drive_disk_scan_config.py b/src/zzz_od/application/inventory_scan/drive_disk/drive_disk_scan_config.py
new file mode 100644
index 0000000000..d78a5ee350
--- /dev/null
+++ b/src/zzz_od/application/inventory_scan/drive_disk/drive_disk_scan_config.py
@@ -0,0 +1,13 @@
+from one_dragon.base.operation.application.application_config import ApplicationConfig
+from zzz_od.application.inventory_scan.drive_disk import drive_disk_scan_const
+
+
+class DriveDiskScanConfig(ApplicationConfig):
+
+ def __init__(self, instance_idx: int, group_id: str):
+ ApplicationConfig.__init__(
+ self,
+ app_id=drive_disk_scan_const.APP_ID,
+ instance_idx=instance_idx,
+ group_id=group_id,
+ )
\ No newline at end of file
diff --git a/src/zzz_od/application/inventory_scan/drive_disk/drive_disk_scan_const.py b/src/zzz_od/application/inventory_scan/drive_disk/drive_disk_scan_const.py
new file mode 100644
index 0000000000..e0be90f399
--- /dev/null
+++ b/src/zzz_od/application/inventory_scan/drive_disk/drive_disk_scan_const.py
@@ -0,0 +1,2 @@
+APP_ID = "drive_disk_scan"
+APP_NAME = "驱动盘自动扫描"
\ No newline at end of file
diff --git a/src/zzz_od/application/inventory_scan/inventory_scan_app.py b/src/zzz_od/application/inventory_scan/inventory_scan_app.py
new file mode 100644
index 0000000000..ba3b6660e9
--- /dev/null
+++ b/src/zzz_od/application/inventory_scan/inventory_scan_app.py
@@ -0,0 +1,235 @@
+from one_dragon.base.operation.application import application_const
+from one_dragon.base.operation.operation_base import OperationResult
+from one_dragon.base.operation.operation_edge import node_from
+from one_dragon.base.operation.operation_node import operation_node
+from one_dragon.base.operation.operation_round_result import OperationRoundResult
+from one_dragon.utils.log_utils import log
+from one_dragon.utils import os_utils
+from zzz_od.application.inventory_scan import inventory_scan_const
+from zzz_od.application.inventory_scan.inventory_scan_config import InventoryScanConfig
+from zzz_od.application.inventory_scan.drive_disk.drive_disk_scan_app import DriveDiskScanApp
+from zzz_od.application.inventory_scan.wengine.wengine_scan_app import WengineScanApp
+from zzz_od.application.inventory_scan.agent.agent_scan_app import AgentScanApp
+from zzz_od.application.inventory_scan.screenshot_cache import ScreenshotCache
+from zzz_od.application.inventory_scan.ocr_worker import OcrWorker
+from zzz_od.application.zzz_application import ZApplication
+from zzz_od.context.zzz_context import ZContext
+from zzz_od.operation.back_to_normal_world import BackToNormalWorld
+import os
+
+
+class InventoryScanApp(ZApplication):
+
+ def __init__(self, ctx: ZContext):
+ ZApplication.__init__(
+ self,
+ ctx=ctx,
+ app_id=inventory_scan_const.APP_ID,
+ op_name=inventory_scan_const.APP_NAME,
+ )
+ targets = getattr(ctx, "_inventory_scan_targets", None) or {}
+ self._scan_drive_disk: bool = bool(targets.get("drive_disk", True))
+ self._scan_wengine: bool = bool(targets.get("wengine", True))
+ self._scan_agent: bool = bool(targets.get("agent", True))
+
+ self.config: InventoryScanConfig = self.ctx.run_context.get_config(
+ app_id=inventory_scan_const.APP_ID,
+ instance_idx=self.ctx.current_instance_idx,
+ group_id=application_const.DEFAULT_GROUP_ID,
+ )
+
+ self.screenshots_dir = os_utils.get_path_under_work_dir('.debug', 'inventory_screenshots')
+ self.screenshot_cache = ScreenshotCache(save_dir=self.screenshots_dir, debug_mode=False)
+
+ self.ocr_worker = OcrWorker(ctx)
+
+ self.drive_disk_scanner = DriveDiskScanApp(ctx, screenshot_cache=self.screenshot_cache, ocr_worker=self.ocr_worker)
+ self.wengine_scanner = WengineScanApp(ctx, screenshot_cache=self.screenshot_cache, ocr_worker=self.ocr_worker)
+ self.agent_scanner = AgentScanApp(ctx, screenshot_cache=self.screenshot_cache, ocr_worker=self.ocr_worker)
+
+ @operation_node(name='开始前返回', is_start_node=True)
+ def back_at_first(self) -> OperationRoundResult:
+ """返回大世界"""
+ op = BackToNormalWorld(self.ctx)
+ return self.round_by_op_result(op.execute())
+
+ @node_from(from_name='开始前返回')
+ @operation_node(name='准备截图文件夹')
+ def prepare_screenshots_dir(self) -> OperationRoundResult:
+ """准备截图文件夹并启动OCR工作线程"""
+ import shutil
+ if os.path.exists(self.screenshots_dir):
+ shutil.rmtree(self.screenshots_dir)
+ self.screenshot_cache.reset_all()
+ self.ocr_worker.reset()
+ self.ocr_worker.start()
+ self.drive_disk_scanner.screenshots_dir = self.screenshots_dir
+ self.wengine_scanner.screenshots_dir = self.screenshots_dir
+ self.agent_scanner.screenshots_dir = self.screenshots_dir
+ return self.round_success('截图文件夹已准备,OCR工作线程已启动')
+
+ @node_from(from_name='准备截图文件夹')
+ @operation_node(name='导航到驱动盘界面')
+ def goto_drive_disk_screen(self) -> OperationRoundResult:
+ """导航到驱动盘界面"""
+ if not self._scan_drive_disk:
+ return self.round_success('跳过驱动盘')
+ return self.round_by_goto_screen(screen_name='仓库-驱动仓库')
+
+ @node_from(from_name='导航到驱动盘界面')
+ @operation_node(name='扫描驱动盘')
+ def scan_drive_disks(self) -> OperationRoundResult:
+ """扫描驱动盘"""
+ if not self._scan_drive_disk:
+ log.debug("已跳过驱动盘扫描")
+ return self.round_success('驱动盘已跳过')
+ log.debug("开始扫描驱动盘...")
+ result = self.drive_disk_scanner.execute()
+ if result.success:
+ log.debug("驱动盘扫描完成")
+ return self.round_success('驱动盘扫描完成')
+ else:
+ log.error(f"驱动盘扫描失败: {result.status}")
+ return self.round_fail(f'驱动盘扫描失败: {result.status}')
+
+ @node_from(from_name='扫描驱动盘')
+ @operation_node(name='导航到音擎界面')
+ def goto_wengine_screen(self) -> OperationRoundResult:
+ """导航到音擎界面"""
+ self.ocr_worker.pause()
+ if not self._scan_wengine:
+ self.ocr_worker.resume()
+ return self.round_success('跳过音擎')
+ result = self.round_by_goto_screen(screen_name='仓库-音擎仓库')
+ if result.is_success or result.is_fail:
+ self.ocr_worker.resume()
+ return result
+
+ @node_from(from_name='导航到音擎界面')
+ @operation_node(name='扫描音擎')
+ def scan_wengines(self) -> OperationRoundResult:
+ """扫描音擎"""
+ if not self._scan_wengine:
+ log.debug("已跳过音擎扫描")
+ return self.round_success('音擎已跳过')
+ log.debug("开始扫描音擎...")
+ result = self.wengine_scanner.execute()
+ if result.success:
+ log.debug("音擎扫描完成")
+ return self.round_success('音擎扫描完成')
+ else:
+ log.error(f"音擎扫描失败: {result.status}")
+ return self.round_fail(f'音擎扫描失败: {result.status}')
+
+ @node_from(from_name='扫描音擎')
+ @operation_node(name='导航到代理人界面', node_max_retry_times=30)
+ def goto_agent_screen(self) -> OperationRoundResult:
+ """导航到代理人界面"""
+ self.ocr_worker.pause()
+ if not self._scan_agent:
+ self.ocr_worker.resume()
+ return self.round_success('跳过角色')
+
+ nav_result = self.round_by_goto_screen(screen_name='代理人-信息')
+ if nav_result.is_fail:
+ self.ocr_worker.resume()
+ if not nav_result.is_success:
+ return nav_result
+
+ screen = self.screenshot()
+ if self._is_button_colorful(screen, '代理人-信息', '按钮-代理人基础'):
+ self.ocr_worker.resume()
+ return self.round_success('已到达代理人界面')
+
+ return self.round_retry(wait=0.1)
+
+ def _is_button_colorful(self, screen, screen_name: str, area_name: str) -> bool:
+ """
+ 检测按钮区域是否出现彩色(中间40%区域)
+ """
+ import cv2
+ try:
+ area = self.ctx.screen_loader.get_area(screen_name, area_name)
+
+ x1, y1 = int(area.rect.x1), int(area.rect.y1)
+ x2, y2 = int(area.rect.x2), int(area.rect.y2)
+ region = screen[y1:y2, x1:x2]
+
+ # 左右各裁剪30%,只检测中间40%
+ width = region.shape[1]
+ crop_left = int(width * 0.3)
+ crop_right = int(width * 0.7)
+ region = region[:, crop_left:crop_right]
+
+ region_hsv = cv2.cvtColor(region, cv2.COLOR_RGB2HSV)
+ avg_s = float(region_hsv[:, :, 1].mean())
+
+ return avg_s > 20
+ except Exception:
+ return False
+
+ @node_from(from_name='导航到代理人界面')
+ @operation_node(name='扫描角色')
+ def scan_agents(self) -> OperationRoundResult:
+ """扫描角色"""
+ if not self._scan_agent:
+ log.debug("已跳过角色扫描")
+ return self.round_success('角色已跳过')
+ log.debug("开始扫描角色...")
+ result = self.agent_scanner.execute()
+ if result.success:
+ log.debug("角色扫描完成")
+ return self.round_success('角色扫描完成')
+ else:
+ log.error(f"角色扫描失败: {result.status}")
+ return self.round_fail(f'角色扫描失败: {result.status}')
+
+ @node_from(from_name='扫描角色')
+ @operation_node(name='等待OCR完成')
+ def wait_ocr_complete(self) -> OperationRoundResult:
+ """等待OCR工作线程完成所有任务"""
+ log.debug("等待OCR处理完成...")
+ self.ocr_worker.wait_complete()
+ self.ocr_worker.stop()
+ self.screenshot_cache.reset_all()
+ log.debug("OCR处理完成,已清空内存缓存")
+
+ return self.round_success('OCR处理完成')
+
+ @node_from(from_name='等待OCR完成')
+ @operation_node(name='导出完整数据')
+ def export_all_data(self) -> OperationRoundResult:
+ """导出所有扫描数据到一个JSON文件"""
+ import json
+ import time
+
+ try:
+ export_dir = os_utils.get_path_under_work_dir('.debug', 'inventory_exports')
+ os.makedirs(export_dir, exist_ok=True)
+
+ timestamp = int(time.time())
+ export_path = os.path.join(export_dir, f'inventory_{timestamp}.json')
+
+ export_data = {
+ 'format': 'ZOD',
+ 'dbVersion': 2,
+ 'source': 'Zenless Optimizer',
+ 'version': 1,
+ 'discs': self.ocr_worker.scanned_discs,
+ 'wengines': self.ocr_worker.scanned_wengines,
+ 'characters': self.ocr_worker.scanned_agents
+ }
+
+ json_str = json.dumps(export_data, indent=2, ensure_ascii=False)
+
+ with open(export_path, 'w', encoding='utf-8') as f:
+ f.write(json_str)
+
+ log.info(f'已导出完整数据到: {export_path}')
+ log.info(f'驱动盘: {len(export_data["discs"])}个, 音擎: {len(export_data["wengines"])}个, 角色: {len(export_data["characters"])}个')
+
+ return self.round_success('完整数据已导出')
+
+ except Exception as e:
+ log.error(f'导出完整数据失败: {e}')
+ return self.round_fail(f'导出失败: {e}')
diff --git a/src/zzz_od/application/inventory_scan/inventory_scan_app_factory.py b/src/zzz_od/application/inventory_scan/inventory_scan_app_factory.py
new file mode 100644
index 0000000000..5914229603
--- /dev/null
+++ b/src/zzz_od/application/inventory_scan/inventory_scan_app_factory.py
@@ -0,0 +1,35 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+from one_dragon.base.operation.application.application_config import ApplicationConfig
+from one_dragon.base.operation.application.application_factory import ApplicationFactory
+from one_dragon.base.operation.application_base import Application
+from zzz_od.application.inventory_scan import inventory_scan_const
+from zzz_od.application.inventory_scan.inventory_scan_app import InventoryScanApp
+from zzz_od.application.inventory_scan.inventory_scan_config import InventoryScanConfig
+
+if TYPE_CHECKING:
+ from zzz_od.context.zzz_context import ZContext
+
+
+class InventoryScanAppFactory(ApplicationFactory):
+
+ def __init__(self, ctx: ZContext):
+ ApplicationFactory.__init__(
+ self,
+ app_id=inventory_scan_const.APP_ID,
+ app_name=inventory_scan_const.APP_NAME,
+ )
+ self.ctx: ZContext = ctx
+
+ def create_application(self, instance_idx: int, group_id: str) -> Application:
+ return InventoryScanApp(self.ctx)
+
+ def create_config(
+ self, instance_idx: int, group_id: str
+ ) -> ApplicationConfig:
+ return InventoryScanConfig(
+ instance_idx=instance_idx,
+ group_id=group_id,
+ )
diff --git a/src/zzz_od/application/inventory_scan/inventory_scan_config.py b/src/zzz_od/application/inventory_scan/inventory_scan_config.py
new file mode 100644
index 0000000000..36c28d3ccd
--- /dev/null
+++ b/src/zzz_od/application/inventory_scan/inventory_scan_config.py
@@ -0,0 +1,13 @@
+from one_dragon.base.operation.application.application_config import ApplicationConfig
+from zzz_od.application.inventory_scan import inventory_scan_const
+
+
+class InventoryScanConfig(ApplicationConfig):
+
+ def __init__(self, instance_idx: int, group_id: str):
+ ApplicationConfig.__init__(
+ self,
+ app_id=inventory_scan_const.APP_ID,
+ instance_idx=instance_idx,
+ group_id=group_id,
+ )
diff --git a/src/zzz_od/application/inventory_scan/inventory_scan_const.py b/src/zzz_od/application/inventory_scan/inventory_scan_const.py
new file mode 100644
index 0000000000..4b6df09b26
--- /dev/null
+++ b/src/zzz_od/application/inventory_scan/inventory_scan_const.py
@@ -0,0 +1,4 @@
+APP_ID = 'inventory_scan'
+APP_NAME = '仓库扫描'
+DEFAULT_GROUP = False
+NEED_NOTIFY = False
diff --git a/src/zzz_od/application/inventory_scan/ocr_worker.py b/src/zzz_od/application/inventory_scan/ocr_worker.py
new file mode 100644
index 0000000000..27a36ef590
--- /dev/null
+++ b/src/zzz_od/application/inventory_scan/ocr_worker.py
@@ -0,0 +1,146 @@
+import threading
+import queue
+from typing import Optional, Any
+from one_dragon.utils.log_utils import log
+from cv2.typing import MatLike
+
+
+class OcrWorker:
+ """
+ OCR工作线程,持续监控队列并处理OCR任务
+ 实现扫描和OCR的并行处理
+ """
+
+ def __init__(self, ctx: Any):
+ """
+ 初始化OCR工作者
+
+ Args:
+ ctx: ZContext上下文,用于访问OCR服务
+ """
+ self.ctx = ctx
+ self._queue: queue.Queue = queue.Queue()
+ self._thread: Optional[threading.Thread] = None
+ self._stop_event = threading.Event()
+ self._pause_event = threading.Event()
+
+ # 结果收集
+ self.scanned_discs: list[dict] = []
+ self.scanned_wengines: list[dict] = []
+ self.scanned_agents: list[dict] = []
+
+ # 统计信息
+ self._processed_count = 0
+ self._error_count = 0
+
+ def start(self):
+ """启动OCR工作线程"""
+ self._stop_event.clear()
+ self._processed_count = 0
+ self._error_count = 0
+ self._thread = threading.Thread(target=self._worker, daemon=True)
+ self._thread.start()
+ log.debug("OCR工作线程已启动")
+
+ def submit(self, task_type: str, screenshot: MatLike, parser: Any):
+ """
+ 提交OCR任务
+
+ Args:
+ task_type: 任务类型 ('disc', 'wengine', 'agent')
+ screenshot: 截图
+ parser: 解析器实例
+ """
+ self._queue.put((task_type, screenshot, parser))
+
+ def wait_complete(self):
+ """等待所有任务完成"""
+ self.resume()
+ self._queue.join()
+ log.debug(f"OCR处理完成: 成功{self._processed_count}个, 失败{self._error_count}个")
+
+ def stop(self):
+ """停止工作线程"""
+ self.resume()
+ self._stop_event.set()
+ if self._thread:
+ self._thread.join(timeout=5)
+ log.debug("OCR工作线程已停止")
+
+ def pause(self):
+ """暂停处理新任务(队列保留)"""
+ self._pause_event.set()
+
+ def resume(self):
+ """恢复处理任务"""
+ self._pause_event.clear()
+
+ @property
+ def is_paused(self) -> bool:
+ return self._pause_event.is_set()
+
+ def reset(self):
+ """重置结果收集"""
+ self.scanned_discs.clear()
+ self.scanned_wengines.clear()
+ self.scanned_agents.clear()
+ self._processed_count = 0
+ self._error_count = 0
+
+ def _worker(self):
+ """工作线程主循环"""
+ while not self._stop_event.is_set():
+ try:
+ if self._pause_event.is_set():
+ self._stop_event.wait(0.05)
+ continue
+
+ task = self._queue.get(timeout=0.1)
+ task_type, screenshot, parser = task
+
+ try:
+ # OCR识别
+ ocr_result = self.ctx.ocr.run_ocr(screenshot)
+ if not ocr_result:
+ log.warning(f"OCR结果为空 ({task_type})")
+ self._error_count += 1
+ self._queue.task_done()
+ continue
+
+ # 转换OCR结果为解析器期望的格式
+ ocr_items = []
+ for text, match_list in ocr_result.items():
+ for match in match_list:
+ ocr_items.append({
+ 'text': text,
+ 'confidence': match.confidence if hasattr(match, 'confidence') else 1.0,
+ 'position': (match.x, match.y, match.x + match.w, match.y + match.h) if hasattr(match, 'x') else None
+ })
+
+ # 解析数据
+ data = parser.parse_ocr_result(ocr_items, screenshot)
+ if data:
+ if task_type == 'disc':
+ self.scanned_discs.append(data)
+ log.debug(f"[OCR] 驱动盘解析成功: {data.get('setKey', 'unknown')}")
+ elif task_type == 'wengine':
+ self.scanned_wengines.append(data)
+ log.debug(f"[OCR] 音擎解析成功: {data.get('key', 'unknown')}")
+ elif task_type == 'agent':
+ self.scanned_agents.append(data)
+ log.debug(f"[OCR] 角色解析成功: {data.get('key', 'unknown')}")
+ self._processed_count += 1
+ else:
+ log.error(f"[OCR] 解析失败 ({task_type})")
+ self._error_count += 1
+
+ except Exception as e:
+ log.error(f"[OCR] 处理任务失败 ({task_type}): {e}", exc_info=True)
+ self._error_count += 1
+
+ self._queue.task_done()
+
+ except queue.Empty:
+ continue
+ except Exception as e:
+ log.error(f"[OCR] 工作线程异常: {e}", exc_info=True)
diff --git a/src/zzz_od/application/inventory_scan/parser/__init__.py b/src/zzz_od/application/inventory_scan/parser/__init__.py
new file mode 100644
index 0000000000..294d4d4c0a
--- /dev/null
+++ b/src/zzz_od/application/inventory_scan/parser/__init__.py
@@ -0,0 +1,3 @@
+from zzz_od.application.inventory_scan.parser.drive_disk_parser import DriveDiskParser
+
+__all__ = ['DriveDiskParser']
diff --git a/src/zzz_od/application/inventory_scan/parser/agent_parser.py b/src/zzz_od/application/inventory_scan/parser/agent_parser.py
new file mode 100644
index 0000000000..97c2e81518
--- /dev/null
+++ b/src/zzz_od/application/inventory_scan/parser/agent_parser.py
@@ -0,0 +1,344 @@
+import re
+import json
+import os
+import cv2
+import time
+from typing import Optional, Dict, Any, List
+from difflib import SequenceMatcher
+from one_dragon.utils.log_utils import log
+from one_dragon.utils import os_utils
+from cv2.typing import MatLike
+
+
+class AgentParser:
+ """代理人数据解析器"""
+
+ def __init__(self):
+ self.agent_counter = 0
+ self.scanned_agent_keys = set() # 记录已扫描的角色key,用于去重
+ # 异常数据保存目录
+ self.error_dir = os_utils.get_path_under_work_dir('.debug', 'inventory_errors')
+ os.makedirs(self.error_dir, exist_ok=True)
+ # 延迟加载翻译服务
+ self._translation_service = None
+
+ @property
+ def translation_service(self):
+ """延迟加载翻译服务"""
+ if self._translation_service is None:
+ from zzz_od.application.inventory_scan.translation.translation_service import TranslationService
+ self._translation_service = TranslationService()
+ return self._translation_service
+
+ def parse_ocr_result(self, ocr_items: List[Dict[str, Any]], screenshot: Optional[MatLike] = None) -> Optional[Dict]:
+ """
+ 解析代理人OCR结果
+
+ Args:
+ ocr_items: OCR识别结果列表
+ screenshot: 截图(用于错误时保存)
+
+ Returns:
+ 解析后的代理人数据字典,如果解析失败则返回None
+ """
+ try:
+ # 收集所有OCR结果(包含置信度)
+ all_results = []
+ for item in ocr_items:
+ text = item.get('text', '')
+ confidence = item.get('confidence', 0)
+ position = item.get('position', (0, 0, 0, 0))
+ if position:
+ x, y = position[0], position[1]
+ else:
+ x, y = 0, 0
+ all_results.append({
+ 'text': text,
+ 'x': x,
+ 'y': y,
+ 'confidence': confidence
+ })
+
+ # 按X轴排序
+ all_results.sort(key=lambda r: r['x'])
+
+ # 解析各个字段(传入截图用于错误保存)
+ agent_name = self._parse_agent_name(all_results, screenshot)
+ if not agent_name:
+ log.error("无法解析代理人名称")
+ return None
+
+ # 检查是否在翻译表中(返回的是角色 code)
+ if self._match_translation(agent_name, screenshot) is None:
+ log.warning(f"角色 {agent_name} 不在翻译表中,跳过")
+ self._save_error(screenshot, f"角色不在翻译表: {agent_name}", all_results)
+ return None
+
+ # 检查是否重复
+ if agent_name in self.scanned_agent_keys:
+ log.warning(f"角色 {agent_name} 已扫描过,跳过重复")
+ return None
+
+ # 记录已扫描
+ self.scanned_agent_keys.add(agent_name)
+
+ agent_level = self._parse_agent_level(all_results)
+ cinema_level = self._parse_cinema_level(all_results)
+ skill_levels = self._parse_skill_levels(all_results)
+ core_skill_level = self._parse_core_skill_level(all_results)
+
+ # 解析level和promotion
+ level_parts = agent_level.split('/')
+ current_level = int(level_parts[0])
+ max_level = int(level_parts[1])
+ promotion = max_level // 10 - 1
+
+ # 解析mindscape
+ mindscape = int(cinema_level.split('/')[0])
+
+ # 展开skills
+ basic = int(skill_levels.get('normal', '1/12').split('/')[0])
+ dodge = int(skill_levels.get('dodge', '1/12').split('/')[0])
+ assist = int(skill_levels.get('assist', '1/12').split('/')[0])
+ special = int(skill_levels.get('special', '1/12').split('/')[0])
+ chain = int(skill_levels.get('chain', '1/12').split('/')[0])
+
+ # 构建代理人数据
+ self.agent_counter += 1
+ agent_data = {
+ 'key': agent_name,
+ 'level': current_level,
+ 'core': core_skill_level,
+ 'mindscape': mindscape,
+ 'dodge': dodge,
+ 'basic': basic,
+ 'chain': chain,
+ 'special': special,
+ 'assist': assist,
+ 'promotion': promotion,
+ 'potential': 0,
+ 'equippedDiscs': {},
+ 'equippedWengine': "",
+ 'id': f'zzz_agent_{self.agent_counter}'
+ }
+
+ log.info(f"解析代理人数据: {agent_data}")
+ return agent_data
+
+ except Exception as e:
+ log.error(f"解析代理人数据失败: {e}", exc_info=True)
+ return None
+
+ def _parse_agent_name(self, results: List[Dict], screenshot: Optional[MatLike] = None) -> Optional[str]:
+ """解析代理人名称(按置信度排序,然后匹配翻译表)"""
+ candidates = []
+ for result in results:
+ text = result['text']
+ confidence = result.get('confidence', 0)
+
+ # 跳过包含"等级"、数字、"/"的文本
+ if '等级' in text or '/' in text:
+ continue
+ # 跳过纯数字
+ if text.replace(' ', '').isdigit():
+ continue
+ # 包含中文字符的可能是名称
+ if any('\u4e00' <= c <= '\u9fff' for c in text):
+ # 使用翻译服务进行文本修正(繁简转换等)
+ corrected_name = self.translation_service.correct_text(text.strip())
+ candidates.append({
+ 'name': corrected_name,
+ 'original': text.strip(),
+ 'confidence': confidence
+ })
+
+ # 按置信度排序
+ if not candidates:
+ return None
+
+ candidates.sort(key=lambda c: c['confidence'], reverse=True)
+
+ # 尝试匹配翻译表
+ for candidate in candidates:
+ ocr_name = candidate['name']
+ matched_key = self._match_translation(ocr_name, screenshot)
+ if matched_key:
+ log.debug(f"OCR识别: {candidate['original']} -> 修正后: {ocr_name} (置信度: {candidate['confidence']:.4f}) -> 匹配到: {matched_key}")
+ return matched_key
+
+ # 如果没有匹配到,返回置信度最高的OCR结果并保存错误
+ best_name = candidates[0]['name']
+ log.warning(f"未在翻译表中找到匹配,使用OCR结果: {best_name} (置信度: {candidates[0]['confidence']:.4f})")
+ self._save_error(screenshot, f"未在翻译表中找到匹配: {best_name}", results)
+ return best_name
+
+ def _match_translation(self, ocr_name: str, screenshot: Optional[MatLike] = None) -> Optional[str]:
+ """
+ 在翻译表中匹配角色名称(使用模糊匹配)
+
+ Args:
+ ocr_name: OCR识别的中文名称(如"浮波 柚叶"或"猫宮又奈")
+ screenshot: 截图(用于错误时保存)
+
+ Returns:
+ 匹配到的英文key(如"Yuzuha"或"Nekomata"),如果没有匹配则返回None
+ """
+ # 使用翻译服务进行翻译(包含模糊匹配)
+ translated = self.translation_service.translate_character(ocr_name, 'EN')
+ character_dict = self.translation_service.translation_dict.get('character', {})
+
+ # 新结构:key 是数字ID,需要在 value 中找 code/EN
+ for _, char_data in character_dict.items():
+ if not isinstance(char_data, dict):
+ continue
+
+ code = char_data.get('code')
+ en_name = char_data.get('EN')
+
+ if translated == code or translated == en_name:
+ return code
+
+ log.warning(f"未找到匹配: {ocr_name}")
+ return None
+
+ def _parse_agent_level(self, results: List[Dict]):
+ """解析代理人等级"""
+ for result in results:
+ text = result['text']
+ # 优先匹配"等级XX/XX"格式(完整等级)
+ match = re.search(r'等级(\d{2,})/(\d{2,})', text)
+ if match:
+ current = int(match.group(1))
+ max_level = int(match.group(2))
+ return f"{current}/{max_level}"
+ # 兼容旧格式,只匹配"等级XX"
+ match = re.search(r'等级(\d{2,})', text)
+ if match:
+ return f"{match.group(1)}/60" # 默认最大等级为60
+ return "1/60" # 默认返回1/60
+
+ def _parse_cinema_level(self, results: List[Dict]) -> str:
+ """解析影画等级(命座)"""
+ for result in results:
+ text = result['text']
+ # 匹配"X/Y"格式
+ if '/' in text and len(text) <= 5:
+ # 清理可能的OCR错误(如O识别成0)
+ cleaned = text.replace('O', '0').replace('o', '0')
+ match = re.search(r'(\d)/(\d)', cleaned)
+ if match:
+ return f"{match.group(1)}/{match.group(2)}"
+ return "0/6"
+
+ def _parse_skill_levels(self, results: List[Dict]) -> Dict[str, str]:
+ """解析技能等级"""
+ # 找到"等级60"和"等级7"的Y坐标
+ level_60_y = None
+ level_7_y = None
+ for result in results:
+ text = result['text']
+ if re.search(r'等级\d{2,}', text):
+ level_60_y = result['y']
+ elif re.search(r'等级\d{1}$', text):
+ level_7_y = result['y']
+
+ if not level_60_y or not level_7_y:
+ log.warning("无法找到技能等级区域的边界")
+ return self._default_skill_levels()
+
+ # 找到Y坐标在两者之间的数字
+ skill_numbers = []
+ for result in results:
+ if level_60_y < result['y'] < level_7_y:
+ text = result['text']
+ # 只保留数字
+ if text.replace('/', '').isdigit():
+ skill_numbers.append({
+ 'text': text,
+ 'x': result['x']
+ })
+
+ # 按X轴排序
+ skill_numbers.sort(key=lambda r: r['x'])
+
+ # 合并所有数字
+ merged = ''.join([n['text'] for n in skill_numbers])
+
+ # 每2个字符作为一个数字
+ numbers = []
+ for i in range(0, len(merged), 2):
+ if i + 1 < len(merged):
+ numbers.append(merged[i:i+2])
+
+ # 每2个数字组成一组
+ skill_names = ['normal', 'dodge', 'assist', 'special', 'chain']
+ skills = {}
+ for i in range(0, min(len(numbers), 10), 2):
+ if i // 2 < len(skill_names):
+ if i + 1 < len(numbers):
+ current = numbers[i]
+ max_level = numbers[i + 1]
+ skills[skill_names[i // 2]] = f"{current}/{max_level}"
+
+ # 如果解析失败,返回默认值
+ if len(skills) != 5:
+ log.warning(f"技能等级解析不完整,只解析到{len(skills)}个技能")
+ return self._default_skill_levels()
+
+ return skills
+
+ def _parse_core_skill_level(self, results: List[Dict]) -> int:
+ """解析核心技能等级"""
+ for result in results:
+ text = result['text']
+ # 匹配"等级X"格式(单个数字)
+ match = re.search(r'等级(\d)$', text)
+ if match:
+ return int(match.group(1))
+ return 0
+
+ def _default_skill_levels(self) -> Dict[str, str]:
+ """返回默认技能等级"""
+ return {
+ 'normal': '1/12',
+ 'dodge': '1/12',
+ 'assist': '1/12',
+ 'special': '1/12',
+ 'chain': '1/12'
+ }
+
+ def _save_error(self, screenshot: Optional[MatLike], error_msg: str, ocr_results: List[Dict]) -> None:
+ """
+ 保存错误截图和相关信息
+
+ Args:
+ screenshot: 截图
+ error_msg: 错误信息
+ ocr_results: OCR识别结果
+ """
+ try:
+ timestamp = time.strftime("%Y%m%d_%H%M%S")
+ error_id = f"agent_{timestamp}_{self.agent_counter}"
+
+ # 保存截图
+ if screenshot is not None:
+ img_path = os.path.join(self.error_dir, f"{error_id}.jpg")
+ # 将 RGB 转换为 BGR 格式(OpenCV 默认格式)
+ bgr_image = cv2.cvtColor(screenshot, cv2.COLOR_RGB2BGR)
+ cv2.imwrite(img_path, bgr_image, [cv2.IMWRITE_JPEG_QUALITY, 85])
+ log.info(f"错误截图已保存: {img_path}")
+
+ # 保存OCR结果和错误信息
+ error_data = {
+ 'error_id': error_id,
+ 'error_msg': error_msg,
+ 'ocr_texts': ocr_results
+ }
+
+ json_path = os.path.join(self.error_dir, f"{error_id}.json")
+ with open(json_path, 'w', encoding='utf-8') as f:
+ json.dump(error_data, f, indent=2, ensure_ascii=False)
+ log.info(f"错误信息已保存: {json_path}")
+
+ except Exception as e:
+ log.error(f"保存错误信息失败: {e}", exc_info=True)
diff --git a/src/zzz_od/application/inventory_scan/parser/drive_disk_parser.py b/src/zzz_od/application/inventory_scan/parser/drive_disk_parser.py
new file mode 100644
index 0000000000..00d482f605
--- /dev/null
+++ b/src/zzz_od/application/inventory_scan/parser/drive_disk_parser.py
@@ -0,0 +1,735 @@
+import json
+import re
+import os
+import cv2
+from typing import Optional, Dict, List, Any
+from one_dragon.utils.log_utils import log
+from one_dragon.utils import os_utils
+
+
+class DriveDiskParser:
+ """驱动盘属性解析器,根据OCR结果生成JSON数据"""
+
+ # 主属性映射(主属性都是百分比,需要加下划线后缀)
+ MAIN_STAT_MAP = {
+ # 英文
+ 'HP': 'hp_',
+ 'ATK': 'atk_',
+ 'DEF': 'def_',
+ 'CRIT Rate': 'crit_',
+ 'CRIT DMG': 'crit_dmg_',
+ 'Anomaly Proficiency': 'anomProf',
+ 'PEN Ratio': 'pen_',
+ 'Impact': 'impact',
+ 'Energy Regen': 'energyRegen_',
+ 'Energy': 'energyRegen_',
+ 'Regen': 'energyRegen_',
+ 'Anomaly Mastery': 'anomMas_',
+ # 中文
+ '生命值': 'hp_',
+ '攻击力': 'atk_',
+ '防御力': 'def_',
+ '暴击率': 'crit_',
+ '暴击伤害': 'crit_dmg_',
+ '异常精通': 'anomProf',
+ '穿透率': 'pen_',
+ '冲击力': 'impact',
+ '能量自动回复': 'energyRegen_',
+ '异常掌控': 'anomMas_',
+ 'Anomaly Mastery': 'anomMas_',
+ # OCR常见错误识别
+ '昇常精通': 'anomProf', # "异常精通"的OCR错误
+ '昇常掌控': 'anomMas_',
+ # 属性伤害加成(新增)
+ '火': 'fire_dmg_',
+ '冰': 'ice_dmg_',
+ '电': 'electric_dmg_',
+ '以太': 'ether_dmg_',
+ '物理': 'physical_dmg_',
+ }
+
+ # 副属性映射
+ SUB_STAT_MAP = {
+ # 英文
+ 'HP': 'hp',
+ 'ATK': 'atk',
+ 'ATK.': 'atk',
+ 'DEF': 'def',
+ 'CRIT Rate': 'crit_',
+ 'CRIT DMG': 'crit_dmg_',
+ 'PEN Ratio': 'pen_',
+ 'PEN': 'pen',
+ 'Anomaly Proficiency': 'anomProf',
+ 'Impact': 'impact',
+ # 中文
+ '生命值': 'hp',
+ '攻击力': 'atk',
+ '防御力': 'def',
+ '暴击率': 'crit_',
+ '暴击伤害': 'crit_dmg_',
+ '穿透率': 'pen_',
+ '穿透值': 'pen',
+ '异常精通': 'anomProf',
+ '冲击力': 'impact',
+ # OCR常见错误识别
+ '昇常精通': 'anomProf', # "异常精通"的OCR错误
+ # 属性伤害加成(新增)
+ '火': 'fire_dmg_',
+ '冰': 'ice_dmg_',
+ '电': 'electric_dmg_',
+ '以太': 'ether_dmg_',
+ '物理': 'physical_dmg_',
+ }
+
+ def __init__(self):
+ self.disc_counter = 0
+ # 初始化翻译服务
+ from zzz_od.application.inventory_scan.translation import TranslationService
+ self.translation_service = TranslationService()
+ # 初始化头像匹配器
+ from zzz_od.application.inventory_scan.utils.agent_icon_matcher import AgentIconMatcher
+ self.icon_matcher = AgentIconMatcher()
+
+ # 异常数据保存目录
+ self.error_dir = os_utils.get_path_under_work_dir('.debug', 'inventory_errors')
+ os.makedirs(self.error_dir, exist_ok=True)
+
+ def parse_ocr_result(self, ocr_texts: List[Dict[str, Any]], screenshot=None, index: int = 0) -> Optional[Dict]:
+ """
+ 解析OCR结果生成驱动盘JSON数据
+
+ Args:
+ ocr_texts: OCR识别结果列表,每项包含 text, confidence, position 等信息
+ position格式: (x1, y1, x2, y2)
+ screenshot: 原始截图(用于异常保存)
+ index: 截图索引(用于异常保存)
+
+ Returns:
+ 驱动盘JSON数据字典,解析失败返回None
+ """
+ try:
+ # 保留完整的OCR项(包含位置信息)
+ ocr_items = []
+ for item in ocr_texts:
+ if isinstance(item, dict):
+ ocr_items.append(item)
+ else:
+ ocr_items.append({'text': str(item)})
+
+ # 1. 预处理:按阅读顺序排序 OCR 结果 (从上到下,从左到右)
+ # OCR结果可能是乱序的,需要重新排序以确保 texts 列表符合阅读习惯
+ ocr_items = self._sort_ocr_items(ocr_items)
+
+ # 2. 生成有序的文本列表,并进行错别字修正
+ texts = []
+ for item in ocr_items:
+ original_text = item.get('text', '')
+ corrected_text = self.translation_service.correct_text(original_text)
+
+ # 更新 OCR 结果中的文本
+ item['text'] = corrected_text
+ texts.append(corrected_text)
+
+ # 解析套装名称(通常在前两个文本中)
+ set_key = self._parse_set_name(texts)
+
+ # 解析位置([1] [2] [3] [4] [5] [6])
+ slot_key = self._parse_slot(texts)
+
+ # 解析等级(Lv. 15/15)
+ level = self._parse_level(texts)
+
+ # 解析主属性(根据位置判断是否百分比)
+ main_stat_key, main_stat_value = self._parse_main_stat(texts, slot_key)
+
+ # 解析副属性(需要位置信息来匹配升级标记,且需要排除主属性)
+ substats = self._parse_substats(ocr_items, main_stat_key)
+
+ # **异常检测:15级驱动盘应该有4个不同种类的副属性**
+ if level == 15:
+ # 检查副属性种类数量
+ unique_keys = set(sub['key'] for sub in substats)
+ if len(unique_keys) < 4:
+ error_msg = f"异常检测:15级驱动盘只有{len(unique_keys)}种副属性(应该有4种不同的副属性)"
+ log.error(error_msg)
+ log.error(f"副属性列表: {substats}")
+ self._save_error_data(screenshot, ocr_texts, texts, level, substats, index, error_msg)
+ # 仍然返回数据,但已记录异常
+ # return None # 如果要阻止这个驱动盘被添加,取消注释这行
+
+ # 匹配头像
+ agent_key = ""
+ if screenshot is not None:
+ # 头像区域(修正后)(54, 54) - (85, 85)
+ if self.icon_matcher.is_region_colorful(screenshot, 54, 54, 85, 85):
+ agent_key = self.icon_matcher.match_agent_icon(screenshot, 54, 54, 85, 85)
+
+ # 生成驱动盘数据
+ self.disc_counter += 1
+ disc_data = {
+ 'setKey': set_key,
+ 'rarity': 'S', # 默认S级,可以根据RARITY字段判断
+ 'level': level,
+ 'slotKey': slot_key,
+ 'mainStatKey': main_stat_key,
+ 'substats': substats,
+ 'location': agent_key,
+ 'lock': False,
+ 'trash': False,
+ 'id': f'zzz_disc_{self.disc_counter}'
+ }
+
+ return disc_data
+
+ except Exception as e:
+ log.error(f"解析OCR结果失败: {e}")
+ if screenshot is not None:
+ self._save_error_data(screenshot, ocr_texts, texts if 'texts' in locals() else [], 0, [], index, str(e))
+ return None
+
+ def _parse_set_name(self, texts: List[str]) -> str:
+ """解析套装名称"""
+ # 找到包含[数字]的文本,提取套装名称
+ set_name = None
+
+ for text in texts:
+ # 检查是否包含[数字]
+ if re.search(r'\[\d\]', text):
+ # 找到了[数字],提取[数字]前的文字
+ match = re.match(r'(.+?)\s*\[\d\]', text)
+ if match:
+ set_name = match.group(1).strip()
+ break
+
+ if not set_name:
+ set_name = texts[0] if texts else 'Unknown'
+
+ # 使用translation服务翻译成英文
+ en_name = self.translation_service.translate_equipment(set_name, 'EN')
+
+ # 转换成驼峰命名key(移除空格)
+ set_key = en_name.replace(' ', '')
+
+ return set_key
+
+ def _parse_slot(self, texts: List[str]) -> str:
+ """解析装备位置"""
+ for text in texts:
+ match = re.search(r'\[(\d)\]', text)
+ if match:
+ return match.group(1)
+ # 单独的数字1-6
+ if text.strip() in ['1', '2', '3', '4', '5', '6']:
+ return text.strip()
+ return '1'
+
+ def _parse_level(self, texts: List[str]) -> int:
+ """解析等级"""
+ for text in texts:
+ # 匹配中文格式: 等级15/15
+ match = re.search(r'等级\s*(\d+)', text)
+ if match:
+ return int(match.group(1))
+ # 匹配英文格式: Lv. 15/15 或 Lv.15
+ match = re.search(r'Lv\.?\s*(\d+)', text, re.IGNORECASE)
+ if match:
+ return int(match.group(1))
+ return 0
+
+ def _parse_main_stat(self, texts: List[str], slot_key: str) -> tuple[str, Optional[float]]:
+ """
+ 解析主属性
+ 位置1-3:固定值(无下划线)- HP, ATK, DEF
+ 位置4-6:百分比(有下划线)- HP%, ATK%, DEF%, CRIT Rate%, etc.
+ """
+ main_stat_key = None
+ main_stat_value = None
+
+ # 找到 "Main Stat" 或 "主属性" 关键字
+ main_stat_idx = -1
+ for i, text in enumerate(texts):
+ if 'Main Stat' in text or 'Main' in text or '主属性' in text:
+ main_stat_idx = i
+ break
+
+ if main_stat_idx >= 0:
+ # 主属性名称通常在下一行
+ for i in range(main_stat_idx + 1, min(main_stat_idx + 3, len(texts))):
+ for key in self.MAIN_STAT_MAP.keys():
+ if key in texts[i]:
+ # 根据位置判断是否百分比
+ value = self.MAIN_STAT_MAP[key]
+ if slot_key in ['1', '2', '3']:
+ # 位置1-3:固定值,移除下划线
+ # hp_ → hp, atk_ → atk, def_ → def
+ main_stat_key = value.rstrip('_')
+ else:
+ # 位置4-6:百分比,保持原样
+ # hp_ → hp_, crit_ → crit_, etc.
+ main_stat_key = value
+ break
+ if main_stat_key:
+ break
+
+ # 主属性值通常是百分比或数字
+ for i in range(main_stat_idx + 1, min(main_stat_idx + 5, len(texts))):
+ match = re.search(r'(\d+(?:\.\d+)?)%?', texts[i])
+ if match and texts[i] not in self.MAIN_STAT_MAP:
+ main_stat_value = float(match.group(1))
+ break
+
+ # 默认值处理:如果解析失败,根据位置返回默认值
+ if main_stat_key is None:
+ main_stat_key = 'hp' if slot_key in ['1', '2', '3'] else 'hp_'
+
+ return main_stat_key, main_stat_value
+
+ def _parse_substats(self, ocr_items: List[Dict], main_stat_key: str) -> List[Dict]:
+ """
+ 解析副属性
+ 副属性不能和主属性重复!需要过滤掉主属性
+
+ 使用位置信息将文本分组到行,然后解析每一行
+ """
+ # 找到 "Sub-Stats" 或 "副属性" 关键字
+ sub_stat_item = None
+ for item in ocr_items:
+ text = item.get('text', '')
+ if 'Sub-Stats' in text or 'Sub' in text or '副属性' in text:
+ sub_stat_item = item
+ break
+
+ if sub_stat_item is None:
+ return []
+
+ # 获取"副属性"标签的Y坐标
+ sub_stat_position = sub_stat_item.get('position')
+ if sub_stat_position is None:
+ # 如果没有位置信息,回退到原来的索引方式
+ return self._parse_substats_by_index(ocr_items, main_stat_key)
+
+ sub_stat_y = sub_stat_position[1] # Y坐标(top)
+
+ # 筛选出Y坐标大于"副属性"标签的所有文本(即在"副属性"下方的文本)
+ sub_items = []
+ for item in ocr_items:
+ position = item.get('position')
+ if position and position[1] > sub_stat_y: # Y坐标大于"副属性"
+ sub_items.append(item)
+
+ if not sub_items:
+ return []
+
+ # 按Y坐标分组(Y坐标差异小于30的认为是同一行)
+ rows = []
+ current_row = [sub_items[0]]
+
+ for i in range(1, len(sub_items)):
+ item = sub_items[i]
+ prev_item = sub_items[i-1]
+
+ # 获取Y坐标
+ curr_y = item.get('position', [0, 0])[1]
+ prev_y = prev_item.get('position', [0, 0])[1]
+
+ # 如果Y坐标差异小于30,认为是同一行
+ if abs(curr_y - prev_y) < 30:
+ current_row.append(item)
+ else:
+ # 新的一行
+ rows.append(current_row)
+ current_row = [item]
+
+ # 添加最后一行
+ if current_row:
+ rows.append(current_row)
+
+ # 解析每一行
+ substats = []
+ for row in rows:
+ # 按X坐标排序(从左到右)
+ row.sort(key=lambda x: x.get('position', [0, 0])[0])
+
+ # 提取文本
+ row_texts = [item.get('text', '') for item in row]
+
+ # 第一个文本通常是属性名
+ if not row_texts:
+ continue
+
+ first_text = row_texts[0].strip()
+ stat_key = None
+ base_text = first_text
+ upgrades = 0
+
+ # 尝试从第一个文本中提取升级标记
+ match = re.search(r'(.+?)\s*\+(\d+)', first_text)
+ if match:
+ base_text = match.group(1).strip()
+ upgrades = int(match.group(2))
+
+ # 匹配属性名
+ # 1. 先尝试完全匹配(去除空格后)
+ base_text_normalized = base_text.replace(' ', '').replace('\t', '')
+ for key, value in self.SUB_STAT_MAP.items():
+ key_normalized = key.replace(' ', '').replace('\t', '')
+ if base_text_normalized == key_normalized:
+ stat_key = value
+ break
+
+ # 2. 如果完全匹配失败,尝试子串匹配
+ if not stat_key:
+ for key, value in self.SUB_STAT_MAP.items():
+ if key in base_text:
+ stat_key = value
+ break
+
+ if not stat_key:
+ continue
+
+ # 如果第一个文本中没有升级标记,检查第二个文本是否是升级标记
+ if upgrades == 0 and len(row_texts) > 1:
+ second_text = row_texts[1].strip()
+ upgrade_match = re.match(r'^\+(\d+)$', second_text)
+ if upgrade_match:
+ upgrades = int(upgrade_match.group(1))
+
+ # 副属性默认有1次升级,加上额外升级次数
+ upgrades += 1
+
+ # 判断HP/ATK/DEF是否是百分比
+ # 查找行中的数值(最后一个文本通常是数值)
+ if stat_key in ['hp', 'atk', 'def']:
+ is_percent = False
+ for text in row_texts:
+ if re.match(r'^\d+(?:\.\d+)?%$', text.strip()):
+ is_percent = True
+ break
+
+ if is_percent:
+ stat_key += '_'
+
+ # **关键过滤:副属性不能和主属性重复!**
+ if stat_key != main_stat_key:
+ substats.append({
+ 'key': stat_key,
+ 'upgrades': upgrades
+ })
+
+ return substats
+
+ def _parse_substats_by_index(self, ocr_items: List[Dict], main_stat_key: str) -> List[Dict]:
+ """
+ 解析副属性
+ 副属性不能和主属性重复!需要过滤掉主属性
+
+ OCR结果按Y坐标排序,相邻的文本属于同一行,通过顺序合并判断百分比
+ """
+ # 找到 "Sub-Stats" 或 "副属性" 关键字的索引
+ sub_stat_idx = -1
+ texts = []
+ for i, item in enumerate(ocr_items):
+ text = item.get('text', '')
+ texts.append(text)
+ if 'Sub-Stats' in text or 'Sub' in text or '副属性' in text:
+ sub_stat_idx = i
+
+ if sub_stat_idx < 0:
+ return []
+
+ # 提取Sub-Stats后的所有文本
+ sub_texts = texts[sub_stat_idx + 1:]
+
+ substats = []
+ i = 0
+ while i < len(sub_texts):
+ text = sub_texts[i].strip()
+
+ # 检查是否是属性名(可能包含升级标记)
+ stat_key = None
+ base_text = text
+ upgrades = 0
+
+ # 先尝试从文本中提取升级标记
+ match = re.search(r'(.+?)\s*\+(\d+)', text)
+ if match:
+ base_text = match.group(1).strip()
+ upgrades = int(match.group(2))
+
+ # 匹配属性名 - 改进匹配逻辑
+ # 1. 先尝试完全匹配(去除空格后)
+ base_text_normalized = base_text.replace(' ', '').replace('\t', '')
+ for key, value in self.SUB_STAT_MAP.items():
+ key_normalized = key.replace(' ', '').replace('\t', '')
+ if base_text_normalized == key_normalized:
+ stat_key = value
+ break
+
+ # 2. 如果完全匹配失败,尝试子串匹配
+ if not stat_key:
+ for key, value in self.SUB_STAT_MAP.items():
+ if key in base_text:
+ stat_key = value
+ break
+
+ if stat_key:
+ # 如果文本中没有升级标记,向后查找直到找到升级标记或数值
+ if upgrades == 0:
+ j = i + 1
+ while j < len(sub_texts):
+ next_text = sub_texts[j].strip()
+
+ # 检查是否是独立的升级标记
+ upgrade_match = re.match(r'^\+(\d+)$', next_text)
+ if upgrade_match:
+ upgrades = int(upgrade_match.group(1))
+ i = j # 更新索引,跳过已处理的文本
+ break
+
+ # 检查是否是纯数字或百分比数字(表示已到数值)
+ if re.match(r'^\d+(?:\.\d+)?%?$', next_text):
+ # 到达数值了,不再是升级标记
+ break
+
+ j += 1
+
+ # 副属性默认有1次升级,加上额外升级次数
+ # 例如:+0表示1次,+1表示2次,+4表示5次
+ upgrades += 1
+
+ # 判断HP/ATK/DEF是否是百分比
+ # 向后查找直到遇到纯数字或百分比数字
+ if stat_key in ['hp', 'atk', 'def']:
+ is_percent = False
+ j = i + 1
+ while j < len(sub_texts):
+ next_text = sub_texts[j].strip()
+
+ # 检查是否是纯数字或百分比数字
+ if re.match(r'^\d+(?:\.\d+)?%?$', next_text):
+ # 找到数值,判断是否包含%
+ if '%' in next_text:
+ is_percent = True
+ break
+
+ j += 1
+
+ if is_percent:
+ stat_key += '_'
+
+ # **关键过滤:副属性不能和主属性重复!**
+ if stat_key != main_stat_key:
+ substats.append({
+ 'key': stat_key,
+ 'upgrades': upgrades
+ })
+
+ i += 1
+
+ return substats
+
+ def _save_error_data(self, screenshot, ocr_texts: List[Dict], texts: List[str],
+ level: int, substats: List[Dict], index: int, error_msg: str):
+ """
+ 保存异常数据到文件
+
+ Args:
+ screenshot: 原始截图
+ ocr_texts: OCR识别结果
+ texts: 提取的文本列表
+ level: 驱动盘等级
+ substats: 解析出的副属性
+ index: 截图索引
+ error_msg: 错误信息
+ """
+ try:
+ import time
+ timestamp = time.strftime("%Y%m%d_%H%M%S")
+ error_id = f"{timestamp}_index{index}"
+
+ # 保存截图
+ if screenshot is not None:
+ img_path = os.path.join(self.error_dir, f"{error_id}.jpg")
+ # 将 RGB 转换为 BGR 格式(OpenCV 默认格式)
+ bgr_image = cv2.cvtColor(screenshot, cv2.COLOR_RGB2BGR)
+ cv2.imwrite(img_path, bgr_image)
+ log.info(f"异常截图已保存: {img_path}")
+
+ # 保存OCR结果和错误信息
+ error_data = {
+ 'error_id': error_id,
+ 'index': index,
+ 'error_msg': error_msg,
+ 'level': level,
+ 'substats': substats,
+ 'unique_substat_count': len(set(sub['key'] for sub in substats)),
+ 'texts': texts,
+ 'ocr_texts': ocr_texts
+ }
+
+ json_path = os.path.join(self.error_dir, f"{error_id}.json")
+ with open(json_path, 'w', encoding='utf-8') as f:
+ json.dump(error_data, f, indent=2, ensure_ascii=False)
+ log.info(f"异常数据已保存: {json_path}")
+
+ except Exception as e:
+ log.error(f"保存异常数据失败: {e}")
+
+ def _sort_ocr_items(self, ocr_items: List[Dict]) -> List[Dict]:
+ """
+ 对OCR结果进行排序:从上到下,同一行从左到右
+ """
+ if not ocr_items:
+ return []
+
+ # 辅助函数:获取Y坐标
+ def get_y(item):
+ pos = item.get('position')
+ return pos[1] if pos else 99999
+
+ def get_x(item):
+ pos = item.get('position')
+ return pos[0] if pos else 0
+
+ # 1. 初步按Y坐标排序
+ # 这一步是为了让相邻行的元素大体在一起
+ ocr_items.sort(key=get_y)
+
+ # 2. 分行并按X坐标排序
+ sorted_items = []
+ current_row = [ocr_items[0]]
+
+ # 行高阈值,参考原代码中的30,这里设为20更严格一点,避免跨行
+ ROW_THRESHOLD = 20
+
+ for i in range(1, len(ocr_items)):
+ item = ocr_items[i]
+ # 与当前行第一个元素比较Y坐标
+ # 注意:这里假设行首元素代表了该行的"标准"高度
+ if abs(get_y(item) - get_y(current_row[0])) < ROW_THRESHOLD:
+ current_row.append(item)
+ else:
+ # 结束当前行
+ # 行内按X坐标排序
+ current_row.sort(key=get_x)
+ sorted_items.extend(current_row)
+ # 新起一行
+ current_row = [item]
+
+ # 处理最后一行
+ if current_row:
+ current_row.sort(key=get_x)
+ sorted_items.extend(current_row)
+
+ return sorted_items
+
+ def generate_export_json(self, discs: List[Dict]) -> str:
+ """
+ 生成导出的JSON字符串
+
+ Args:
+ discs: 驱动盘数据列表
+
+ Returns:
+ 格式化的JSON字符串
+ """
+ export_data = {
+ 'format': 'ZOD',
+ 'dbVersion': 2,
+ 'source': 'Zenless Optimizer',
+ 'version': 1,
+ 'discs': discs
+ }
+
+ return json.dumps(export_data, indent=2, ensure_ascii=False)
+
+
+def test_ocr_first_image():
+ """测试OCR第一个驱动盘图片"""
+ import sys
+ import os
+
+ # 设置启动路径
+ project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..', '..', '..', '..'))
+ src_path = os.path.join(project_root, 'src')
+ if src_path not in sys.path:
+ sys.path.insert(0, src_path)
+
+ import cv2
+ from one_dragon.utils import os_utils
+ from zzz_od.context.zzz_context import ZContext
+
+ # 初始化上下文
+ ctx = ZContext()
+ ctx.init()
+
+ # 获取第一个截图路径
+ screenshots_dir = os_utils.get_path_under_work_dir('.debug', 'inventory_screenshots')
+ image_path = os.path.join(screenshots_dir, 'drive_disk-0.jpg')
+
+ if not os.path.exists(image_path):
+ print(f"图片不存在: {image_path}")
+ return
+
+ # 读取图片
+ screenshot = cv2.imread(image_path)
+ if screenshot is None:
+ print(f"读取图片失败: {image_path}")
+ return
+
+ print(f"正在OCR图片: {image_path}")
+ print(f"图片尺寸: {screenshot.shape}")
+
+ # 直接OCR
+ ocr_result = ctx.ocr.run_ocr_single_line(screenshot)
+
+ print("\n=== OCR结果 ===")
+ if not ocr_result:
+ print("OCR结果为空")
+ return
+
+ for idx, item in enumerate(ocr_result):
+ if isinstance(item, str):
+ print(f"{idx}: {item}")
+ elif isinstance(item, dict):
+ print(f"{idx}: {item}")
+ else:
+ text = item.text if hasattr(item, 'text') else str(item)
+ score = item.score if hasattr(item, 'score') else 'N/A'
+ print(f"{idx}: {text} (confidence: {score})")
+
+ # 解析结果
+ print("\n=== 解析结果 ===")
+ parser = DriveDiskParser()
+
+ # 转换OCR结果
+ ocr_items = []
+ for item in ocr_result:
+ if isinstance(item, str):
+ ocr_items.append({'text': item, 'confidence': 1.0, 'position': None})
+ elif isinstance(item, dict):
+ ocr_items.append(item)
+ else:
+ box = item.box if hasattr(item, 'box') else None
+ if box and len(box) >= 4:
+ x1, y1 = min(p[0] for p in box), min(p[1] for p in box)
+ x2, y2 = max(p[0] for p in box), max(p[1] for p in box)
+ position = (x1, y1, x2, y2)
+ else:
+ position = None
+ ocr_items.append({
+ 'text': item.text if hasattr(item, 'text') else str(item),
+ 'confidence': item.score if hasattr(item, 'score') else 1.0,
+ 'position': position
+ })
+
+ disc_data = parser.parse_ocr_result(ocr_items)
+ if disc_data:
+ import json
+ print(json.dumps(disc_data, indent=2, ensure_ascii=False))
+ else:
+ print("解析失败")
+
+
+if __name__ == '__main__':
+ test_ocr_first_image()
diff --git a/src/zzz_od/application/inventory_scan/parser/wengine_parser.py b/src/zzz_od/application/inventory_scan/parser/wengine_parser.py
new file mode 100644
index 0000000000..d52e2ede1f
--- /dev/null
+++ b/src/zzz_od/application/inventory_scan/parser/wengine_parser.py
@@ -0,0 +1,191 @@
+import re
+import json
+import os
+import time
+from typing import Optional, Dict, List, Any
+import cv2
+from cv2.typing import MatLike
+
+from one_dragon.utils.log_utils import log
+from one_dragon.utils import os_utils
+
+
+class WengineParser:
+ """音擎属性解析器"""
+
+ def __init__(self):
+ self.wengine_counter = 0
+ from zzz_od.application.inventory_scan.translation import TranslationService
+ self.translation_service = TranslationService()
+ from zzz_od.application.inventory_scan.utils.agent_icon_matcher import AgentIconMatcher
+ self.icon_matcher = AgentIconMatcher()
+ # 异常数据保存目录
+ self.error_dir = os_utils.get_path_under_work_dir('.debug', 'inventory_errors')
+ os.makedirs(self.error_dir, exist_ok=True)
+
+ def parse_ocr_result(self, ocr_items: List[Dict[str, Any]], screenshot: MatLike) -> Optional[Dict]:
+ """
+ 解析OCR结果生成音擎JSON数据
+
+ Args:
+ ocr_items: OCR识别结果列表
+ screenshot: 原始截图(用于灰度检测)
+
+ Returns:
+ 音擎JSON数据字典,解析失败返回None
+ """
+ try:
+ texts = [item.get('text', '') for item in ocr_items]
+
+ # 解析音擎名称
+ wengine_key = self._parse_wengine_name(texts)
+
+ # 解析等级和突破等级
+ level, promotion = self._parse_level_and_promotion(texts)
+
+ # 解析精炼等级(灰度检测)
+ modification = self._parse_modification(screenshot)
+
+ # 匹配头像
+ agent_key = ""
+ # 头像区域(修正后)(54, 54) - (85, 85)
+ if self.icon_matcher.is_region_colorful(screenshot, 54, 54, 85, 85):
+ agent_key = self.icon_matcher.match_agent_icon(screenshot, 54, 54, 85, 85)
+
+ # 生成音擎数据
+ self.wengine_counter += 1
+ wengine_data = {
+ 'key': wengine_key,
+ 'level': level,
+ 'modification': modification,
+ 'promotion': promotion,
+ 'location': agent_key,
+ 'lock': False,
+ 'id': f'zzz_wengine_{self.wengine_counter}'
+ }
+
+ return wengine_data
+
+ except Exception as e:
+ log.error(f"解析OCR结果失败: {e}", exc_info=True)
+ # 保存错误截图
+ if screenshot is not None:
+ self._save_error(screenshot, f"解析异常: {e}", ocr_items)
+ return None
+
+ def _save_error(self, screenshot: MatLike, error_msg: str, ocr_results: List[Dict]) -> None:
+ """
+ 保存错误截图和相关信息
+
+ Args:
+ screenshot: 截图
+ error_msg: 错误信息
+ ocr_results: OCR识别结果
+ """
+ try:
+ timestamp = time.strftime("%Y%m%d_%H%M%S")
+ error_id = f"wengine_{timestamp}_{self.wengine_counter}"
+
+ # 保存截图
+ if screenshot is not None:
+ img_path = os.path.join(self.error_dir, f"{error_id}.jpg")
+ # 将 RGB 转换为 BGR 格式(OpenCV 默认格式)
+ bgr_image = cv2.cvtColor(screenshot, cv2.COLOR_RGB2BGR)
+ cv2.imwrite(img_path, bgr_image, [cv2.IMWRITE_JPEG_QUALITY, 85])
+ log.info(f"错误截图已保存: {img_path}")
+
+ # 保存OCR结果和错误信息
+ error_data = {
+ 'error_id': error_id,
+ 'error_msg': error_msg,
+ 'ocr_texts': ocr_results
+ }
+
+ json_path = os.path.join(self.error_dir, f"{error_id}.json")
+ with open(json_path, 'w', encoding='utf-8') as f:
+ json.dump(error_data, f, indent=2, ensure_ascii=False)
+ log.info(f"错误信息已保存: {json_path}")
+
+ except Exception as e:
+ log.error(f"保存错误信息失败: {e}", exc_info=True)
+
+ def _parse_wengine_name(self, texts: List[str]) -> str:
+ """解析音擎名称"""
+ # 音擎名称通常是第一个文本
+ wengine_name = texts[0] if texts else 'Unknown'
+
+ # 使用translation服务翻译成英文
+ en_name = self.translation_service.translate_weapon(wengine_name, 'EN')
+
+ # 转换成驼峰命名key(移除空格)
+ wengine_key = en_name.replace(' ', '')
+
+ return wengine_key
+
+ def _parse_level_and_promotion(self, texts: List[str]) -> tuple[int, int]:
+ """
+ 解析等级和突破等级
+ 从"等级A/B"中提取:level=A, promotion=B/10-1
+ """
+ level = 0
+ promotion = 0
+
+ for text in texts:
+ # 匹配中文格式: 等级60/60
+ match = re.search(r'等级\s*(\d+)/(\d+)', text)
+ if match:
+ level = int(match.group(1))
+ max_level = int(match.group(2))
+ promotion = max_level // 10 - 1
+ break
+ # 匹配英文格式: Lv. 60/60
+ match = re.search(r'Lv\.?\s*(\d+)/(\d+)', text, re.IGNORECASE)
+ if match:
+ level = int(match.group(1))
+ max_level = int(match.group(2))
+ promotion = max_level // 10 - 1
+ break
+
+ return level, promotion
+
+ def _parse_modification(self, screenshot: MatLike) -> int:
+ """
+ 解析精炼等级(通过灰度检测)
+ 检测5个点的灰度值,相邻差异≤20则继续计数
+ """
+ # 转换为灰度图
+ if len(screenshot.shape) == 3:
+ gray = cv2.cvtColor(screenshot, cv2.COLOR_BGR2GRAY)
+ else:
+ gray = screenshot
+
+ # 检测5个点的灰度值
+ gray_values = []
+ for i in range(5):
+ x = 260 + i * 30
+ y = 160
+ gray_value = int(gray[y, x])
+ gray_values.append(gray_value)
+
+ # 从第一个点开始,相邻点差异≤20则继续,>20则停止
+ modification = 1 # 至少有1个星星
+ for i in range(1, 5):
+ diff = abs(gray_values[i] - gray_values[i-1])
+ if diff <= 20:
+ modification += 1
+ else:
+ break
+
+ return modification
+
+ def generate_export_json(self, wengines: List[Dict]) -> str:
+ """生成导出的JSON字符串"""
+ import json
+ export_data = {
+ 'format': 'ZOD',
+ 'dbVersion': 2,
+ 'source': 'Zenless Optimizer',
+ 'version': 1,
+ 'wengines': wengines
+ }
+ return json.dumps(export_data, indent=2, ensure_ascii=False)
diff --git a/src/zzz_od/application/inventory_scan/screenshot_cache.py b/src/zzz_od/application/inventory_scan/screenshot_cache.py
new file mode 100644
index 0000000000..d76e452912
--- /dev/null
+++ b/src/zzz_od/application/inventory_scan/screenshot_cache.py
@@ -0,0 +1,196 @@
+import os
+import cv2
+from typing import Optional
+from cv2.typing import MatLike
+from one_dragon.utils import cv2_utils, os_utils
+from one_dragon.utils.log_utils import log
+
+
+class ScreenshotCache:
+ """截图缓存管理器,支持多种类型的独立缓存"""
+
+ def __init__(self, save_dir: Optional[str] = None, debug_mode: bool = False):
+ """
+ 初始化截图缓存
+
+ Args:
+ save_dir: 截图保存目录,None表示不保存到文件
+ debug_mode: 调试模式,True时保存到文件,False时只缓存到内存
+ """
+ self.save_dir = save_dir
+ self.debug_mode = debug_mode
+
+ # 3个独立的缓存
+ self._drive_disk_cache: dict[int, MatLike] = {}
+ self._wengine_cache: dict[int, MatLike] = {}
+ self._agent_cache: dict[int, MatLike] = {}
+
+ # 3个独立的索引计数器
+ self._drive_disk_index = 0
+ self._wengine_index = 0
+ self._agent_index = 0
+
+ # 缓存类型到字典的映射
+ self._cache_map = {
+ 'drive_disk': self._drive_disk_cache,
+ 'wengine': self._wengine_cache,
+ 'agent': self._agent_cache
+ }
+
+ # 缓存类型到文件名前缀的映射
+ self._prefix_map = {
+ 'drive_disk': 'drive_disk',
+ 'wengine': 'wengine',
+ 'agent': 'agent'
+ }
+
+ if self.save_dir is not None:
+ os.makedirs(self.save_dir, exist_ok=True)
+
+ def save(self, cache_type: str, screenshot: MatLike) -> int:
+ """
+ 保存截图到指定类型的缓存
+
+ Args:
+ cache_type: 缓存类型 ('drive_disk', 'wengine', 'agent')
+ screenshot: 截图数据
+
+ Returns:
+ 截图索引
+ """
+ if cache_type not in self._cache_map:
+ raise ValueError(f"不支持的缓存类型: {cache_type}")
+
+ cache = self._cache_map[cache_type]
+
+ # 获取当前索引并递增计数器
+ if cache_type == 'drive_disk':
+ index = self._drive_disk_index
+ self._drive_disk_index += 1
+ elif cache_type == 'wengine':
+ index = self._wengine_index
+ self._wengine_index += 1
+ elif cache_type == 'agent':
+ index = self._agent_index
+ self._agent_index += 1
+ else:
+ raise ValueError(f"不支持的缓存类型: {cache_type}")
+
+ # 先缓存到内存
+ cache[index] = screenshot.copy()
+
+ # 调试模式下保存到文件
+ if self.debug_mode and self.save_dir is not None:
+ try:
+ prefix = self._prefix_map[cache_type]
+ filename = f"{prefix}-{index}.jpg"
+ filepath = os.path.join(self.save_dir, filename)
+
+ # 转换为灰度图以减少体积
+ gray = cv2.cvtColor(screenshot, cv2.COLOR_RGB2GRAY)
+ cv2.imwrite(filepath, gray, [cv2.IMWRITE_JPEG_QUALITY, 85])
+ except Exception as e:
+ log.error(f"保存截图到文件失败({cache_type}, index={index}): {e}")
+
+ return index
+
+ def get(self, cache_type: str, index: int) -> Optional[MatLike]:
+ """
+ 从指定类型缓存获取截图
+
+ Args:
+ cache_type: 缓存类型 ('drive_disk', 'wengine', 'agent')
+ index: 截图索引
+
+ Returns:
+ 截图数据,不存在返回None
+ """
+ if cache_type not in self._cache_map:
+ raise ValueError(f"不支持的缓存类型: {cache_type}")
+
+ cache = self._cache_map[cache_type]
+
+ # 先从内存读取
+ if index in cache:
+ return cache[index].copy()
+
+ # 内存中没有,尝试从文件读取
+ if self.save_dir is not None:
+ try:
+ prefix = self._prefix_map[cache_type]
+ filename = f"{prefix}-{index}.jpg"
+ filepath = os.path.join(self.save_dir, filename)
+
+ if os.path.exists(filepath):
+ screenshot = cv2.imread(filepath)
+ if screenshot is not None:
+ # 缓存到内存,避免重复读取文件
+ cache[index] = screenshot
+ return screenshot.copy()
+ except Exception as e:
+ log.error(f"从文件读取截图失败({cache_type}, index={index}): {e}")
+
+ return None
+
+ def get_all_indices(self, cache_type: str) -> list[int]:
+ """
+ 获取指定类型的所有截图索引
+
+ Args:
+ cache_type: 缓存类型 ('drive_disk', 'wengine', 'agent')
+
+ Returns:
+ 索引列表
+ """
+ if cache_type not in self._cache_map:
+ raise ValueError(f"不支持的缓存类型: {cache_type}")
+
+ cache = self._cache_map[cache_type]
+ prefix = self._prefix_map[cache_type]
+
+ # 如果有保存目录且目录存在,尝试从文件系统获取索引
+ if self.save_dir is not None and os.path.exists(self.save_dir):
+ try:
+ files = [f for f in os.listdir(self.save_dir) if f.startswith(f'{prefix}-') and f.endswith('.jpg')]
+ file_indices = sorted([int(f.split('-')[1].split('.')[0]) for f in files])
+ # 合并内存缓存和文件的索引
+ return sorted(set(file_indices + list(cache.keys())))
+ except Exception as e:
+ log.error(f"从文件系统获取截图索引失败({cache_type}): {e}")
+ # fallback到内存缓存
+
+ # 从内存缓存获取索引(包括文件读取失败的fallback情况)
+ return sorted(cache.keys())
+
+ def clear_cache(self, cache_type: str):
+ """
+ 清空指定类型的内存缓存
+
+ Args:
+ cache_type: 缓存类型 ('drive_disk', 'wengine', 'agent')
+ """
+ if cache_type not in self._cache_map:
+ raise ValueError(f"不支持的缓存类型: {cache_type}")
+
+ self._cache_map[cache_type].clear()
+
+ def reset_all(self):
+ """
+ 清空所有缓存并重置索引计数器
+ 用于开始新一轮扫描时确保从头开始
+ """
+ # 清空所有内存缓存
+ self._drive_disk_cache.clear()
+ self._wengine_cache.clear()
+ self._agent_cache.clear()
+
+ # 重置所有索引计数器
+ self._drive_disk_index = 0
+ self._wengine_index = 0
+ self._agent_index = 0
+
+ log.info("已清空所有缓存并重置索引")
+
+ def __len__(self) -> int:
+ """返回所有缓存中的截图总数"""
+ return len(self._drive_disk_cache) + len(self._wengine_cache) + len(self._agent_cache)
\ No newline at end of file
diff --git a/src/zzz_od/application/inventory_scan/translation/__init__.py b/src/zzz_od/application/inventory_scan/translation/__init__.py
new file mode 100644
index 0000000000..5d0769e6bd
--- /dev/null
+++ b/src/zzz_od/application/inventory_scan/translation/__init__.py
@@ -0,0 +1,3 @@
+from zzz_od.application.inventory_scan.translation.translation_service import TranslationService
+
+__all__ = ['TranslationService']
diff --git a/src/zzz_od/application/inventory_scan/translation/icon_downloader.py b/src/zzz_od/application/inventory_scan/translation/icon_downloader.py
new file mode 100644
index 0000000000..e770eedf6e
--- /dev/null
+++ b/src/zzz_od/application/inventory_scan/translation/icon_downloader.py
@@ -0,0 +1,185 @@
+import os
+import json
+import urllib.request
+import time
+from urllib.error import HTTPError, URLError
+
+from one_dragon.utils import os_utils
+from one_dragon.utils.log_utils import log
+
+
+class IconDownloader:
+ """游戏图标下载器(已废弃,不再自动更新)"""
+
+ BASE_URL = "https://api.hakush.in/zzz/UI/"
+
+ # 静态图标列表
+ STATIC_ICONS = [
+ # 技能图标
+ "Icon_Normal.webp",
+ "Icon_Evade.webp",
+ "Icon_SpecialReady.webp",
+ "Icon_UltimateReady.webp",
+ "Icon_Switch.webp",
+ "Icon_CoreSkill.webp",
+ # 属性图标
+ "IconPhysical.webp",
+ "IconFire.webp",
+ "IconIce.webp",
+ "IconElectric.webp",
+ "IconEther.webp",
+ # 武器类型图标
+ "IconAttackType.webp",
+ "IconStun.webp",
+ "IconAnomaly.webp",
+ "IconDefense.webp",
+ "IconRupture.webp",
+ "IconSupport.webp",
+ ]
+
+ # 类级别标志:同一次运行中如果已经失败过,就不再尝试
+ _failed_this_run = False
+
+ def __init__(self):
+ # 图标保存路径:assets/wiki_data/icons/
+ self.icons_dir = os.path.join(
+ os_utils.get_path_under_work_dir('assets', 'wiki_data'),
+ 'icons'
+ )
+ # 翻译数据路径
+ self.translation_path = os.path.join(
+ os_utils.get_path_under_work_dir('assets', 'wiki_data'),
+ 'zzz_translation.json'
+ )
+
+ def download_if_needed(self) -> bool:
+ """已废弃:保留接口兼容,始终不下载。"""
+ log.info("头像素材自动更新已废弃,跳过图标下载")
+ return False
+
+ def _should_update(self) -> bool:
+ """检查是否需要更新(每周一次)"""
+ if not os.path.exists(self.icons_dir):
+ return True
+
+ # 检查目录下是否有任何图标文件
+ try:
+ files = os.listdir(self.icons_dir)
+ if not files:
+ return True
+
+ # 检查最近修改的文件
+ latest_mtime = 0
+ for f in files:
+ filepath = os.path.join(self.icons_dir, f)
+ if os.path.isfile(filepath):
+ mtime = os.path.getmtime(filepath)
+ if mtime > latest_mtime:
+ latest_mtime = mtime
+
+ # 计算相差天数
+ now = time.time()
+ days_diff = (now - latest_mtime) / (24 * 3600)
+
+ # 7天内不更新
+ return days_diff >= 7
+ except Exception:
+ return True
+
+ def download_all(self) -> bool:
+ """已废弃:保留接口兼容,始终不下载。"""
+ log.info("头像素材自动更新已废弃,跳过图标下载")
+ return False
+
+ def _download_static_icons(self) -> int:
+ """下载静态图标"""
+ count = 0
+ for filename in self.STATIC_ICONS:
+ url = f"{self.BASE_URL}{filename}"
+ filepath = os.path.join(self.icons_dir, filename)
+ if self._download_file(url, filepath):
+ count += 1
+ return count
+
+ def _download_character_icons(self) -> int:
+ """下载角色图标(只下载圆头和半身)"""
+ count = 0
+
+ # 读取翻译数据
+ if not os.path.exists(self.translation_path):
+ log.warning(f"翻译数据文件不存在: {self.translation_path}")
+ return 0
+
+ try:
+ with open(self.translation_path, 'r', encoding='utf-8') as f:
+ data = json.load(f)
+
+ character_data = data.get('character', {})
+
+ for char_id, char_info in character_data.items():
+ icon_code = char_info.get("icon")
+ if icon_code:
+ # 圆头 (IconRolexx -> IconRoleCirclexx)
+ if "IconRole" in icon_code:
+ circle_code = icon_code.replace("IconRole", "IconRoleCircle")
+ circle_filename = f"{circle_code}.webp"
+ circle_url = f"{self.BASE_URL}{circle_filename}"
+ circle_filepath = os.path.join(self.icons_dir, circle_filename)
+ if self._download_file(circle_url, circle_filepath):
+ count += 1
+
+ # 半身 (IconRolexx -> IconRoleCropxx)
+ if "IconRole" in icon_code:
+ crop_code = icon_code.replace("IconRole", "IconRoleCrop")
+ crop_filename = f"{crop_code}.webp"
+ crop_url = f"{self.BASE_URL}{crop_filename}"
+ crop_filepath = os.path.join(self.icons_dir, crop_filename)
+ if self._download_file(crop_url, crop_filepath):
+ count += 1
+
+ except Exception as e:
+ log.error(f"处理角色图标失败: {e}")
+
+ return count
+
+ def _download_file(self, url: str, filepath: str) -> bool:
+ """下载单个文件(带重试机制)"""
+ # 文件已存在则跳过
+ if os.path.exists(filepath):
+ return True
+
+ # 最多重试 3 次
+ max_retries = 3
+ for attempt in range(max_retries):
+ try:
+ req = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0'})
+ with urllib.request.urlopen(req, timeout=60) as response:
+ with open(filepath, 'wb') as out_file:
+ out_file.write(response.read())
+ return True
+ except HTTPError as e:
+ log.error(f"下载失败 (HTTP {e.code}): {url}")
+ break # HTTP 错误不重试
+ except URLError as e:
+ if attempt < max_retries - 1:
+ log.warning(f"下载失败,重试 {attempt + 1}/{max_retries}: {url} {e.reason}")
+ time.sleep(1) # 等待 1 秒后重试
+ else:
+ log.error(f"下载失败 (URL Error): {url} {e.reason}")
+ except Exception as e:
+ if attempt < max_retries - 1:
+ log.warning(f"下载失败,重试 {attempt + 1}/{max_retries}: {url} {str(e)}")
+ time.sleep(1)
+ else:
+ log.error(f"下载失败: {url} {str(e)}")
+ return False
+
+
+def __debug():
+ """测试下载"""
+ downloader = IconDownloader()
+ downloader.download_all()
+
+
+if __name__ == '__main__':
+ __debug()
diff --git a/src/zzz_od/application/inventory_scan/translation/translation_service.py b/src/zzz_od/application/inventory_scan/translation/translation_service.py
new file mode 100644
index 0000000000..92009c43b7
--- /dev/null
+++ b/src/zzz_od/application/inventory_scan/translation/translation_service.py
@@ -0,0 +1,201 @@
+import json
+import os
+from typing import Optional, Dict, Tuple
+
+from one_dragon.utils import os_utils
+from one_dragon.utils.log_utils import log
+import difflib
+
+
+class TranslationService:
+ """翻译服务"""
+
+ def __init__(self):
+ # 从 assets/wiki_data 读取字典
+ self.user_dict_path = os.path.join(
+ os_utils.get_path_under_work_dir('assets', 'wiki_data'),
+ 'zzz_translation.json'
+ )
+ # 本项目只有一份字典:config/zzz_translation.json
+ # “自带的默认字典”和“下载更新的字典”是同一个文件路径,只是我们预置了一份初始内容。
+ self.translation_dict: Optional[Dict] = None
+
+ # 特殊名称映射(繁简转换等)
+ self.special_name_mapping = {
+ '賽斯': '赛斯', # 繁体 -> 简体
+ '賽斯・洛威尔': '赛斯', # 繁体 -> 简体
+ '赛斯・洛威尔': '赛斯', # 简体 -> 简体
+ '搖摆': '摇摆', # 错别字修复
+ '昇常': '异常', # 错别字修复
+ '異常': '异常', # 日繁体 -> 简体
+ '昇常精通': '异常精通', # 错别字修复
+ '昇常掌控': '异常掌控', # 错别字修复
+ }
+
+ self._load_dict()
+
+ def _load_dict(self):
+ """加载翻译字典"""
+ # 1. 尝试加载用户字典
+ if self._try_load_dict(self.user_dict_path, "用户"):
+ return
+
+ # 2. 加载失败,使用空字典
+ log.warning("未找到任何翻译字典,使用空字典")
+ self.translation_dict = {
+ 'character': {},
+ 'weapon': {},
+ 'equipment': {}
+ }
+
+ def _try_load_dict(self, path: str, type_name: str) -> bool:
+ """尝试加载字典"""
+ try:
+ if os.path.exists(path):
+ with open(path, 'r', encoding='utf-8') as f:
+ self.translation_dict = json.load(f)
+ log.info(f"加载{type_name}翻译字典成功: {path}")
+ return True
+ except Exception as e:
+ log.error(f"加载{type_name}翻译字典失败: {e}")
+ return False
+
+ def translate_character(self, name: str, target_lang: str = 'CHS') -> str:
+ """翻译角色名称"""
+ return self._translate('character', name, target_lang)
+
+ def translate_weapon(self, name: str, target_lang: str = 'CHS') -> str:
+ """翻译音擎名称"""
+ return self._translate('weapon', name, target_lang)
+
+ def translate_equipment(self, name: str, target_lang: str = 'CHS') -> str:
+ """翻译驱动盘名称"""
+ return self._translate('equipment', name, target_lang)
+
+ def correct_text(self, text: str) -> str:
+ """
+ 修正OCR文本中的常见错误
+ 该方法会对文本进行遍历替换,适用于包含错别字的长文本
+ """
+ if not text:
+ return text
+
+ result = text
+ for wrong, right in self.special_name_mapping.items():
+ if wrong in result:
+ result = result.replace(wrong, right)
+
+ return result
+
+ def _translate(self, category: str, name: str, target_lang: str) -> str:
+ """通用翻译方法,支持模糊匹配"""
+ if not self.translation_dict:
+ return name
+
+ # 0. 检查特殊名称映射(优先级最高)
+ if name in self.special_name_mapping:
+ mapped_name = self.special_name_mapping[name]
+ log.info(f"特殊名称映射: {name} -> {mapped_name}")
+ name = mapped_name
+
+ category_dict = self.translation_dict.get(category, {})
+
+ # 辅助函数:从新格式中提取翻译结果
+ def extract_translation(trans_obj: dict, lang: str, default: str) -> str:
+ """从翻译对象中提取文本(新格式:{"EN": {"name": "...", ...}})"""
+ result = trans_obj.get(lang, default)
+ if isinstance(result, dict):
+ result = result.get('name', default)
+ return result
+
+ # 1. 尝试直接匹配(完全匹配)
+ if name in category_dict:
+ return extract_translation(category_dict[name], target_lang, name)
+
+ # 2. 尝试从其他语言反向查找(完全匹配)
+ for key, translations in category_dict.items():
+ if not isinstance(translations, dict):
+ continue
+
+ for lang, trans_name in translations.items():
+ # 新格式:trans_name 是字典 {"name": "...", ...}
+ if isinstance(trans_name, dict):
+ trans_name = trans_name.get('name', '')
+
+ if trans_name == name:
+ return extract_translation(translations, target_lang, name)
+
+ # 3. 模糊匹配(相似度>0.2,取最接近的)
+ best_match, confidence = self._fuzzy_match(name, category_dict)
+ if best_match and confidence > 0.2:
+ log.info(f"模糊匹配: {name} -> {best_match} (置信度: {confidence:.4f})")
+ return extract_translation(category_dict[best_match], target_lang, name)
+ else:
+ log.warning(f"模糊匹配失败: {name} (最佳匹配: {best_match}, 置信度: {confidence:.4f})")
+
+ # 4. 未匹配到,返回原名
+ log.warning(f"未找到匹配: {name}")
+ return name
+
+ def _fuzzy_match(self, name: str, category_dict: Dict) -> Tuple[Optional[str], float]:
+ """
+ 模糊匹配,返回最佳匹配的key和置信度
+
+ Args:
+ name: 待匹配的名称
+ category_dict: 类别字典
+
+ Returns:
+ (best_match_key, confidence)
+ """
+ best_match = None
+ best_ratio = 0.0
+
+ # 收集所有可能的匹配候选
+ candidates = []
+ for key, translations in category_dict.items():
+ candidates.append((key, key)) # 英文key
+ # 检查 translations 是否是字典类型
+ if not isinstance(translations, dict):
+ continue
+ for lang, trans_name in translations.items():
+ # 确保 trans_name 是字符串
+ if isinstance(trans_name, str):
+ candidates.append((key, trans_name)) # 各语言翻译
+ elif isinstance(trans_name, dict):
+ # 新格式:trans_name 是字典 {"name": "..."}
+ name_text = trans_name.get('name', '')
+ if name_text:
+ candidates.append((key, name_text))
+
+ # 计算相似度
+ for key, candidate in candidates:
+ ratio = difflib.SequenceMatcher(None, name, candidate).ratio()
+ if ratio > best_ratio:
+ best_ratio = ratio
+ best_match = key
+
+ return best_match, best_ratio
+
+ def reload(self):
+ """重新加载字典"""
+ self._load_dict()
+
+
+def __debug():
+ """测试翻译"""
+ service = TranslationService()
+
+ # 测试角色翻译
+ print(service.translate_character('Corin', 'CHS')) # 应该输出: 可琳
+ print(service.translate_character('可琳', 'EN')) # 应该输出: Corin
+
+ # 测试音擎翻译
+ print(service.translate_weapon('The Brimstone', 'CHS')) # 应该输出: 硫磺石
+
+ # 测试驱动盘翻译
+ print(service.translate_equipment('Proto Punk', 'CHS')) # 应该输出: 原始朋克
+
+
+if __name__ == '__main__':
+ __debug()
diff --git a/src/zzz_od/application/inventory_scan/translation/translation_updater.py b/src/zzz_od/application/inventory_scan/translation/translation_updater.py
new file mode 100644
index 0000000000..47668164e6
--- /dev/null
+++ b/src/zzz_od/application/inventory_scan/translation/translation_updater.py
@@ -0,0 +1,158 @@
+import json
+import os
+import urllib.request
+from datetime import datetime
+from typing import Dict, Optional
+from urllib.error import HTTPError, URLError
+
+from one_dragon.utils import os_utils
+from one_dragon.utils.log_utils import log
+
+
+class TranslationUpdater:
+ """翻译字典更新器"""
+
+ API_URLS = {
+ 'character': 'https://api.hakush.in/zzz/data/character.json',
+ 'weapon': 'https://api.hakush.in/zzz/data/weapon.json',
+ 'equipment': 'https://api.hakush.in/zzz/data/equipment.json',
+ }
+
+ # 类级别标志:同一次运行中如果已经失败过,就不再尝试
+ _failed_this_run = False
+
+ def __init__(self):
+ # 保存原始JSON到 assets/wiki_data
+ self.dict_path = os.path.join(
+ os_utils.get_path_under_work_dir('assets', 'wiki_data'),
+ 'zzz_translation.json'
+ )
+
+ def update_if_needed(self) -> bool:
+ """如果需要则更新(每天一次)"""
+ # 在离线/被403时,优先使用旧字典,不做联网更新
+ if os.environ.get('OD_OFFLINE', '').strip() == '1':
+ return False
+ # 同一次运行中,如果已经失败过,就不要再尝试了(避免重复请求)
+ if TranslationUpdater._failed_this_run:
+ return False
+ if not self._should_update():
+ return False
+ return self.update_all()
+
+ def _should_update(self) -> bool:
+ """检查是否需要更新(每周一次)"""
+ if not os.path.exists(self.dict_path):
+ log.info(f"翻译字典文件不存在: {self.dict_path}")
+ return True
+
+ try:
+ # 获取文件修改时间
+ mtime = os.path.getmtime(self.dict_path)
+ modified_date = datetime.fromtimestamp(mtime)
+
+ # 获取当前时间
+ now = datetime.now()
+
+ # 计算相差天数
+ days_diff = (now - modified_date).days
+
+ log.info(f"翻译字典最后修改: {modified_date}, 距今 {days_diff} 天")
+
+ # 7天内不更新
+ return days_diff >= 7
+ except Exception as e:
+ log.error(f"检查更新时间失败: {e}")
+ return True
+
+ def update_all(self) -> bool:
+ """更新所有翻译数据"""
+ try:
+ translation_dict = {
+ 'last_updated': datetime.now().strftime('%Y-%m-%d'),
+ 'character': {},
+ 'weapon': {},
+ 'equipment': {}
+ }
+
+ # 更新角色
+ log.info("正在下载角色数据...")
+ char_data = self._download_json(self.API_URLS['character'])
+ if char_data is None:
+ log.error("角色数据下载失败,取消更新")
+ TranslationUpdater._failed_this_run = True
+ return False
+ translation_dict['character'] = char_data
+ log.info(f"角色数据更新完成,共{len(translation_dict['character'])}个")
+
+ # 更新音擎
+ log.info("正在下载音擎数据...")
+ weapon_data = self._download_json(self.API_URLS['weapon'])
+ if weapon_data is None:
+ log.error("音擎数据下载失败,取消更新")
+ TranslationUpdater._failed_this_run = True
+ return False
+ translation_dict['weapon'] = weapon_data
+ log.info(f"音擎数据更新完成,共{len(translation_dict['weapon'])}个")
+
+ # 更新驱动盘
+ log.info("正在下载驱动盘数据...")
+ equipment_data = self._download_json(self.API_URLS['equipment'])
+ if equipment_data is None:
+ log.error("驱动盘数据下载失败,取消更新")
+ TranslationUpdater._failed_this_run = True
+ return False
+ translation_dict['equipment'] = equipment_data
+ log.info(f"驱动盘数据更新完成,共{len(translation_dict['equipment'])}个")
+
+ # 保存字典
+ self._save_dict(translation_dict)
+ log.info(f"翻译字典已保存到: {self.dict_path}")
+
+ return True
+
+ except Exception as e:
+ log.error(f"更新翻译字典失败: {e}")
+ TranslationUpdater._failed_this_run = True
+ return False
+
+ def _download_json(self, url: str) -> Optional[Dict]:
+ """下载JSON数据"""
+ try:
+ req = urllib.request.Request(
+ url,
+ headers={'User-Agent': 'Mozilla/5.0'}
+ )
+ with urllib.request.urlopen(req, timeout=30) as response:
+ data = response.read()
+ # 验证JSON有效性
+ try:
+ json_content = json.loads(data)
+ return json_content
+ except json.JSONDecodeError:
+ log.error(f"下载的JSON格式无效: {url}")
+ return None
+ except HTTPError as e:
+ log.error(f"下载失败 (HTTP {e.code}): {url}")
+ except URLError as e:
+ log.error(f"下载失败 (URL Error): {url} {e.reason}")
+ except Exception as e:
+ log.error(f"下载失败: {url} {str(e)}")
+ return None
+
+ def _save_dict(self, translation_dict: Dict):
+ """保存翻译字典"""
+ # 确保目录存在
+ os.makedirs(os.path.dirname(self.dict_path), exist_ok=True)
+ # 保存完整JSON数据
+ with open(self.dict_path, 'w', encoding='utf-8') as f:
+ json.dump(translation_dict, f, ensure_ascii=False, indent=2)
+
+def __debug():
+ """测试更新"""
+ updater = TranslationUpdater()
+ updater.update_all()
+
+
+if __name__ == '__main__':
+ __debug()
diff --git a/src/zzz_od/application/inventory_scan/utils/__init__.py b/src/zzz_od/application/inventory_scan/utils/__init__.py
new file mode 100644
index 0000000000..649928758d
--- /dev/null
+++ b/src/zzz_od/application/inventory_scan/utils/__init__.py
@@ -0,0 +1 @@
+# 工具模块
\ No newline at end of file
diff --git a/src/zzz_od/application/inventory_scan/utils/agent_icon_matcher.py b/src/zzz_od/application/inventory_scan/utils/agent_icon_matcher.py
new file mode 100644
index 0000000000..17bedbc807
--- /dev/null
+++ b/src/zzz_od/application/inventory_scan/utils/agent_icon_matcher.py
@@ -0,0 +1,282 @@
+"""
+代理人头像匹配工具
+用于识别驱动盘和音擎上装备的代理人头像
+"""
+import os
+import re
+import json
+from pathlib import Path
+from datetime import datetime
+from typing import Optional, Dict, List, Any
+
+import cv2
+import numpy as np
+from cv2.typing import MatLike
+
+from one_dragon.utils import os_utils
+from one_dragon.utils.log_utils import log
+from one_dragon.utils import cv2_utils
+
+
+class AgentIconMatcher:
+ """代理人头像匹配器"""
+
+ def __init__(self, save_debug_images: bool = True):
+ self.icon_dir = os_utils.get_path_under_work_dir('assets', 'wiki_data', 'icons')
+ self.icon_cache: Dict[str, MatLike] = {}
+ self.translation_dict: Dict = {}
+ self.save_debug_images = save_debug_images
+ self.debug_dir = os_utils.get_path_under_work_dir('.debug', 'icon_crops')
+ self._unique_crops: Dict[str, Dict[str, Any]] = {}
+ self._unique_crop_seq: int = 0
+ self._hash_distance_threshold = 2
+ if save_debug_images:
+ os.makedirs(self.debug_dir, exist_ok=True)
+ self._load_icons()
+ self._load_translation_dict()
+
+ def _load_icons(self):
+ """加载所有代理人头像"""
+ if not os.path.exists(self.icon_dir):
+ log.warning(f"头像目录不存在: {self.icon_dir}")
+ return
+
+ icon_files = list(Path(self.icon_dir).glob("IconRoleCircle*.webp"))
+ log.debug(f"加载 {len(icon_files)} 个代理人头像")
+
+ for icon_file in icon_files:
+ icon = cv2_utils.read_image(str(icon_file))
+ self.icon_cache[icon_file.name] = icon
+
+ def _load_translation_dict(self):
+ """加载翻译字典"""
+ dict_path = os_utils.get_path_under_work_dir('assets', 'wiki_data', 'zzz_translation.json')
+ try:
+ if os.path.exists(dict_path):
+ import json
+ with open(dict_path, 'r', encoding='utf-8') as f:
+ self.translation_dict = json.load(f)
+ log.debug(f"加载翻译字典成功: {dict_path}")
+ except Exception as e:
+ log.error(f"加载翻译字典失败: {e}")
+
+ def is_region_colorful(self, screenshot: MatLike, x1: int, y1: int, x2: int, y2: int) -> bool:
+ """
+ 检测指定区域是否出现彩色
+
+ Args:
+ screenshot: 截图
+ x1, y1, x2, y2: 区域坐标
+
+ Returns:
+ True 如果区域有彩色(S通道均值>20),False 否则
+ """
+ try:
+ region = screenshot[y1:y2, x1:x2]
+ width = region.shape[1]
+ crop_left = int(width * 0.3)
+ crop_right = int(width * 0.7)
+ region = region[:, crop_left:crop_right]
+ region_hsv = cv2.cvtColor(region, cv2.COLOR_RGB2HSV)
+ avg_s = float(region_hsv[:, :, 1].mean())
+ is_colorful = avg_s > 20
+
+ return is_colorful
+ except Exception as e:
+ log.error(f"检测区域彩色失败: {e}")
+ return False
+
+ def match_agent_icon(self, screenshot: MatLike, x1: int, y1: int, x2: int, y2: int) -> str:
+ """
+ 匹配代理人头像
+
+ Args:
+ screenshot: 截图
+ x1, y1, x2, y2: 头像区域坐标
+
+ Returns:
+ 代理人key(如 "Billy"),未匹配到返回空字符串
+ """
+ try:
+ small_icon = screenshot[y1:y2, x1:x2]
+
+ if small_icon is None or small_icon.size == 0:
+ return ""
+
+ match_size = (31, 31)
+ small_icon_rs = cv2.resize(small_icon, match_size, interpolation=cv2.INTER_AREA)
+ small_icon_rs = self._apply_circle_mask(small_icon_rs)
+ small_icon_gray = cv2.cvtColor(small_icon_rs, cv2.COLOR_RGB2GRAY)
+ small_icon_edge = cv2.Canny(small_icon_gray, 50, 150)
+ scores = []
+
+ for icon_name, full_icon in self.icon_cache.items():
+ scaled_full = cv2.resize(full_icon, match_size, interpolation=cv2.INTER_AREA)
+ scaled_full = self._apply_circle_mask(scaled_full)
+ scaled_full_gray = cv2.cvtColor(scaled_full, cv2.COLOR_RGB2GRAY)
+ scaled_full_edge = cv2.Canny(scaled_full_gray, 50, 150)
+ result = cv2.matchTemplate(small_icon_edge, scaled_full_edge, cv2.TM_CCOEFF_NORMED)
+ score = result[0][0]
+
+ scores.append((icon_name, score))
+
+ scores.sort(key=lambda x: x[1], reverse=True)
+
+ if not scores:
+ return ""
+
+ best_icon_name = scores[0][0]
+ best_score = float(scores[0][1])
+ icon_name_match = re.search(r'IconRoleCircle(\d+)', best_icon_name)
+ if not icon_name_match:
+ return ""
+
+ icon_number = icon_name_match.group(1)
+ icon_name = f"IconRole{icon_number}"
+
+ agent_key = self._find_agent_key_by_icon(icon_name)
+
+ if agent_key:
+ log.debug(f"头像匹配成功: {best_icon_name} -> {agent_key}")
+ else:
+ log.warning(f"未找到对应的代理人: {icon_name}")
+
+ self._cache_unique_crop_debug(
+ small_icon=small_icon,
+ best_icon_name=best_icon_name,
+ best_score=best_score,
+ agent_key=agent_key or "",
+ top_scores=scores[:5]
+ )
+
+ return agent_key or ""
+
+ except Exception as e:
+ log.error(f"匹配代理人头像失败: {e}", exc_info=True)
+ return ""
+
+ def _apply_circle_mask(self, image: MatLike) -> MatLike:
+ """对头像应用内圆mask。"""
+ h, w = image.shape[:2]
+ center = (w // 2, h // 2)
+ radius = max(1, min(w, h) // 2 - 3)
+
+ mask = np.zeros((h, w), dtype=np.uint8)
+ cv2.circle(mask, center, radius, 255, -1)
+
+ return cv2.bitwise_and(image, image, mask=mask)
+
+ def _cache_unique_crop_debug(
+ self,
+ small_icon: MatLike,
+ best_icon_name: str,
+ best_score: float,
+ agent_key: str,
+ top_scores: List[tuple[str, float]]
+ ) -> None:
+ """缓存并保存唯一裁剪块的调试信息。"""
+ if not self.save_debug_images:
+ return
+
+ try:
+ ahash = self._compute_ahash(small_icon)
+ record_key = self._find_existing_crop_key(ahash)
+
+ now_iso = datetime.now().isoformat(timespec='seconds')
+
+ if record_key is None:
+ self._unique_crop_seq += 1
+ record_key = f"icon_crop_{self._unique_crop_seq:04d}"
+ img_file = f"{record_key}.jpg"
+ img_path = os.path.join(self.debug_dir, img_file)
+ json_path = os.path.join(self.debug_dir, f"{record_key}.json")
+
+ bgr_icon = cv2.cvtColor(small_icon, cv2.COLOR_RGB2BGR)
+ cv2.imwrite(img_path, bgr_icon)
+
+ self._unique_crops[record_key] = {
+ 'ahash': ahash,
+ 'image': img_file,
+ 'json_path': json_path,
+ 'count': 1,
+ 'firstSeen': now_iso,
+ 'lastSeen': now_iso,
+ 'bestMatch': best_icon_name,
+ 'bestScore': round(best_score, 6),
+ 'agentKey': agent_key,
+ 'topMatches': [{'icon': name, 'score': round(float(score), 6)} for name, score in top_scores]
+ }
+ else:
+ record = self._unique_crops[record_key]
+ record['count'] += 1
+ record['lastSeen'] = now_iso
+
+ if best_score > record.get('bestScore', -1):
+ record['bestMatch'] = best_icon_name
+ record['bestScore'] = round(best_score, 6)
+ record['agentKey'] = agent_key
+ record['topMatches'] = [{'icon': name, 'score': round(float(score), 6)} for name, score in top_scores]
+
+ self._write_crop_record_json(record_key)
+ except Exception as e:
+ log.error(f"保存唯一裁剪块调试信息失败: {e}")
+
+ def _compute_ahash(self, image: MatLike) -> np.ndarray:
+ """计算图片aHash,用于近似去重。"""
+ gray = cv2.cvtColor(image, cv2.COLOR_RGB2GRAY)
+ small = cv2.resize(gray, (8, 8), interpolation=cv2.INTER_AREA)
+ avg = float(small.mean())
+ return (small >= avg).astype(np.uint8)
+
+ def _find_existing_crop_key(self, ahash: np.ndarray) -> Optional[str]:
+ """查找是否存在近似相同的已缓存裁剪块。"""
+ for key, record in self._unique_crops.items():
+ old_hash = record.get('ahash')
+ if old_hash is None:
+ continue
+ distance = int(np.count_nonzero(old_hash != ahash))
+ if distance <= self._hash_distance_threshold:
+ return key
+ return None
+
+ def _write_crop_record_json(self, record_key: str) -> None:
+ """将唯一裁剪块记录写入json。"""
+ record = self._unique_crops[record_key]
+ json_path = record.get('json_path')
+ if not json_path:
+ return
+
+ payload = {
+ 'recordKey': record_key,
+ 'image': record.get('image'),
+ 'count': record.get('count', 0),
+ 'firstSeen': record.get('firstSeen'),
+ 'lastSeen': record.get('lastSeen'),
+ 'bestMatch': record.get('bestMatch', ''),
+ 'bestScore': record.get('bestScore', 0),
+ 'agentKey': record.get('agentKey', ''),
+ 'topMatches': record.get('topMatches', [])
+ }
+
+ with open(json_path, 'w', encoding='utf-8') as f:
+ json.dump(payload, f, ensure_ascii=False, indent=2)
+
+ def _find_agent_key_by_icon(self, icon_name: str) -> Optional[str]:
+ """
+ 根据图标名称查找代理人key
+
+ Args:
+ icon_name: 图标名称(如 "IconRole10")
+
+ Returns:
+ 代理人key(如 "Billy"),未找到返回None
+ """
+ try:
+ characters = self.translation_dict.get('character', {})
+ for char_id, char_data in characters.items():
+ if char_data.get('icon') == icon_name:
+ return char_data.get('code')
+ return None
+ except Exception as e:
+ log.error(f"查找代理人key失败: {e}")
+ return None
diff --git a/src/zzz_od/application/inventory_scan/wengine/__init__.py b/src/zzz_od/application/inventory_scan/wengine/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/src/zzz_od/application/inventory_scan/wengine/wengine_scan_app.py b/src/zzz_od/application/inventory_scan/wengine/wengine_scan_app.py
new file mode 100644
index 0000000000..0dc1c30c9b
--- /dev/null
+++ b/src/zzz_od/application/inventory_scan/wengine/wengine_scan_app.py
@@ -0,0 +1,42 @@
+import cv2
+import os
+from typing import Optional, TYPE_CHECKING
+from one_dragon.utils.log_utils import log
+from cv2.typing import MatLike
+from one_dragon.utils import cv2_utils
+from zzz_od.application.inventory_scan.drive_disk.drive_disk_scan_app import DriveDiskScanApp
+from zzz_od.application.inventory_scan.parser.wengine_parser import WengineParser
+from zzz_od.application.inventory_scan.screenshot_cache import ScreenshotCache
+from zzz_od.context.zzz_context import ZContext
+
+if TYPE_CHECKING:
+ from zzz_od.application.inventory_scan.ocr_worker import OcrWorker
+
+
+class WengineScanApp(DriveDiskScanApp):
+
+ def __init__(self, ctx: ZContext, screenshot_cache: Optional[ScreenshotCache] = None,
+ ocr_worker: Optional["OcrWorker"] = None):
+ super().__init__(ctx, screenshot_cache=screenshot_cache, ocr_worker=ocr_worker)
+ self.app_id = 'wengine_scan'
+ self.op_name = '音擎扫描'
+ self.parser = WengineParser()
+
+ def _save_screenshot(self, row: int, col: int, screenshot: MatLike):
+ """保存截图到缓存并提交OCR任务"""
+ if self.screenshot_cache is None:
+ return
+
+ try:
+ storage_area = self.ctx.screen_loader.get_area('仓库-驱动仓库', '驱动盘属性')
+ cropped = cv2_utils.crop_image_only(screenshot, storage_area.rect)
+
+ # 保存到音擎缓存(调试模式下会同时保存到文件)
+ index = self.screenshot_cache.save('wengine', cropped)
+ self.screenshot_index = index + 1
+
+ # 提交OCR任务(异步处理)
+ if self.ocr_worker is not None:
+ self.ocr_worker.submit('wengine', cropped, self.parser)
+ except Exception as e:
+ log.error(f"保存截图失败({row+1},{col+1}): {e}")
diff --git a/src/zzz_od/gui/view/game_assistant/game_assistant_interface.py b/src/zzz_od/gui/view/game_assistant/game_assistant_interface.py
index 2dd4bfbe84..3f8f33da0c 100644
--- a/src/zzz_od/gui/view/game_assistant/game_assistant_interface.py
+++ b/src/zzz_od/gui/view/game_assistant/game_assistant_interface.py
@@ -8,6 +8,7 @@
from zzz_od.gui.view.game_assistant.commission_assistant_interface import (
CommissionAssistantRunInterface,
)
+from zzz_od.gui.view.game_assistant.inventory_scan_interface import InventoryScanInterface
class GameAssistantInterface(PivotNavigatorInterface):
@@ -21,7 +22,7 @@ def __init__(self, ctx: ZContext, parent=None):
初始化游戏助手界面。
:param ctx: 应用程序上下文,包含配置和状态信息。
- :param parent: 父组件,默认为 None。
+n :param parent: 父组件,默认为 None。
"""
self.ctx: ZContext = ctx
PivotNavigatorInterface.__init__(self, object_name='game_assistant_interface', parent=parent,
@@ -33,3 +34,4 @@ def create_sub_interface(self):
"""
self.add_sub_interface(BattleAssistantInterface(self.ctx))
self.add_sub_interface(CommissionAssistantRunInterface(self.ctx))
+ self.add_sub_interface(InventoryScanInterface(self.ctx))
diff --git a/src/zzz_od/gui/view/game_assistant/inventory_scan_interface.py b/src/zzz_od/gui/view/game_assistant/inventory_scan_interface.py
new file mode 100644
index 0000000000..4124ee6322
--- /dev/null
+++ b/src/zzz_od/gui/view/game_assistant/inventory_scan_interface.py
@@ -0,0 +1,146 @@
+import os
+import webbrowser
+from typing import Optional
+
+from PySide6.QtCore import QUrl
+from PySide6.QtGui import QDesktopServices
+from PySide6.QtWidgets import QVBoxLayout, QWidget, QLabel
+from qfluentwidgets import FluentIcon, PushButton, CheckBox
+
+from one_dragon.base.operation.application import application_const
+from one_dragon.utils import os_utils
+from one_dragon.utils.log_utils import log
+from one_dragon_qt.utils.config_utils import get_prop_adapter
+from one_dragon_qt.view.app_run_interface import AppRunInterface
+from one_dragon_qt.widgets.row import Row
+from one_dragon_qt.widgets.setting_card.help_card import HelpCard
+from zzz_od.application.inventory_scan import inventory_scan_const
+from zzz_od.application.inventory_scan.inventory_scan_config import InventoryScanConfig
+from zzz_od.application.zzz_application import ZApplication
+from zzz_od.context.zzz_context import ZContext
+
+
+class InventoryScanInterface(AppRunInterface):
+
+ def __init__(self,
+ ctx: ZContext,
+ parent=None):
+ self.ctx: ZContext = ctx
+ self.app: Optional[ZApplication] = None
+
+ AppRunInterface.__init__(
+ self,
+ ctx=ctx,
+ app_id=inventory_scan_const.APP_ID,
+ object_name='inventory_scan_interface',
+ nav_text_cn='仓库自动扫描',
+ parent=parent,
+ )
+ self.config: Optional[InventoryScanConfig] = None
+ # 扫描目标选择
+ self.scan_drive_disk_check: Optional[CheckBox] = None
+ self.scan_wengine_check: Optional[CheckBox] = None
+ self.scan_agent_check: Optional[CheckBox] = None
+
+ def get_widget_at_top(self) -> QWidget:
+ content = Row()
+ left_layout = QVBoxLayout()
+ content.h_layout.addLayout(left_layout)
+
+ # 使用说明卡片
+ self.help_opt = HelpCard(
+ title='使用说明',
+ content='从大世界开始,自动扫描驱动盘、音擎、角色,并导出数据。扫描结果可导入绝区零伤害优化计算网站进行配装分析。\n注意:请确保游戏分辨率为 1920x1080,否则无法正常扫描。'
+ )
+ left_layout.addWidget(self.help_opt)
+
+ # 链接按钮行
+ from one_dragon_qt.widgets.row import Row as HRow
+ link_row = HRow()
+
+ self.open_output_btn = PushButton('打开输出目录', link_row, FluentIcon.FOLDER)
+ self.open_output_btn.clicked.connect(self._on_open_output_clicked)
+ link_row.h_layout.addWidget(self.open_output_btn)
+
+ self.open_error_btn = PushButton('打开错误目录', link_row, FluentIcon.FOLDER_ADD)
+ self.open_error_btn.clicked.connect(self._on_open_error_clicked)
+ link_row.h_layout.addWidget(self.open_error_btn)
+
+ self.github_btn = PushButton('问题反馈', link_row, FluentIcon.GITHUB)
+ self.github_btn.clicked.connect(self._on_github_clicked)
+ link_row.h_layout.addWidget(self.github_btn)
+
+ self.analyze_btn = PushButton('分析网站', link_row, FluentIcon.LINK)
+ self.analyze_btn.clicked.connect(self._on_analyze_clicked)
+ link_row.h_layout.addWidget(self.analyze_btn)
+
+ left_layout.addWidget(link_row)
+
+ # 扫描目标选择
+ from qfluentwidgets import BodyLabel
+
+ scan_target_row = HRow()
+ scan_target_label = BodyLabel('扫描内容:', scan_target_row)
+ scan_target_row.h_layout.addWidget(scan_target_label)
+
+ self.scan_drive_disk_check = CheckBox('驱动盘', scan_target_row)
+ self.scan_drive_disk_check.setChecked(True)
+ scan_target_row.h_layout.addWidget(self.scan_drive_disk_check)
+
+ self.scan_wengine_check = CheckBox('音擎', scan_target_row)
+ self.scan_wengine_check.setChecked(True)
+ scan_target_row.h_layout.addWidget(self.scan_wengine_check)
+
+ self.scan_agent_check = CheckBox('角色', scan_target_row)
+ self.scan_agent_check.setChecked(True)
+ scan_target_row.h_layout.addWidget(self.scan_agent_check)
+
+ left_layout.addWidget(scan_target_row)
+
+ return content
+
+ def _on_open_output_clicked(self):
+ """打开输出目录按钮点击事件"""
+ output_dir = os_utils.get_path_under_work_dir('.debug', 'inventory_exports')
+ os.startfile(output_dir)
+
+ def _on_open_error_clicked(self):
+ """打开错误目录按钮点击事件"""
+ error_dir = os_utils.get_path_under_work_dir('.debug', 'inventory_errors')
+ os.makedirs(error_dir, exist_ok=True)
+ os.startfile(error_dir)
+
+ def _on_github_clicked(self):
+ """打开 GitHub 链接(问题反馈)"""
+ github_url = 'https://github.com/kawayiYokami/zzz_optimizer'
+ QDesktopServices.openUrl(QUrl(github_url))
+
+ def _on_analyze_clicked(self):
+ """打开分析网站"""
+ webbrowser.open('http://zzz.233618.xyz/')
+
+ def on_interface_shown(self) -> None:
+ AppRunInterface.on_interface_shown(self)
+ self.config = self.ctx.run_context.get_config(
+ app_id=inventory_scan_const.APP_ID,
+ instance_idx=self.ctx.current_instance_idx,
+ group_id=application_const.DEFAULT_GROUP_ID,
+ )
+
+ def _on_start_clicked(self) -> None:
+ """在启动应用前保存扫描目标配置"""
+ # 保存扫描目标选择到 context
+ if hasattr(self, 'scan_drive_disk_check') and self.scan_drive_disk_check:
+ targets = {
+ 'drive_disk': self.scan_drive_disk_check.isChecked(),
+ 'wengine': self.scan_wengine_check.isChecked(),
+ 'agent': self.scan_agent_check.isChecked(),
+ }
+ setattr(self.ctx, '_inventory_scan_targets', targets)
+ log.info(f"扫描目标: {targets}")
+
+ # 调用父类方法启动应用
+ AppRunInterface._on_start_clicked(self)
+
+ def on_interface_hidden(self) -> None:
+ AppRunInterface.on_interface_hidden(self)
diff --git a/tools/analyze_icon_crop.py b/tools/analyze_icon_crop.py
new file mode 100644
index 0000000000..74427f641a
--- /dev/null
+++ b/tools/analyze_icon_crop.py
@@ -0,0 +1,185 @@
+import argparse
+from pathlib import Path
+from typing import List, Tuple
+
+import cv2
+import numpy as np
+
+
+def read_image(path: str) -> np.ndarray:
+ img = cv2.imread(path, cv2.IMREAD_COLOR)
+ if img is None:
+ raise FileNotFoundError(f"Failed to read image: {path}")
+ return cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
+
+
+def compute_ahash(image: np.ndarray) -> np.ndarray:
+ gray = cv2.cvtColor(image, cv2.COLOR_RGB2GRAY)
+ small = cv2.resize(gray, (8, 8), interpolation=cv2.INTER_AREA)
+ avg = float(small.mean())
+ return (small >= avg).astype(np.uint8)
+
+
+def ahash_distance(a: np.ndarray, b: np.ndarray) -> int:
+ return int(np.count_nonzero(a != b))
+
+
+def mse(a: np.ndarray, b: np.ndarray) -> float:
+ diff = a.astype(np.float32) - b.astype(np.float32)
+ return float(np.mean(diff * diff))
+
+
+def hist_corr(a: np.ndarray, b: np.ndarray) -> float:
+ # 3D color histogram correlation
+ hist_a = cv2.calcHist([a], [0, 1, 2], None, [8, 8, 8], [0, 256, 0, 256, 0, 256])
+ hist_b = cv2.calcHist([b], [0, 1, 2], None, [8, 8, 8], [0, 256, 0, 256, 0, 256])
+ cv2.normalize(hist_a, hist_a)
+ cv2.normalize(hist_b, hist_b)
+ return float(cv2.compareHist(hist_a, hist_b, cv2.HISTCMP_CORREL))
+
+
+def hs_hist_corr(a: np.ndarray, b: np.ndarray) -> float:
+ ah = cv2.cvtColor(a, cv2.COLOR_RGB2HSV)
+ bh = cv2.cvtColor(b, cv2.COLOR_RGB2HSV)
+ # 仅使用 H/S,尽量减少亮度(V)波动影响
+ hist_a = cv2.calcHist([ah], [0, 1], None, [30, 32], [0, 180, 0, 256])
+ hist_b = cv2.calcHist([bh], [0, 1], None, [30, 32], [0, 180, 0, 256])
+ cv2.normalize(hist_a, hist_a)
+ cv2.normalize(hist_b, hist_b)
+ return float(cv2.compareHist(hist_a, hist_b, cv2.HISTCMP_CORREL))
+
+
+def normalize_luminance_gray(image: np.ndarray) -> np.ndarray:
+ # LAB + CLAHE: 抵抗 UI 明暗变化
+ bgr = cv2.cvtColor(image, cv2.COLOR_RGB2BGR)
+ lab = cv2.cvtColor(bgr, cv2.COLOR_BGR2LAB)
+ l, a, b = cv2.split(lab)
+ clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8))
+ l = clahe.apply(l)
+ merged = cv2.merge([l, a, b])
+ out_bgr = cv2.cvtColor(merged, cv2.COLOR_LAB2BGR)
+ out_gray = cv2.cvtColor(out_bgr, cv2.COLOR_BGR2GRAY)
+ return out_gray
+
+
+def ncc_norm(a: np.ndarray, b: np.ndarray) -> float:
+ ag = normalize_luminance_gray(a)
+ bg = normalize_luminance_gray(b)
+ result = cv2.matchTemplate(ag, bg, cv2.TM_CCOEFF_NORMED)
+ return float(result[0][0])
+
+
+def edge_ncc(a: np.ndarray, b: np.ndarray) -> float:
+ ag = normalize_luminance_gray(a)
+ bg = normalize_luminance_gray(b)
+ ae = cv2.Canny(ag, 50, 150)
+ be = cv2.Canny(bg, 50, 150)
+ result = cv2.matchTemplate(ae, be, cv2.TM_CCOEFF_NORMED)
+ return float(result[0][0])
+
+
+def ncc(a: np.ndarray, b: np.ndarray) -> float:
+ # Use grayscale normalized cross-correlation
+ ag = cv2.cvtColor(a, cv2.COLOR_RGB2GRAY)
+ bg = cv2.cvtColor(b, cv2.COLOR_RGB2GRAY)
+ result = cv2.matchTemplate(ag, bg, cv2.TM_CCOEFF_NORMED)
+ return float(result[0][0])
+
+
+def sqdiff(a: np.ndarray, b: np.ndarray) -> float:
+ # Use grayscale normalized squared difference
+ ag = cv2.cvtColor(a, cv2.COLOR_RGB2GRAY)
+ bg = cv2.cvtColor(b, cv2.COLOR_RGB2GRAY)
+ result = cv2.matchTemplate(ag, bg, cv2.TM_SQDIFF_NORMED)
+ return float(result[0][0])
+
+
+def score_icons(
+ crop: np.ndarray,
+ icon_paths: List[Path],
+) -> Tuple[List[Tuple[str, float]], List[Tuple[str, float]], List[Tuple[str, float]], List[Tuple[str, float]], List[Tuple[str, int]], List[Tuple[str, float]], List[Tuple[str, float]]]:
+ crop_h, crop_w = crop.shape[:2]
+ crop_hash = compute_ahash(crop)
+
+ ncc_scores = []
+ sqdiff_scores = []
+ mse_scores = []
+ hist_scores = []
+ ahash_scores = []
+ ncc_norm_scores = []
+ edge_ncc_scores = []
+
+ for p in icon_paths:
+ icon = read_image(str(p))
+ icon_rs = cv2.resize(icon, (crop_w, crop_h), interpolation=cv2.INTER_AREA)
+
+ ncc_scores.append((p.name, ncc(crop, icon_rs)))
+ sqdiff_scores.append((p.name, sqdiff(crop, icon_rs)))
+ mse_scores.append((p.name, mse(crop, icon_rs)))
+ ncc_norm_scores.append((p.name, ncc_norm(crop, icon_rs)))
+ edge_ncc_scores.append((p.name, edge_ncc(crop, icon_rs)))
+ hist_scores.append((p.name, hs_hist_corr(crop, icon_rs)))
+ ahash_scores.append((p.name, ahash_distance(crop_hash, compute_ahash(icon_rs))))
+
+ ncc_scores.sort(key=lambda x: x[1], reverse=True)
+ sqdiff_scores.sort(key=lambda x: x[1]) # lower is better
+ mse_scores.sort(key=lambda x: x[1]) # lower is better
+ hist_scores.sort(key=lambda x: x[1], reverse=True)
+ ahash_scores.sort(key=lambda x: x[1]) # lower is better
+ ncc_norm_scores.sort(key=lambda x: x[1], reverse=True)
+ edge_ncc_scores.sort(key=lambda x: x[1], reverse=True)
+
+ return ncc_scores, sqdiff_scores, mse_scores, hist_scores, ahash_scores, ncc_norm_scores, edge_ncc_scores
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser(description="Analyze icon crop against IconRoleCircle assets.")
+ parser.add_argument("crop", help="Path to crop image (jpg/png)")
+ parser.add_argument("--icons", default="assets/wiki_data/icons", help="Directory containing IconRoleCircle*.webp")
+ parser.add_argument("--top", type=int, default=10, help="Top N results per method")
+ parser.add_argument("--expect", default="", help="Expected icon filename, e.g. IconRoleCircle40.webp")
+ args = parser.parse_args()
+
+ crop_path = args.crop
+ icon_dir = args.icons
+
+ crop = read_image(crop_path)
+ icon_paths = sorted(Path(icon_dir).glob("IconRoleCircle*.webp"))
+ if not icon_paths:
+ raise FileNotFoundError(f"No IconRoleCircle*.webp found in {icon_dir}")
+
+ ncc_scores, sqdiff_scores, mse_scores, hist_scores, ahash_scores, ncc_norm_scores, edge_ncc_scores = score_icons(crop, icon_paths)
+
+ print(f"Crop: {crop_path}")
+ print(f"Icons: {icon_dir} ({len(icon_paths)} files)")
+ print("")
+
+ def show(title: str, items: List[Tuple[str, float]]) -> None:
+ print(title)
+ for name, score in items[: args.top]:
+ print(f"{name}\t{score:.6f}" if isinstance(score, float) else f"{name}\t{score}")
+ print("")
+
+ show("Top NCC (TM_CCOEFF_NORMED)", ncc_scores)
+ show("Top SQDIFF (TM_SQDIFF_NORMED, lower better)", sqdiff_scores)
+ show("Top MSE (lower better)", mse_scores)
+ show("Top HS-Hist Corr (higher better, illumination robust)", hist_scores)
+ show("Top NCC after CLAHE normalize (higher better)", ncc_norm_scores)
+ show("Top Edge NCC (higher better, illumination robust)", edge_ncc_scores)
+ show("Top aHash Distance (lower better)", ahash_scores)
+
+ if args.expect:
+ print(f"Expected: {args.expect}")
+
+ def print_rank(method_name: str, items: List[Tuple[str, float]]) -> None:
+ rank = next((idx + 1 for idx, (name, _) in enumerate(items) if name == args.expect), -1)
+ print(f"{method_name}\tRank={rank}")
+
+ print_rank("NCC", ncc_scores)
+ print_rank("NCC_CLAHE", ncc_norm_scores)
+ print_rank("Edge_NCC", edge_ncc_scores)
+ print_rank("HS_Hist", hist_scores)
+ print_rank("SQDIFF", sqdiff_scores)
+ print_rank("MSE", mse_scores)
+ print_rank("aHash", ahash_scores)
+
diff --git a/tools/analyze_icon_crop_algos.py b/tools/analyze_icon_crop_algos.py
new file mode 100644
index 0000000000..774261e38a
--- /dev/null
+++ b/tools/analyze_icon_crop_algos.py
@@ -0,0 +1,155 @@
+import argparse
+import json
+from pathlib import Path
+
+import cv2
+import numpy as np
+
+
+def load_rgb(path: Path) -> np.ndarray:
+ img = cv2.imread(str(path), cv2.IMREAD_COLOR)
+ if img is None:
+ raise FileNotFoundError(f"Cannot read image: {path}")
+ return cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
+
+
+def ahash(image: np.ndarray) -> np.ndarray:
+ gray = cv2.cvtColor(image, cv2.COLOR_RGB2GRAY)
+ small = cv2.resize(gray, (8, 8), interpolation=cv2.INTER_AREA)
+ return (small >= small.mean()).astype(np.uint8)
+
+
+def hamming(a: np.ndarray, b: np.ndarray) -> int:
+ return int(np.count_nonzero(a != b))
+
+
+def tm_ccoef(a: np.ndarray, b: np.ndarray) -> float:
+ ag = cv2.cvtColor(a, cv2.COLOR_RGB2GRAY)
+ bg = cv2.cvtColor(b, cv2.COLOR_RGB2GRAY)
+ return float(cv2.matchTemplate(ag, bg, cv2.TM_CCOEFF_NORMED)[0, 0])
+
+
+def tm_sqdiff(a: np.ndarray, b: np.ndarray) -> float:
+ ag = cv2.cvtColor(a, cv2.COLOR_RGB2GRAY)
+ bg = cv2.cvtColor(b, cv2.COLOR_RGB2GRAY)
+ return float(cv2.matchTemplate(ag, bg, cv2.TM_SQDIFF_NORMED)[0, 0])
+
+
+def edge_ccoef(a: np.ndarray, b: np.ndarray) -> float:
+ ag = cv2.cvtColor(a, cv2.COLOR_RGB2GRAY)
+ bg = cv2.cvtColor(b, cv2.COLOR_RGB2GRAY)
+ ae = cv2.Canny(ag, 50, 150)
+ be = cv2.Canny(bg, 50, 150)
+ return float(cv2.matchTemplate(ae, be, cv2.TM_CCOEFF_NORMED)[0, 0])
+
+
+def hist_corr(a: np.ndarray, b: np.ndarray) -> float:
+ ha = cv2.calcHist([a], [0, 1, 2], None, [8, 8, 8], [0, 256, 0, 256, 0, 256])
+ hb = cv2.calcHist([b], [0, 1, 2], None, [8, 8, 8], [0, 256, 0, 256, 0, 256])
+ cv2.normalize(ha, ha)
+ cv2.normalize(hb, hb)
+ return float(cv2.compareHist(ha, hb, cv2.HISTCMP_CORREL))
+
+
+def mse(a: np.ndarray, b: np.ndarray) -> float:
+ d = a.astype(np.float32) - b.astype(np.float32)
+ return float(np.mean(d * d))
+
+
+def rank_of(scores: list[tuple[str, float]], name: str) -> int:
+ for idx, (n, _) in enumerate(scores, 1):
+ if n == name:
+ return idx
+ return -1
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser(description="Analyze one icon crop against IconRoleCircle templates")
+ parser.add_argument("crop", help="Path to crop image")
+ parser.add_argument("--icons", default="assets/wiki_data/icons", help="IconRoleCircle*.webp dir")
+ parser.add_argument("--expect", default="IconRoleCircle40.webp", help="Expected icon filename")
+ parser.add_argument("--top", type=int, default=10)
+ parser.add_argument("--out", default=".debug/icon_matching_test")
+ args = parser.parse_args()
+
+ crop_path = Path(args.crop)
+ icon_dir = Path(args.icons)
+ out_dir = Path(args.out)
+ out_dir.mkdir(parents=True, exist_ok=True)
+
+ crop = load_rgb(crop_path)
+ h, w = crop.shape[:2]
+ crop_hash = ahash(crop)
+
+ methods = {
+ "tm_ccoef": {"higher": True, "scores": []},
+ "tm_sqdiff": {"higher": False, "scores": []},
+ "edge_ccoef": {"higher": True, "scores": []},
+ "hist_corr": {"higher": True, "scores": []},
+ "mse": {"higher": False, "scores": []},
+ "ahash_dist": {"higher": False, "scores": []},
+ }
+
+ icon_paths = sorted(icon_dir.glob("IconRoleCircle*.webp"))
+ if not icon_paths:
+ raise FileNotFoundError(f"No IconRoleCircle*.webp in {icon_dir}")
+
+ for p in icon_paths:
+ icon = load_rgb(p)
+ icon = cv2.resize(icon, (w, h), interpolation=cv2.INTER_AREA)
+ methods["tm_ccoef"]["scores"].append((p.name, tm_ccoef(crop, icon)))
+ methods["tm_sqdiff"]["scores"].append((p.name, tm_sqdiff(crop, icon)))
+ methods["edge_ccoef"]["scores"].append((p.name, edge_ccoef(crop, icon)))
+ methods["hist_corr"]["scores"].append((p.name, hist_corr(crop, icon)))
+ methods["mse"]["scores"].append((p.name, mse(crop, icon)))
+ methods["ahash_dist"]["scores"].append((p.name, float(hamming(crop_hash, ahash(icon)))))
+
+ n_icons = len(icon_paths)
+ ensemble = {p.name: 0.0 for p in icon_paths}
+ report = {
+ "crop": str(crop_path),
+ "expect": args.expect,
+ "iconCount": n_icons,
+ "methods": {},
+ }
+
+ print(f"Crop: {crop_path}")
+ print(f"Icons: {icon_dir} ({n_icons})")
+ print(f"Expected: {args.expect}\\n")
+
+ for method_name, cfg in methods.items():
+ scores = cfg["scores"]
+ scores.sort(key=lambda x: x[1], reverse=cfg["higher"])
+ rank = rank_of(scores, args.expect)
+ report["methods"][method_name] = {
+ "rankOfExpected": rank,
+ "top": [{"icon": n, "score": float(s)} for n, s in scores[: args.top]],
+ }
+ for rank_idx, (name, _) in enumerate(scores, 1):
+ ensemble[name] += (n_icons - rank_idx + 1)
+
+ print(f"[{method_name}] rank(expect)={rank}")
+ for name, score in scores[: args.top]:
+ print(f" {name:<24} {score:.6f}")
+ print()
+
+ ensemble_scores = sorted(ensemble.items(), key=lambda x: x[1], reverse=True)
+ ensemble_rank = rank_of(ensemble_scores, args.expect)
+ report["ensemble"] = [{"icon": n, "score": float(s)} for n, s in ensemble_scores[: args.top]]
+ report["ensembleRankOfExpected"] = ensemble_rank
+
+ print(f"[ensemble] rank(expect)={ensemble_rank}")
+ for name, score in ensemble_scores[: args.top]:
+ print(f" {name:<24} {score:.2f}")
+
+ out_json = out_dir / f"analysis_{crop_path.stem}.json"
+ payload = json.dumps(report, ensure_ascii=False, indent=2)
+ try:
+ out_json.write_text(payload, encoding="utf-8")
+ print(f"\\nSaved: {out_json}")
+ except PermissionError:
+ fallback = Path(".debug") / f"analysis_{crop_path.stem}.json"
+ fallback.parent.mkdir(parents=True, exist_ok=True)
+ fallback.write_text(payload, encoding="utf-8")
+ print(f"\\nSaved (fallback): {fallback}")
+