@sophiacyl/dsh-plugin-waves 0.0.0-stage → 0.1.0-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (140) hide show
  1. package/DSH-GO-VERIFICATION.md +57 -0
  2. package/ICON.png +0 -0
  3. package/LICENSE +674 -0
  4. package/README.md +251 -2
  5. package/cordis.patch.yml +8 -0
  6. package/install.ps1 +0 -0
  7. package/install.sh +209 -0
  8. package/lib/client.js +1511 -0
  9. package/lib/host/aliashint.js +70 -0
  10. package/lib/host/attachments.js +25 -0
  11. package/lib/host/command.js +34 -0
  12. package/lib/host/consts.js +16 -0
  13. package/lib/host/dataguide.js +35 -0
  14. package/lib/host/imagebridge.js +41 -0
  15. package/lib/host/native-result.js +91 -0
  16. package/lib/host/native.js +191 -0
  17. package/lib/host/proxy.js +59 -0
  18. package/lib/host/results.js +67 -0
  19. package/lib/host/service.js +263 -0
  20. package/lib/host/tool.js +185 -0
  21. package/lib/host/troubleshoot.js +31 -0
  22. package/lib/host/worldbook.js +80 -0
  23. package/lib/index.js +74 -0
  24. package/locale/en.json +6 -0
  25. package/locale/zh.json +6 -0
  26. package/package.json +136 -4
  27. package/service/ICON.png +0 -0
  28. package/service/ai/AI_DATA.md +221 -0
  29. package/service/knowledge/additional/manual.jsonl +5 -0
  30. package/service/knowledge/wiki/baike.jsonl +1231 -0
  31. package/service/knowledge/wiki/chengjiu.jsonl +36 -0
  32. package/service/knowledge/wiki/gonglve.jsonl +383 -0
  33. package/service/knowledge/wiki/huodong.jsonl +427 -0
  34. package/service/knowledge/wiki/renwu.jsonl +412 -0
  35. package/service/knowledge/wiki/tujian.jsonl +2889 -0
  36. package/service/knowledge/wiki/zixun.jsonl +47 -0
  37. package/service/plugins/__init__.py +0 -0
  38. package/service/pyproject.toml +40 -0
  39. package/service/rover/__init__.py +0 -0
  40. package/service/rover/ai_core/__init__.py +0 -0
  41. package/service/rover/ai_core/models.py +50 -0
  42. package/service/rover/ai_core/rag/__init__.py +5 -0
  43. package/service/rover/ai_core/rag/knowledge.py +23 -0
  44. package/service/rover/ai_core/register.py +86 -0
  45. package/service/rover/ai_core/resource.py +6 -0
  46. package/service/rover/ai_core/skills/__init__.py +0 -0
  47. package/service/rover/ai_core/skills/operations.py +8 -0
  48. package/service/rover/api.py +720 -0
  49. package/service/rover/api_render.py +161 -0
  50. package/service/rover/app_life.py +154 -0
  51. package/service/rover/aps.py +47 -0
  52. package/service/rover/avatar.py +85 -0
  53. package/service/rover/bot.py +133 -0
  54. package/service/rover/builtin.py +152 -0
  55. package/service/rover/cards/__init__.py +26 -0
  56. package/service/rover/cards/_shared.py +129 -0
  57. package/service/rover/cards/char_list.py +258 -0
  58. package/service/rover/cards/char_panel.py +1353 -0
  59. package/service/rover/cards/echo_list.py +206 -0
  60. package/service/rover/cards/matrix_roles.py +25 -0
  61. package/service/rover/cards/rank_boards.py +714 -0
  62. package/service/rover/cards/rank_total.py +664 -0
  63. package/service/rover/compat.py +215 -0
  64. package/service/rover/config.py +163 -0
  65. package/service/rover/custom_panel.py +220 -0
  66. package/service/rover/data_store.py +31 -0
  67. package/service/rover/gss.py +19 -0
  68. package/service/rover/handler.py +191 -0
  69. package/service/rover/help/__init__.py +0 -0
  70. package/service/rover/help/draw_new_plugin_help.py +332 -0
  71. package/service/rover/help/model.py +16 -0
  72. package/service/rover/help/utils.py +7 -0
  73. package/service/rover/hooks.py +420 -0
  74. package/service/rover/html_actions.py +236 -0
  75. package/service/rover/i18n.py +19 -0
  76. package/service/rover/knowledge.py +235 -0
  77. package/service/rover/logger.py +102 -0
  78. package/service/rover/main.py +33 -0
  79. package/service/rover/media.py +222 -0
  80. package/service/rover/message_models.py +23 -0
  81. package/service/rover/models.py +92 -0
  82. package/service/rover/native_media.py +64 -0
  83. package/service/rover/ocr.py +79 -0
  84. package/service/rover/pool.py +43 -0
  85. package/service/rover/restart.py +36 -0
  86. package/service/rover/segment.py +143 -0
  87. package/service/rover/server.py +131 -0
  88. package/service/rover/status/__init__.py +0 -0
  89. package/service/rover/status/models.py +10 -0
  90. package/service/rover/status/plugin_status.py +17 -0
  91. package/service/rover/subscribe.py +148 -0
  92. package/service/rover/sv.py +403 -0
  93. package/service/rover/templates/char_list.html +182 -0
  94. package/service/rover/templates/char_panel.html +465 -0
  95. package/service/rover/templates/echo_list.html +221 -0
  96. package/service/rover/templates/rank_all_total.html +193 -0
  97. package/service/rover/templates/rank_board.html +107 -0
  98. package/service/rover/templates/rank_level_total.html +57 -0
  99. package/service/rover/templates/rank_matrix_total.html +66 -0
  100. package/service/rover/templates/rank_phantom_total.html +180 -0
  101. package/service/rover/templates/rank_slash_total.html +38 -0
  102. package/service/rover/trigger.py +98 -0
  103. package/service/rover/utils/__init__.py +0 -0
  104. package/service/rover/utils/boardcast/__init__.py +0 -0
  105. package/service/rover/utils/boardcast/models.py +15 -0
  106. package/service/rover/utils/cookie_manager/__init__.py +0 -0
  107. package/service/rover/utils/cookie_manager/qrlogin.py +24 -0
  108. package/service/rover/utils/database/__init__.py +0 -0
  109. package/service/rover/utils/database/base_models.py +1771 -0
  110. package/service/rover/utils/database/models.py +80 -0
  111. package/service/rover/utils/database/startup.py +60 -0
  112. package/service/rover/utils/default_bg/bg.jpg +0 -0
  113. package/service/rover/utils/download_resource/__init__.py +0 -0
  114. package/service/rover/utils/download_resource/download_core.py +164 -0
  115. package/service/rover/utils/download_resource/download_file.py +46 -0
  116. package/service/rover/utils/fonts/__init__.py +0 -0
  117. package/service/rover/utils/fonts/fonts.py +33 -0
  118. package/service/rover/utils/image/__init__.py +0 -0
  119. package/service/rover/utils/image/convert.py +51 -0
  120. package/service/rover/utils/image/image_tools.py +30 -0
  121. package/service/rover/utils/image/utils.py +70 -0
  122. package/service/rover/utils/plugins_config/__init__.py +0 -0
  123. package/service/rover/utils/plugins_config/gs_config.py +506 -0
  124. package/service/rover/utils/plugins_config/models.py +182 -0
  125. package/service/rover/web_app.py +3 -0
  126. package/service/rover/webconsole/__init__.py +0 -0
  127. package/service/rover/webconsole/mount_app.py +26 -0
  128. package/service/tools/migrate.py +351 -0
  129. package/service/tools/regression.py +87 -0
  130. package/service/tools/smoke.py +63 -0
  131. package/service/tools/update_plugins.py +212 -0
  132. package/service/tools/verify_actions.py +104 -0
  133. package/service/tools/verify_card.py +82 -0
  134. package/service/tools/wwdata.py +665 -0
  135. package/tests/compat.test.js +181 -0
  136. package/tests/imagebridge.test.js +58 -0
  137. package/tests/media.test.js +68 -0
  138. package/tests/native.test.js +127 -0
  139. package/tests/runtime.test.js +69 -0
  140. package/tests/service.test.js +48 -0
@@ -0,0 +1,664 @@
1
+ """角色总排行 / 声骸总排行 的 HTML 卡片。
2
+
3
+ 取数沿用原绘图函数, 版式按 PIL 版逐坐标搬运; 构图出错时回退原图。
4
+ """
5
+
6
+ import asyncio
7
+ import base64
8
+ import importlib
9
+ import time
10
+ from io import BytesIO
11
+ from typing import Any, Dict, List, Optional
12
+
13
+ from rover.cards import _shared
14
+ from rover.logger import logger
15
+
16
+ # char_mask.png 的竖向渐变(实测采样): 410px 起淡出, 500px 全透
17
+ _MASK_GRADIENT = (
18
+ "linear-gradient(to bottom, rgba(0,0,0,1) 0px, rgba(0,0,0,1) 410px,"
19
+ " rgba(0,0,0,.84) 435px, rgba(0,0,0,.6) 450px, rgba(0,0,0,.32) 465px,"
20
+ " rgba(0,0,0,.11) 480px, rgba(0,0,0,0) 500px)"
21
+ )
22
+ _HEADER_H = 500
23
+ # 标题图与人物立绘在原版里整体左移 300px 后再上遮罩
24
+ _TITLE_DX = -300
25
+
26
+
27
+ def _plugin(module, path: str):
28
+ """按插件根包取兄弟模块, 不写死插件的导入路径。"""
29
+ return importlib.import_module(f"{module.__name__.rsplit('.', 2)[0]}.{path}")
30
+
31
+
32
+ def _sibling(module, name: str):
33
+ return importlib.import_module(f"{module.__name__.rsplit('.', 1)[0]}.{name}")
34
+
35
+
36
+ def _prefix(module) -> str:
37
+ try:
38
+ return _plugin(module, "wutheringwaves_config").PREFIX
39
+ except Exception:
40
+ return "ww"
41
+
42
+
43
+ def _font_css_url(module) -> str:
44
+ try:
45
+ return _shared.font_css_url(_plugin(module, "utils.render_utils"))
46
+ except Exception:
47
+ return ""
48
+
49
+
50
+ def _css(color: Any, alpha: Optional[float] = None) -> str:
51
+ """插件里的颜色有 (r,g,b) 元组和 '#RRGGBB' 两种写法, 统一成 CSS。"""
52
+ if color is None:
53
+ return "inherit"
54
+ if isinstance(color, str):
55
+ return color
56
+ r, g, b = color[0], color[1], color[2]
57
+ if alpha is None and len(color) > 3:
58
+ alpha = color[3] / 255
59
+ if alpha is None:
60
+ return f"rgb({r},{g},{b})"
61
+ return f"rgba({r},{g},{b},{alpha:.3f})"
62
+
63
+
64
+ def _data_uri(image, quality: int = 0) -> str:
65
+ buffer = BytesIO()
66
+ if quality:
67
+ image.save(buffer, format="WEBP", quality=quality)
68
+ mime = "webp"
69
+ else:
70
+ image.save(buffer, format="PNG")
71
+ mime = "png"
72
+ return f"data:image/{mime};base64," + base64.b64encode(buffer.getvalue()).decode()
73
+
74
+
75
+ class _Assets:
76
+ """重复出现的图(武器/评级/合鸣/徽章底)只内联一次, 行内按 class 引用。"""
77
+
78
+ def __init__(self) -> None:
79
+ self.items: List[Dict[str, str]] = []
80
+ self._index: Dict[Any, str] = {}
81
+
82
+ def add(self, key: Any, image, quality: int = 0) -> str:
83
+ if image is None:
84
+ return ""
85
+ name = self._index.get(key)
86
+ if name is None:
87
+ name = f"g{len(self.items)}"
88
+ self.items.append({"cls": name, "uri": _data_uri(image, quality)})
89
+ self._index[key] = name
90
+ return name
91
+
92
+
93
+ def _bot_badge(module, assets: _Assets, background: str, name: str) -> Optional[dict]:
94
+ """bot 主人徽章: 底图按 388x72 → 208x39 等比缩放后居中, 名字压在中间。"""
95
+ if not name:
96
+ return None
97
+ try:
98
+ image = _plugin(module, "utils.image").get_bot_bg(background or "")
99
+ except Exception as e:
100
+ logger.debug(f"[鸣潮·排行卡] 徽章底图取用失败: {e}")
101
+ image = None
102
+ badge: Dict[str, Any] = {"name": name, "cls": "", "w": 0, "h": 0}
103
+ if image is not None:
104
+ badge["cls"] = assets.add(("botbg", background or "", id(image)), image)
105
+ badge["w"] = round(image.width * 208 / 388)
106
+ badge["h"] = round(image.height * 39 / 72)
107
+ return badge
108
+
109
+
110
+ def _rank_box(rank_id: int) -> dict:
111
+ """非前三名的名次底板: 位数越多底板越宽(照原版三档)。"""
112
+ if rank_id > 1000:
113
+ return {"left": 10, "w": 100, "text": "999+"}
114
+ if rank_id > 999:
115
+ return {"left": 10, "w": 100, "text": str(rank_id)}
116
+ if rank_id > 99:
117
+ return {"left": 25, "w": 75, "text": str(rank_id)}
118
+ return {"left": 40, "w": 50, "text": str(rank_id)}
119
+
120
+
121
+ async def _header_context(module, char_id: str, title_name: str, pile_x: int) -> dict:
122
+ """人物立绘头部: 标题底图 + 立绘 + 遮罩, 坐标照原版整体左移 300。"""
123
+ version = module.get_version()
124
+ header: Dict[str, Any] = {
125
+ "title_bg": _data_uri(module.TITLE_II, 85),
126
+ "logo": _data_uri(module.logo_img),
127
+ "title_dx": _TITLE_DX,
128
+ "title_name": title_name,
129
+ "version": f"v{version}",
130
+ "version_x": 240 + 31 * len(title_name),
131
+ "time": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()),
132
+ "mask": _MASK_GRADIENT,
133
+ "pile": None,
134
+ }
135
+ try:
136
+ pile, _ = await module.get_role_pile_default(char_id, custom=True)
137
+ header["pile"] = {
138
+ "uri": _data_uri(pile, 82),
139
+ "x": pile_x,
140
+ "y": -120,
141
+ "w": pile.width,
142
+ "h": pile.height,
143
+ }
144
+ except Exception as e:
145
+ logger.debug(f"[鸣潮·排行卡] 立绘取用失败 char_id={char_id}: {e}")
146
+ return header
147
+
148
+
149
+ def _background(module, quality: int = 72) -> str:
150
+ bg = module.get_custom_waves_bg(bg="bg3", crop=False)
151
+ return _data_uri(bg.convert("RGB"), quality)
152
+
153
+
154
+ # ---------------------------------------------------------------- 角色总排行
155
+
156
+
157
+ async def _build_all_rank(module, bot, ev, char, rank_type, pages, modal, group_uids):
158
+ self_uid = ""
159
+ is_self_ck = False
160
+ try:
161
+ self_uid = await module.WavesBind.get_uid_by_game(ev.user_id, ev.bot_id)
162
+ is_self_ck, _ = await module.waves_api.get_ck_result(self_uid, ev.user_id, ev.bot_id)
163
+ except Exception:
164
+ pass
165
+
166
+ char_id = module.char_name_to_char_id(char)
167
+ if not char_id:
168
+ return "未找到指定角色, 请检查输入是否正确!"
169
+ char_name = module.alias_to_char_name(char)
170
+ char_model = module.get_char_model(char_id)
171
+ if not char_model:
172
+ return f"[鸣潮] 角色名【{char_name}】暂未适配!\n"
173
+ attribute_name = module.ATTRIBUTE_ID_MAP[char_model.attributeId]
174
+
175
+ rank_type_num = 3 if rank_type == "综合评分" else (2 if rank_type == "伤害" else 1)
176
+ page_num = module.RANK_PAGE_SIZE
177
+ if not modal:
178
+ options = module.get_modal_options(int(char_id))
179
+ if options:
180
+ role = await module.find_role_detail(self_uid, char_id) if self_uid else None
181
+ modal = module.get_role_modal(role) if role else module.get_default_modal(int(char_id))
182
+
183
+ is_group = group_uids is not None
184
+ if is_group:
185
+ resp = await module.get_cards_rank(
186
+ module.CardsRankRequest(
187
+ char_id=int(char_id),
188
+ rank_type=rank_type_num,
189
+ modal=modal,
190
+ waves_ids=[str(u) for u in group_uids if u],
191
+ )
192
+ )
193
+ if not resp:
194
+ return "获取群排行失败"
195
+ if not resp.data or not resp.data.details:
196
+ return "暂无排行数据"
197
+ details = [d for d in resp.data.details if d.overall_score > 0]
198
+ if not details:
199
+ return "[鸣潮] 群内暂无该角色综合评分数据\n需【登录】并【刷新单角色面板】上传后才会上榜"
200
+ details.sort(key=lambda d: d.overall_score, reverse=True)
201
+ for i, d in enumerate(details):
202
+ d.rank = i + 1
203
+ self_entry = next((d for d in details if self_uid and d.waves_id == self_uid), None)
204
+ details, _, page_count, page_item_count = module.paginate_group_rank(
205
+ details, pages, self_entry.rank if self_entry else None, self_entry
206
+ )
207
+ if page_item_count == 0:
208
+ return module.group_rank_empty_page_message(pages, page_count)
209
+ else:
210
+ item = module.RankItem(
211
+ char_id=int(char_id),
212
+ page=pages,
213
+ page_num=page_num,
214
+ rank_type=rank_type_num,
215
+ waves_id=self_uid,
216
+ version=module.get_version(
217
+ dynamic=True, waves_id=self_uid, char_id=char_id, rank_type=rank_type, pages=pages
218
+ ),
219
+ modal=modal,
220
+ )
221
+ rank_info = await module.get_rank(item)
222
+ if not rank_info:
223
+ return "获取排行失败"
224
+ if rank_info.message and not rank_info.data:
225
+ return rank_info.message
226
+ if not rank_info.data or not rank_info.data.details:
227
+ return "暂无排行数据"
228
+ details = rank_info.data.details
229
+
230
+ assets = _Assets()
231
+ avatars = await asyncio.gather(
232
+ *[
233
+ module.get_avatar(rank.user_id, getattr(rank, "sender_avatar", ""), char_id=rank.char_id)
234
+ for rank in details
235
+ ]
236
+ )
237
+ mask_uid = None
238
+ if is_group:
239
+ mask_uid = await module.build_uid_masker(
240
+ [(d.waves_id, d.user_id) for d in details], ev.bot_id
241
+ )
242
+
243
+ attribute_icon = assets.add(
244
+ ("attr", attribute_name),
245
+ (await module.get_attribute(attribute_name, is_simple=True)).resize((40, 40)).convert("RGBA"),
246
+ )
247
+
248
+ prefix = _prefix(module)
249
+ rows: List[dict] = []
250
+ total_score = 0.0
251
+ total_damage = 0.0
252
+ avg_num = 0
253
+ damage_name = ""
254
+ valid_pairs = [(r, a) for r, a in zip(details, avatars) if r.rank > 0]
255
+
256
+ for index, (rank, avatar) in enumerate(valid_pairs):
257
+ damage_name = rank.expected_name
258
+ weapon_model = module.get_weapon_model(rank.weapon_id)
259
+ if not weapon_model:
260
+ logger.warning("[鸣潮·排行卡] 武器无法找到, 可能暂未适配")
261
+ continue
262
+
263
+ weapon_icon = module.crop_center_img(await module.get_square_weapon(rank.weapon_id), 110, 110)
264
+ weapon_bg = module.get_weapon_icon_bg(weapon_model.starLevel, module.TEXT_PATH)
265
+ weapon_bg.paste(weapon_icon, (10, 20), weapon_icon)
266
+
267
+ score_value = rank.overall_score if rank_type == "综合评分" else rank.phantom_score
268
+ score: Optional[dict] = None
269
+ if score_value > 0.0:
270
+ grade = (
271
+ module.get_panel_score_grade(score_value)
272
+ if rank_type == "综合评分"
273
+ else rank.phantom_score_bg
274
+ )
275
+ emblem = module.Image.open(module.TEXT_PATH / f"score_{grade}.png")
276
+ score = {
277
+ "cls": assets.add(("score", grade), emblem),
278
+ "value": f"{int(score_value * 100) / 100:.2f}",
279
+ "label": "综合评分" if rank_type == "综合评分" else "声骸分数",
280
+ }
281
+
282
+ if rank.sonata_name:
283
+ sonata_label = module.get_sonata_label(rank.sonata_name)
284
+ sonata_cls = assets.add(
285
+ ("sonata", rank.sonata_name), await module.get_sonata_effect_image(rank.sonata_name, 50)
286
+ )
287
+ else:
288
+ sonata_label, sonata_cls = "合鸣效果", ""
289
+
290
+ if is_group:
291
+ uid_text = mask_uid(rank.waves_id, rank.user_id)
292
+ else:
293
+ uid_text = module.hide_uid(rank.waves_id, user_pref="on" if rank.hide_uid else "")
294
+
295
+ rows.append(
296
+ {
297
+ "rank": rank.rank,
298
+ "medal": assets.add(
299
+ ("medal", rank.rank), _sibling(module, "rank_badge")._load_badge(rank.rank)
300
+ ),
301
+ "box": _rank_box(rank.rank),
302
+ "avatar": _data_uri(avatar, 80),
303
+ "chain": module.get_chain_name(rank.chain),
304
+ "chain_color": _css(module.CHAIN_COLOR[rank.chain], 0.9),
305
+ "level": f"Lv.{rank.level}",
306
+ "attribute": attribute_icon,
307
+ "uid": f"特征码: {uid_text}",
308
+ "uid_color": _css(module.RED) if is_self_ck and self_uid == rank.waves_id else "white",
309
+ # 点行按对方 uid 查这个角色的面板(对方未公开则由命令自己回提示)
310
+ "cmd": f"{_shared.prefix()}{rank.waves_id}{char_name}面板" if rank.waves_id else "",
311
+ "name": str(rank.kuro_name),
312
+ "badge": _bot_badge(module, assets, getattr(rank, "background", ""), rank.alias_name),
313
+ "score": score,
314
+ "sonata": sonata_cls,
315
+ "sonata_label": sonata_label,
316
+ "sonata_size": 14 if len(sonata_label) > 4 else 16,
317
+ "weapon": {
318
+ "cls": assets.add(("weapon", rank.weapon_id, weapon_model.starLevel), weapon_bg),
319
+ "name": weapon_model.name,
320
+ "level": f"Lv.{rank.weapon_level}/90",
321
+ "reson": f"{rank.weapon_reson_level}阶",
322
+ "reson_color": _css(module.WEAPON_RESONLEVEL_COLOR[rank.weapon_reson_level], 0.8),
323
+ },
324
+ "damage": f"{rank.expected_damage:,.0f}",
325
+ "damage_name": rank.expected_name,
326
+ }
327
+ )
328
+
329
+ if index + 1 + (pages - 1) * page_num == rank.rank:
330
+ total_score += rank.overall_score if rank_type == "综合评分" else rank.phantom_score
331
+ total_damage += rank.expected_damage
332
+ avg_num += 1
333
+
334
+ title_char = module.SPECIAL_CHAR_NAME.get(char_id, char_name)
335
+ title_name = f"{title_char}{rank_type}{'群排行' if is_group else '总排行'}"
336
+
337
+ modal_options = module.get_modal_options(int(char_id))
338
+ if rank_type == "伤害":
339
+ notes = "排行标准:以期望伤害(计算暴击率的伤害,不代表实际伤害) 为排序的排名"
340
+ elif rank_type == "综合评分":
341
+ notes = "综合评分为个性化标准,按各自配置估算得出,仅供参考,非公平对比。"
342
+ else:
343
+ notes = "排行标准:以声骸分数(声骸评分高,不代表实际伤害高) 为排序的排名"
344
+ cond = {
345
+ "height": 170 if modal_options else 130,
346
+ "line1": "1. 声骸套装为常规套装",
347
+ "line2": "2. 登录用户&刷新单角色面板" if rank_type == "综合评分" else "2. 登录用户&刷新面板",
348
+ "modal": "",
349
+ "modal_current": "",
350
+ "notes": notes,
351
+ }
352
+ if modal_options:
353
+ names = "/".join(o["name"] for o in modal_options)
354
+ cond["modal"] = f"支持模态: {prefix}{char}总排行 {names}"
355
+ current = module.get_modal_name(int(char_id), modal)
356
+ if current:
357
+ cond["modal_current"] = f"当前模态: {current}"
358
+
359
+ header = await _header_context(module, char_id, title_name, 600)
360
+ header["stats"] = [
361
+ {
362
+ "x": 300,
363
+ "value": f"{total_score / avg_num:.1f}" if avg_num else "0",
364
+ "label": "平均综合评分" if rank_type == "综合评分" else "平均声骸分数",
365
+ },
366
+ {
367
+ "x": 490,
368
+ "value": f"{total_damage / avg_num:,.0f}" if avg_num else "0",
369
+ "label": "平均治疗量" if "治疗" in damage_name else "平均伤害",
370
+ },
371
+ ]
372
+
373
+ return {
374
+ "card_w": 1300,
375
+ "header_h": _HEADER_H,
376
+ "row_h": 110,
377
+ "bg": _background(module),
378
+ "bar": _data_uri(module.Image.open(module.TEXT_PATH / "bar1.png")),
379
+ "font_css_url": _font_css_url(module),
380
+ "gold": _css(module.SPECIAL_GOLD),
381
+ "grey": _css(module.GREY),
382
+ "header": header,
383
+ "cond": cond,
384
+ "rows": rows,
385
+ "assets": assets.items,
386
+ "cmd_score": f"{prefix}{char_name}评分总排行",
387
+ "cmd_damage": f"{prefix}{char_name}总排行",
388
+ "cmd_phantom": f"{prefix}{char_name}声骸总排行",
389
+ }
390
+
391
+
392
+ # ---------------------------------------------------------------- 声骸总排行
393
+
394
+
395
+ def _phantom_geometry(module) -> dict:
396
+ """行内坐标全部取原模块实测常量, 保证与 PIL 版逐像素一致。"""
397
+ return {
398
+ "width": module.WIDTH,
399
+ "item_h": module.ITEM_H,
400
+ "rank_x": module._RANK_X,
401
+ "medal_y": module._RANK_MEDAL_Y,
402
+ "medal_size": module._RANK_MEDAL_SIZE[0],
403
+ "box_y": module._RANK_BOX_Y,
404
+ "box_h": module._RANK_BOX_SIZE[1],
405
+ "box_cx": module._RANK_X + module._RANK_BOX_SIZE[0] // 2,
406
+ "user_x": module.USER_INFO_X,
407
+ "name_y": module._USER_NAME_Y,
408
+ "name_max": module._USER_NAME_MAX_WIDTH,
409
+ "uid_y": module._USER_UID_Y,
410
+ "badge_x": module._BOT_BADGE_POS[0],
411
+ "badge_y": module._BOT_BADGE_POS[1],
412
+ "thumb_x": module.PHANTOM_THUMB_X - module._THUMB_SHIFT,
413
+ "icon_y": module._PHANTOM_ICON_Y,
414
+ "icon_size": module._PHANTOM_ICON_SIZE[0],
415
+ "fetter_dx": 58,
416
+ "fetter_y": module._FETTER_ICON_POS[1],
417
+ "fetter_size": module._FETTER_ICON_SIZE[0],
418
+ "pip_size": module._PROMOTE_SIZE[0],
419
+ "pip_y": module._PROMOTE_Y,
420
+ "name_col_max": module._NAME_MAX,
421
+ "emblem_x": module._EMBLEM_X,
422
+ "emblem_y": module._EMBLEM_Y,
423
+ "emblem_size": module._EMBLEM_SIZE[0],
424
+ "score_x": module._SCORE_NUM_X,
425
+ "score_y": module._SCORE_NUM_Y,
426
+ "score_label_x": module._SCORE_LABEL_X,
427
+ "score_label_y": module._SCORE_LABEL_Y,
428
+ }
429
+
430
+
431
+ def _name_size(name: str, max_width: int) -> int:
432
+ """词条名照原版逐级缩字号(18/16/14), 汉字宽度按字号估算。"""
433
+ for size in (18, 16, 14):
434
+ if len(name) * size <= max_width:
435
+ return size
436
+ return 14
437
+
438
+
439
+ def _phantom_props(module, detail, calc_map, geo) -> List[dict]:
440
+ """词条 2 列表: 名左对齐, 数值与单词条分各按固定右边界对齐。"""
441
+ entries = module.build_entries(detail.main_props, detail.sub_props, calc_map)
442
+ props: List[dict] = []
443
+ for i, entry in enumerate(entries[:6]):
444
+ name, value, pscore, is_main, name_color, num_color = entry
445
+ row, col = divmod(i, 2)
446
+ name_x = module._COL_NAME_X[col]
447
+ score_right = module._COL_SCORE_RIGHT[col]
448
+ props.append(
449
+ {
450
+ "name": name,
451
+ "name_size": _name_size(name, geo["name_col_max"]),
452
+ "value": value,
453
+ "score": f"+{pscore:.2f}",
454
+ "name_x": name_x,
455
+ "value_right": geo["width"] - module._COL_VALUE_RIGHT[col],
456
+ "score_right": geo["width"] - score_right,
457
+ "y": module._PROP_ROW_Y[row],
458
+ "name_color": _css(name_color),
459
+ "value_color": _css(num_color),
460
+ "score_color": _css(module.SCORE_POS if pscore > 0 else module.SCORE_ZERO),
461
+ "highlight": {"left": name_x - 6, "w": score_right - name_x + 12} if is_main else None,
462
+ }
463
+ )
464
+ return props
465
+
466
+
467
+ async def _build_phantom_rank(module, bot, ev, char, pages):
468
+ char_id = module.char_name_to_char_id(char)
469
+ if not char_id:
470
+ return "未找到指定角色, 请检查输入是否正确!"
471
+ char = module.char_id_to_char_name(char_id) or char
472
+ self_uid = await module.WavesBind.get_uid_by_game(ev.user_id, ev.bot_id) or ""
473
+
474
+ rank_info = await module.get_phantom_total_rank(
475
+ module.PhantomTotalRankRequest(
476
+ char_id=int(char_id),
477
+ page=pages,
478
+ page_num=module.RANK_PAGE_SIZE,
479
+ waves_id=self_uid,
480
+ version=module.get_version(dynamic=True, waves_id=self_uid, pages=pages),
481
+ )
482
+ )
483
+ if not rank_info:
484
+ return "获取声骸总排行失败"
485
+ if rank_info.message and not rank_info.data:
486
+ return rank_info.message
487
+ if not rank_info.data or not rank_info.data.rank_list:
488
+ return "暂无排行数据"
489
+
490
+ ranking = rank_info.data.rank_list
491
+ details = list(ranking)
492
+ self_entry = getattr(rank_info.data, "self_entry", None)
493
+ if self_uid and self_entry is not None:
494
+ if self_entry.rank and self_entry.rank > 0:
495
+ self_row = self_entry
496
+ else:
497
+ self_row = await module._build_local_self_row(self_uid, char_id, self_entry)
498
+ if self_row is not None:
499
+ details.append(self_row)
500
+ if not details:
501
+ return f"[鸣潮] 暂无【{char}】声骸总排行数据"
502
+
503
+ try:
504
+ calc_map = module.get_calc_map({}, char, char_id)
505
+ except Exception as e:
506
+ logger.debug(f"[鸣潮·排行卡] 词条权重取用失败 char_id={char_id}: {e}")
507
+ calc_map = None
508
+
509
+ avatars, phantom_icons, fetter_icons = await asyncio.gather(
510
+ asyncio.gather(
511
+ *[module.get_avatar(d.user_id, getattr(d, "sender_avatar", ""), char_id=d.char_id) for d in details]
512
+ ),
513
+ asyncio.gather(*[module._safe_phantom_icon(d) for d in details]),
514
+ asyncio.gather(*[module._safe_fetter_icon(d.set) for d in details]),
515
+ )
516
+
517
+ geo = _phantom_geometry(module)
518
+ assets = _Assets()
519
+ rows: List[dict] = []
520
+ for detail, avatar, phantom_icon, fetter_icon in zip(details, avatars, phantom_icons, fetter_icons):
521
+ avatar_box = None
522
+ if avatar is not None:
523
+ bbox = avatar.getchannel("A").getbbox()
524
+ if bbox is not None:
525
+ avatar_box = {
526
+ "uri": _data_uri(avatar, 80),
527
+ "x": module._AVATAR_VISIBLE_X - bbox[0],
528
+ "y": module.FRAME_TOP
529
+ + (module.FRAME_BOTTOM - module.FRAME_TOP - (bbox[3] - bbox[1])) // 2
530
+ - bbox[1],
531
+ "w": avatar.width,
532
+ "h": avatar.height,
533
+ }
534
+ emblem = None
535
+ try:
536
+ emblem = module.Image.open(module.TEXT_PATH / f"score_{detail.grade}.png").resize(
537
+ module._EMBLEM_SIZE
538
+ )
539
+ except Exception as e:
540
+ logger.debug(f"[鸣潮·排行卡] 评级贴图缺失 grade={detail.grade}: {e}")
541
+
542
+ pip_gap = 3
543
+ pip_step = geo["pip_size"] + pip_gap
544
+ pips_w = detail.cost * geo["pip_size"] + max(detail.cost - 1, 0) * pip_gap
545
+ pips_x = geo["thumb_x"] + (geo["icon_size"] - pips_w) // 2
546
+
547
+ rows.append(
548
+ {
549
+ "rank": detail.rank,
550
+ "medal": assets.add(("medal", detail.rank), module._load_badge(detail.rank)),
551
+ "box": _phantom_rank_box(module, detail.rank),
552
+ "avatar": avatar_box,
553
+ "name": str(detail.kuro_name),
554
+ "uid": f"特征码: {module.hide_uid(detail.waves_id, user_pref='on' if detail.hide_uid else '')}",
555
+ "uid_color": _css(module.RED) if detail.waves_id == self_uid else "white",
556
+ "badge": _bot_badge(
557
+ module, assets, getattr(detail, "background", ""), getattr(detail, "alias_name", "")
558
+ ),
559
+ "icon": assets.add(("phantom", detail.phantom_id), phantom_icon, 85),
560
+ "fetter": assets.add(("fetter", detail.set), fetter_icon),
561
+ "pips": [pips_x + pip_step * i for i in range(detail.cost)],
562
+ "props": _phantom_props(module, detail, calc_map, geo),
563
+ "emblem": assets.add(("grade", detail.grade), emblem),
564
+ "score": f"{detail.score:.2f}",
565
+ }
566
+ )
567
+
568
+ avg_value = sum(d.score for d in ranking) / len(ranking) if ranking else 0
569
+ title_char = module.SPECIAL_CHAR_NAME.get(char_id, char)
570
+ title_name = f"{title_char}声骸总排行"
571
+ header = await _header_context(module, char_id, title_name, geo["width"] - 700)
572
+ header["stats"] = [{"x": 300, "value": f"{avg_value:.1f}", "label": "平均单声骸分数"}]
573
+
574
+ prefix = _prefix(module)
575
+ return {
576
+ "card_w": geo["width"],
577
+ "header_h": _HEADER_H,
578
+ "geo": geo,
579
+ "bg": _background(module),
580
+ "bar": _data_uri(module.draw_phantom_bar_bg(module.Image.open(module.TEXT_PATH / "bar1.png"))),
581
+ "pip": _data_uri(module.promote_icon.resize(module._PROMOTE_SIZE)),
582
+ "font_css_url": _font_css_url(module),
583
+ "gold": _css(module.SPECIAL_GOLD),
584
+ "grey": _css(module.GREY),
585
+ "header": header,
586
+ "cond": {
587
+ "height": 130,
588
+ "line1": "1. 声骸满 5 副词条 & 常规有效套装",
589
+ "line2": "2. 登录用户 & 刷新面板",
590
+ "notes": "排行标准:以单声骸分数排序 (评分高不代表实际伤害高)",
591
+ },
592
+ "rows": rows,
593
+ "assets": assets.items,
594
+ "cmd_damage": f"{prefix}{char}总排行",
595
+ "cmd_score": f"{prefix}{char}评分总排行",
596
+ }
597
+
598
+
599
+ def _phantom_rank_box(module, rank_id: int) -> dict:
600
+ """声骸榜名次底板: 金框外空间有限, 4 位数字换小一号字。"""
601
+ if rank_id > 999:
602
+ return {"text": "999+" if rank_id > 1000 else str(rank_id), "w": 88, "size": 30}
603
+ if rank_id > 99:
604
+ return {"text": str(rank_id), "w": 72, "size": 34}
605
+ return {"text": str(rank_id), "w": module._RANK_BOX_SIZE[0], "size": 34}
606
+
607
+
608
+ # ---------------------------------------------------------------- 补丁挂载
609
+
610
+
611
+ def _render(template_name: str, context: dict):
612
+ card = _shared.render(template_name, context)
613
+ logger.info(f"[鸣潮·排行卡] {template_name} {len(card) / 1024 / 1024:.2f}MB")
614
+ return card
615
+
616
+
617
+ def _bind(module, name: str, func) -> None:
618
+ """功能模块 import 时已按值绑定, 包内同名引用一并换掉。"""
619
+ import sys
620
+
621
+ setattr(module, name, func)
622
+ package = sys.modules.get(module.__name__.rsplit(".", 1)[0])
623
+ if package is not None and hasattr(package, name):
624
+ setattr(package, name, func)
625
+
626
+
627
+ def patch_all_rank(module) -> None:
628
+ original = module.draw_all_rank_card
629
+
630
+ async def draw_all_rank_card(bot, ev, char, rank_type, pages, modal="", group_uids=None):
631
+ try:
632
+ context = await _build_all_rank(module, bot, ev, char, rank_type, pages, modal, group_uids)
633
+ if isinstance(context, str):
634
+ return context
635
+ return _render("rank_all_total.html", context)
636
+ except Exception as e:
637
+ logger.exception(f"[鸣潮·排行卡] 角色总排行渲染失败, 回退原图: {e}")
638
+ return await original(bot, ev, char, rank_type, pages, modal, group_uids)
639
+
640
+ _bind(module, "draw_all_rank_card", draw_all_rank_card)
641
+
642
+
643
+ def patch_phantom_rank(module) -> None:
644
+ original = module.draw_phantom_total_rank
645
+
646
+ async def draw_phantom_total_rank(bot, ev, char, pages):
647
+ try:
648
+ context = await _build_phantom_rank(module, bot, ev, char, pages)
649
+ if isinstance(context, str):
650
+ return context
651
+ return _render("rank_phantom_total.html", context)
652
+ except Exception as e:
653
+ logger.exception(f"[鸣潮·排行卡] 声骸总排行渲染失败, 回退原图: {e}")
654
+ return await original(bot, ev, char, pages)
655
+
656
+ _bind(module, "draw_phantom_total_rank", draw_phantom_total_rank)
657
+
658
+
659
+ PATCHES = {
660
+ "XutheringWavesUID": {
661
+ "wutheringwaves_rank.draw_all_rank_card": patch_all_rank,
662
+ "wutheringwaves_rank.draw_phantom_total_rank_card": patch_phantom_rank,
663
+ }
664
+ }