@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,1353 @@
1
+ """角色面板 HTML 卡片: 面板/查询/伤害/权重/优化 五个入口接管 PIL 出图。
2
+
3
+ 上游取数流程在此复刻一遍, 只把最后的画布排版换成模板渲染;
4
+ 任何一步出错都回落原函数, 命令不会因此失败。
5
+ """
6
+
7
+ import base64
8
+ import copy
9
+ import importlib
10
+ import sys
11
+ from contextlib import nullcontext
12
+ from io import BytesIO
13
+ from pathlib import Path
14
+ from typing import Any, Dict, List, Optional, Tuple
15
+
16
+ from rover.logger import logger
17
+
18
+ TEMPLATE_DIR = Path(__file__).resolve().parents[1] / "templates"
19
+ TEMPLATE_NAME = "char_panel.html"
20
+
21
+ # 图片编码结果按 key 复用, 素材是静态的
22
+ _URI_CACHE: Dict[str, str] = {}
23
+ _env = None
24
+
25
+
26
+ # ── 基础工具 ────────────────────────────────────────────────────────────
27
+
28
+
29
+ def _template():
30
+ global _env
31
+ if _env is None:
32
+ from jinja2 import Environment, FileSystemLoader, select_autoescape
33
+
34
+ _env = Environment(
35
+ loader=FileSystemLoader(str(TEMPLATE_DIR)),
36
+ autoescape=select_autoescape(["html"]),
37
+ )
38
+ return _env.get_template(TEMPLATE_NAME)
39
+
40
+
41
+ def _color(value: Any, default: str = "#ffffff") -> str:
42
+ """PIL 的颜色写法统一成 CSS。"""
43
+ if value is None:
44
+ return default
45
+ if isinstance(value, (tuple, list)):
46
+ parts = list(value)[:4]
47
+ if len(parts) == 4:
48
+ r, g, b, a = parts
49
+ return f"rgba({int(r)},{int(g)},{int(b)},{round(int(a) / 255, 3)})"
50
+ if len(parts) == 3:
51
+ return "rgb({},{},{})".format(*[int(i) for i in parts])
52
+ return default
53
+ return str(value)
54
+
55
+
56
+ def _encode(img, key: str = "", quality: int = 88) -> str:
57
+ """PIL 图 → data URI(webp), 有 key 时跨请求复用。"""
58
+ if key and key in _URI_CACHE:
59
+ return _URI_CACHE[key]
60
+ buffer = BytesIO()
61
+ img.save(buffer, format="WEBP", quality=quality, method=4)
62
+ uri = "data:image/webp;base64," + base64.b64encode(buffer.getvalue()).decode()
63
+ if key:
64
+ if len(_URI_CACHE) > 400:
65
+ _URI_CACHE.clear()
66
+ _URI_CACHE[key] = uri
67
+ return uri
68
+
69
+
70
+ def _fit(img, width: int = 0, height: int = 0):
71
+ if width and height and img.size != (width, height):
72
+ from PIL import Image
73
+
74
+ return img.resize((width, height), Image.LANCZOS)
75
+ return img
76
+
77
+
78
+ class Icons:
79
+ """同一张图在页面里只出一份 base64, 元素按 class 引用。"""
80
+
81
+ def __init__(self):
82
+ self.entries: List[Tuple[str, str]] = []
83
+ self.index: Dict[str, str] = {}
84
+
85
+ def put(self, key: str, uri: str) -> str:
86
+ if not uri:
87
+ return ""
88
+ name = self.index.get(key)
89
+ if name is None:
90
+ name = f"i{len(self.entries)}"
91
+ self.index[key] = name
92
+ self.entries.append((name, uri))
93
+ return name
94
+
95
+ def has(self, key: str) -> Optional[str]:
96
+ return self.index.get(key)
97
+
98
+ def image(self, key: str, img, width: int = 0, height: int = 0, quality: int = 88, cache: bool = True) -> str:
99
+ name = self.index.get(key)
100
+ if name is not None:
101
+ return name
102
+ return self.put(key, _encode(_fit(img, width, height), key if cache else "", quality))
103
+
104
+
105
+ # ── 插件侧模块与素材 ────────────────────────────────────────────────────
106
+
107
+
108
+ def _root(m) -> str:
109
+ return m.__name__.rsplit(".", 2)[0]
110
+
111
+
112
+ def _mod(m, suffix: str):
113
+ return importlib.import_module(f"{_root(m)}.{suffix}")
114
+
115
+
116
+ def _sibling(m, name: str):
117
+ return importlib.import_module(f"{m.__name__.rsplit('.', 1)[0]}.{name}")
118
+
119
+
120
+ def _texture_uri(m, name: str, quality: int = 90) -> str:
121
+ """charinfo 的 texture2d 素材, 返回 data URI。"""
122
+ from PIL import Image
123
+
124
+ key = f"tex:{name}"
125
+ if key in _URI_CACHE:
126
+ return _URI_CACHE[key]
127
+ path = m.TEXT_PATH / name
128
+ if not path.exists():
129
+ return ""
130
+ return _encode(Image.open(path).convert("RGBA"), key, quality)
131
+
132
+
133
+ def _texture(m, name: str, icons: Icons, quality: int = 90) -> str:
134
+ cached = icons.has(f"tex:{name}")
135
+ if cached:
136
+ return cached
137
+ return icons.put(f"tex:{name}", _texture_uri(m, name, quality))
138
+
139
+
140
+ def _font_css_url() -> str:
141
+ from rover.config import core_config
142
+
143
+ host = core_config.get_config("HOST") or "127.0.0.1"
144
+ if host in ("0.0.0.0", ""):
145
+ host = "127.0.0.1"
146
+ return f"http://{host}:{core_config.get_config('PORT')}/waves/fonts/fonts.css"
147
+
148
+
149
+ async def _background(m) -> str:
150
+ """卡片底图: 沿用 bg3 与自定义背景开关, 模糊参数也跟着配置走。"""
151
+ from PIL import Image
152
+
153
+ path = None
154
+ if m.ShowConfig.get_config("CardBg").data:
155
+ custom = Path(m.ShowConfig.get_config("CardBgPath").data)
156
+ if custom.is_file():
157
+ path = custom
158
+ key = f"bg:{path or 'bg3'}"
159
+ if key in _URI_CACHE:
160
+ return _URI_CACHE[key]
161
+ img = Image.open(path).convert("RGBA") if path else m.get_waves_bg(bg="bg3", crop=False)
162
+ scale = 1200 / img.width
163
+ img = img.resize((1200, max(1, int(img.height * scale))), Image.LANCZOS)
164
+ img = await m.get_custom_gaussian_blur(img)
165
+ return _encode(img.convert("RGB"), key, quality=72)
166
+
167
+
168
+ def _footer(m, icons: Icons) -> str:
169
+ image_mod = _mod(m, "utils.image")
170
+ return icons.image("footer", image_mod.get_footer("white"), quality=90)
171
+
172
+
173
+ # ── 头部 / 立绘 / 右侧 ──────────────────────────────────────────────────
174
+
175
+
176
+ def _header_ctx(m, icons: Icons, account_info, avatar, uid, user_pref, locale) -> dict:
177
+ """头部照画布版摆: base_info_bg(35,-30) / 头像(45,20)+光环(55,30) / title_bar(200,15)。"""
178
+ name = account_info.name[:10] if account_info else (uid or "")
179
+ ident = account_info.id if account_info else (uid or "")
180
+ image_mod = _mod(m, "utils.image")
181
+ logo = image_mod.get_small_logo(2)
182
+ return {
183
+ "avatar": icons.image(f"avatar:{ident}", avatar, quality=90, cache=False),
184
+ "avatar_ring": _texture(m, "avatar_ring.png", icons),
185
+ "base_info": _texture(m, "base_info_bg.png", icons),
186
+ "title_bar": _texture(m, "title_bar.png", icons),
187
+ "logo": icons.image("small_logo", logo, quality=90),
188
+ "logo_w": logo.width,
189
+ "logo_h": logo.height,
190
+ "name": name,
191
+ "uid": m.hide_uid(ident, user_pref=user_pref),
192
+ "uid_label": m.t("特征码", locale),
193
+ "level": account_info.level if account_info else "",
194
+ "world_level": account_info.worldLevel if account_info else "",
195
+ "level_label": m.t("联觉等级", locale),
196
+ "world_label": m.t("索拉等阶", locale),
197
+ "show_stats": bool(account_info and account_info.is_full),
198
+ }
199
+
200
+
201
+ async def _pile_ctx(m, icons: Icons, role_detail, locale, uid, char_name) -> dict:
202
+ """立绘区: 立绘按 char_mask 裁形, char_fg 压在上面, 属性/武器型标在原坐标。"""
203
+ pin_token = None
204
+ if uid and char_name and m._force_pile_path.get() is None:
205
+ try:
206
+ pin_key = m.panel_card_pref.pair_pin_key(role_detail.role.roleId, char_name)
207
+ pinned_hash = m.panel_card_pref.get_pin(str(uid), pin_key)
208
+ if pinned_hash:
209
+ pinned_path = m._hash_lookup_in_pair("card", str(role_detail.role.roleId), pinned_hash)
210
+ if pinned_path is not None and pinned_path.is_file():
211
+ pin_token = m._force_pile_path.set(pinned_path)
212
+ except Exception as e:
213
+ logger.debug(f"[面板] 面板图绑定不可用, 用默认图: {e}")
214
+ try:
215
+ is_custom, role_pile, role_pile_path = await m.get_role_pile_with_path(role_detail.role.roleId, True)
216
+ finally:
217
+ if pin_token is not None:
218
+ m._force_pile_path.reset(pin_token)
219
+
220
+ from PIL import Image
221
+
222
+ canvas = Image.new("RGBA", (560, 1000))
223
+ pile = m.resize_and_center_image(role_pile, is_custom=is_custom)
224
+ canvas.paste(pile, ((560 - pile.size[0]) // 2, (1000 - pile.size[1]) // 2), pile)
225
+
226
+ attribute = await m.get_attribute(role_detail.role.attributeName)
227
+ weapon_type = await m.get_weapon_type(role_detail.role.weaponTypeName)
228
+ role_name = role_detail.role.roleName
229
+ if "漂泊者" in role_name:
230
+ role_name = "漂泊者"
231
+
232
+ hash_id = ""
233
+ if is_custom and role_pile_path is not None:
234
+ hash_id = m.compute_hash(role_pile_path.name)
235
+
236
+ return {
237
+ "image": icons.image(
238
+ f"pile:{role_pile_path}", canvas, quality=82, cache=role_pile_path is not None
239
+ ),
240
+ "fg": _texture(m, "char_fg.png", icons),
241
+ "attribute": icons.image(f"attr:{role_detail.role.attributeName}", attribute, 50, 50),
242
+ "weapon_type": icons.image(f"wtype:{role_detail.role.weaponTypeName}", weapon_type, 40, 40),
243
+ "name": f"{m.t(role_name, locale)} Lv.{role_detail.role.level}",
244
+ "hash_id": hash_id,
245
+ }
246
+
247
+
248
+ async def _prop_icon(m, icons: Icons, name: str) -> str:
249
+ key = f"prop:{name}"
250
+ cached = icons.has(key)
251
+ if cached:
252
+ return cached
253
+ return icons.image(key, await m.get_attribute_prop(name), 40, 40)
254
+
255
+
256
+ async def _panel_props(m, icons: Icons, calc, role_detail, locale) -> List[dict]:
257
+ """右侧十项面板属性, 末位取治疗与四类技能加成里的最高项。"""
258
+ shuxing = f"{role_detail.role.attributeName}伤害加成"
259
+
260
+ def percent(value):
261
+ try:
262
+ return float(str(value).replace("%", ""))
263
+ except (ValueError, TypeError):
264
+ return 0.0
265
+
266
+ candidates = ("治疗效果加成", "普攻伤害加成", "重击伤害加成", "共鸣技能伤害加成", "共鸣解放伤害加成")
267
+ last_slot = max(candidates, key=lambda k: percent(calc.role_card.get(k, "0")))
268
+ items = list(m.card_sort_name[:-1]) + [(last_slot, "0.0%")]
269
+
270
+ rows = []
271
+ for index, (name, default) in enumerate(items):
272
+ if name == "属性伤害加成":
273
+ value = calc.role_card.get(shuxing, default)
274
+ icon = await _prop_icon(m, icons, shuxing)
275
+ color, _ = m.get_valid_color(shuxing, value, calc.calc_temp)
276
+ label = m.t(shuxing, locale)
277
+ else:
278
+ value = calc.role_card.get(name, default)
279
+ icon = await _prop_icon(m, icons, name)
280
+ color, _ = m.get_valid_color(name, value, calc.calc_temp)
281
+ label = m.t(name.replace("破坏", "") if index < 4 else name, locale)
282
+ rows.append({"icon": icon, "name": label, "value": str(value), "color": _color(color)})
283
+ return rows
284
+
285
+
286
+ async def _weapon_ctx(m, icons: Icons, role_detail, locale) -> dict:
287
+ weapon_data = role_detail.weaponData
288
+ weapon = weapon_data.weapon
289
+ branch = role_detail.get_skill_branch()
290
+
291
+ icon = await m.get_square_weapon(weapon.weaponId)
292
+ icon = m.crop_center_img(icon, 110, 110)
293
+ icon_bg = m.get_weapon_icon_bg(weapon.weaponStarLevel, m.TEXT_PATH)
294
+ icon_bg.paste(icon, (10, 20), icon)
295
+
296
+ detail = m.get_weapon_detail(weapon.weaponId, weapon_data.level, weapon_data.breach, weapon_data.resonLevel)
297
+ stats = []
298
+ for item in detail.stats[:2]:
299
+ stats.append(
300
+ {
301
+ "icon": await _prop_icon(m, icons, item["name"]),
302
+ "name": m.t(item["name"], locale, partial=True),
303
+ "value": str(item["value"]),
304
+ }
305
+ )
306
+
307
+ branch_icon = ""
308
+ if branch:
309
+ skill_img = await m.get_attribute_skill(branch.branchName, locale)
310
+ branch_icon = icons.image(f"branch:{branch.branchName}", skill_img, 100, 100)
311
+
312
+ return {
313
+ "bg": _texture(m, "weapon_branch_bg.png" if branch else "weapon_bg.png", icons),
314
+ "icon": icons.image(f"weapon:{weapon.weaponId}", icon_bg, quality=90),
315
+ "name": m.t(weapon.weaponName, locale),
316
+ "level": f"Lv.{weapon_data.level}/90",
317
+ "reson": m._weapon_reson_text(weapon_data.resonLevel, locale),
318
+ "reson_color": _color(m.WEAPON_RESONLEVEL_COLOR[weapon_data.resonLevel] + (204,)),
319
+ "breach": m.get_breach(weapon_data.breach, weapon_data.level) or 0,
320
+ "promote": _texture(m, "promote_icon.png", icons),
321
+ "stats": stats,
322
+ "branch_icon": branch_icon,
323
+ "has_branch": bool(branch),
324
+ "cmd": f"{m.PREFIX}{m.t(weapon.weaponName, locale)}图鉴",
325
+ }
326
+
327
+
328
+ async def _skills_ctx(m, icons: Icons, role_detail, locale) -> dict:
329
+ items = []
330
+ for skill in role_detail.get_skill_list():
331
+ if skill.skill.type in ["延奏技能", "谐度破坏"]:
332
+ continue
333
+ image = await m.get_skill_img(role_detail.role.roleId, skill.skill.name, skill.skill.iconUrl)
334
+ items.append(
335
+ {
336
+ "icon": icons.image(f"skill:{role_detail.role.roleId}:{skill.skill.name}", image, 70, 70),
337
+ "type": m.t(skill.skill.type, locale),
338
+ "level": f"Lv.{skill.level}",
339
+ }
340
+ )
341
+ char_name = m.t(role_detail.role.roleName, locale)
342
+ return {
343
+ "bar": _texture(m, "skill_bar.png", icons),
344
+ "bg": _texture(m, "skill_bg.png", icons),
345
+ "entries": items,
346
+ "cmd": f"{m.PREFIX}{char_name}技能",
347
+ }
348
+
349
+
350
+ async def _chains_ctx(m, icons: Icons, role_detail, locale) -> dict:
351
+ """共鸣链: 底框与链图按属性色染色/压暗后整体成图, 名字仍是文字。"""
352
+ from PIL import Image, ImageEnhance
353
+
354
+ shuxing_color = m.WAVES_SHUXING_MAP[role_detail.role.attributeName]
355
+ items = []
356
+ for chain in role_detail.chainList:
357
+ frame = Image.open(m.TEXT_PATH / "mz_bg.png")
358
+ image = await m.get_chain_img(role_detail.role.roleId, chain.order, chain.iconUrl)
359
+ image = image.resize((100, 100))
360
+ frame.paste(image, (95, 75), image)
361
+ temp = Image.new("RGBA", frame.size)
362
+ temp.alpha_composite(frame, dest=(0, 0))
363
+ if chain.unlocked:
364
+ temp = await m.change_color(temp, shuxing_color)
365
+ else:
366
+ temp = ImageEnhance.Brightness(temp).enhance(0.3)
367
+ name = m.re.sub(r'[",,]+', "", chain.name) if chain.name else ""
368
+ name = m.t(name, locale, partial=True)
369
+ items.append(
370
+ {
371
+ "image": icons.image(
372
+ f"chain:{role_detail.role.roleId}:{chain.order}:{int(chain.unlocked)}", temp, quality=88
373
+ ),
374
+ "name": name,
375
+ "small": len(name) >= 8,
376
+ }
377
+ )
378
+ char_name = m.t(role_detail.role.roleName, locale)
379
+ return {"items": items, "cmd": f"{m.PREFIX}{char_name}共鸣链"}
380
+
381
+
382
+ # ── 声骸区 ──────────────────────────────────────────────────────────────
383
+
384
+
385
+ async def _stat_grid(m, icons: Icons, calc, card, role_detail, locale) -> List[List[dict]]:
386
+ """声骸词条统计: 三列四行。"""
387
+ shuxing = f"{role_detail.role.attributeName}伤害加成"
388
+ groups = []
389
+ for group in m.ph_sort_name:
390
+ rows = []
391
+ for index, (name, default) in enumerate(group):
392
+ key = shuxing if name == "属性伤害加成" else name
393
+ value = card.get(key, default)
394
+ color, _ = m.get_valid_color(key, value, calc.calc_temp)
395
+ rows.append(
396
+ {
397
+ "icon": await _prop_icon(m, icons, key),
398
+ "name": m.t(key, locale),
399
+ "value": str(value),
400
+ "color": _color(color),
401
+ "odd": index % 2 == 1,
402
+ }
403
+ )
404
+ groups.append(rows)
405
+ return groups
406
+
407
+
408
+ def _grade_ctx(m, icons: Icons, grade: str, top: str, value: str, bottom: str) -> dict:
409
+ return {
410
+ "bg": _texture(m, f"sh_score_bg_{grade}.png", icons, quality=84),
411
+ "fg": _texture(m, f"sh_score_{grade}.png", icons, quality=88),
412
+ "top": top,
413
+ "value": value,
414
+ "bottom": bottom,
415
+ }
416
+
417
+
418
+ def _grade_empty(m, icons: Icons, locale) -> dict:
419
+ return {
420
+ "empty": True,
421
+ "bg": _texture(m, "abs.png", icons, quality=84),
422
+ "top": m.t("暂无", locale),
423
+ "value": f"- {m.t('分', locale)}",
424
+ }
425
+
426
+
427
+ async def _echo_card(m, icons: Icons, phantom, calc, role_detail, locale, with_entry: bool) -> Tuple[dict, float]:
428
+ """单件声骸卡: with_entry 时每条词条右侧再挂单条得分(权重图)。"""
429
+ props = phantom.get_props()
430
+ score, grade = m.calc_phantom_score(role_detail.role.roleId, props, phantom.cost, calc.calc_temp)
431
+ if score > 49.95:
432
+ score = 50.0
433
+
434
+ icon = await m.get_phantom_img(phantom.phantomProp.phantomId, phantom.phantomProp.iconUrl)
435
+ fetter = await m.get_attribute_effect(phantom.fetterDetail.name)
436
+ icon.alpha_composite(fetter.resize((50, 50)), dest=(205, 0))
437
+
438
+ name = m.t(phantom.phantomProp.name, locale).replace("·", " ").replace("(", " ").replace(")", "")
439
+ short_name = name if locale == "en" else m.get_short_name(phantom.phantomProp.phantomId, name)
440
+
441
+ rows = []
442
+ for index, prop in enumerate(props):
443
+ name_color = value_color = "white"
444
+ if index > 1:
445
+ name_color, value_color = m.get_valid_color(prop.attributeName, prop.attributeValue, calc.calc_temp)
446
+ row = {
447
+ "icon": await _prop_icon(m, icons, prop.attributeName),
448
+ "name": m.t(prop.attributeName, locale, partial=True)[: 12 if locale == "en" else 6],
449
+ "value": str(prop.attributeValue),
450
+ "name_color": _color(name_color),
451
+ "value_color": _color(value_color),
452
+ }
453
+ if with_entry:
454
+ _, final_score = m.calc_phantom_entry(
455
+ index, prop, phantom.cost, calc.calc_temp, role_detail.role.attributeName or ""
456
+ )
457
+ row["entry"] = f"{final_score}{m.t('分', locale)}"
458
+ row["entry_color"] = _color(m.WAVES_FREEZING if final_score > 0 else m.WAVES_MOONLIT)
459
+ rows.append(row)
460
+
461
+ card = {
462
+ "bg": _texture(m, "sh_bg.png", icons),
463
+ "title": _texture(m, f"sh_title_{grade}.png", icons, quality=86),
464
+ "icon": icons.image(f"phantom:{phantom.phantomProp.phantomId}:{phantom.fetterDetail.name}", icon, 100, 100),
465
+ "name": short_name,
466
+ "level": f"Lv.{phantom.level}",
467
+ "score": f"{score}{m.t('分', locale)}",
468
+ "cost": phantom.cost,
469
+ "promote": _texture(m, "promote_icon.png", icons),
470
+ "props": rows,
471
+ "wide_score": locale == "en",
472
+ # 单件声骸跳它自己的图鉴, 比跳仓库更有意义
473
+ "cmd": f"{m.PREFIX}{m.t(phantom.phantomProp.name, locale)}图鉴"
474
+ if getattr(phantom.phantomProp, "name", "")
475
+ else "",
476
+ }
477
+ if with_entry:
478
+ max_score, _ = m.get_max_score(phantom.cost, calc.calc_temp)
479
+ card["max_line"] = f"C{phantom.cost}MAX:{max_score}{m.t('分', locale)}"
480
+ return card, score
481
+
482
+
483
+ async def _optimal_card(m, icons: Icons, slot, phantom, calc, locale) -> dict:
484
+ """培养目标声骸卡: 图标沿用实际声骸, 词条换成最优解。"""
485
+ icon_cls = ""
486
+ name = m.t("推荐声骸", locale)
487
+ if phantom and phantom.phantomProp:
488
+ image = await m.get_phantom_img(phantom.phantomProp.phantomId, phantom.phantomProp.iconUrl)
489
+ fetter = await m.get_attribute_effect(phantom.fetterDetail.name)
490
+ image.alpha_composite(fetter.resize((50, 50)), dest=(205, 0))
491
+ icon_cls = icons.image(
492
+ f"phantom:{phantom.phantomProp.phantomId}:{phantom.fetterDetail.name}", image, 100, 100
493
+ )
494
+ raw = m.t(phantom.phantomProp.name, locale).replace("·", " ").replace("(", " ").replace(")", "")
495
+ name = raw if locale == "en" else m.get_short_name(phantom.phantomProp.phantomId, raw)
496
+
497
+ display = [
498
+ (slot.main1_name, f"{slot.main1_value_pct:.1f}%"),
499
+ (slot.main2_name, m._fmt_opt_val(slot.main2_name, slot.main2_value_flat)),
500
+ ]
501
+ for sub_name, sub_value in list(slot.subs)[:5]:
502
+ display.append((sub_name, m._fmt_opt_val(sub_name, float(str(sub_value).replace("%", "")))))
503
+
504
+ rows = []
505
+ for index_prop, (prop_name, prop_value) in enumerate(display[:7]):
506
+ color = "white"
507
+ if index_prop > 1:
508
+ color, _ = m.get_valid_color(prop_name.rstrip("%"), prop_value, calc.calc_temp)
509
+ rows.append(
510
+ {
511
+ "icon": await _prop_icon(m, icons, prop_name),
512
+ "name": m.t(prop_name.rstrip("%"), locale, partial=True)[: 12 if locale == "en" else 6],
513
+ "value": prop_value,
514
+ "name_color": _color(color),
515
+ "value_color": _color(color),
516
+ }
517
+ )
518
+
519
+ return {
520
+ "bg": _texture(m, "sh_bg.png", icons),
521
+ "title": _texture(m, "sh_title_s.png", icons, quality=86),
522
+ "icon": icon_cls,
523
+ "name": name,
524
+ "badge": f"Lv.25 {m.t('模板声骸', locale)}",
525
+ "cost": slot.cost,
526
+ "promote": _texture(m, "promote_icon.png", icons),
527
+ "props": rows,
528
+ }
529
+
530
+
531
+ # ── 计算 ────────────────────────────────────────────────────────────────
532
+
533
+
534
+ # ── 自定义权重 (上游有对应 helper 时才生效, 旧版上游照常走内置) ──────────────
535
+
536
+
537
+ def _custom_id(calc_temp) -> str:
538
+ return calc_temp.get("_cw_id", "") if isinstance(calc_temp, dict) else ""
539
+
540
+
541
+ def _builtin_scope(m, calc, role_detail):
542
+ helper = getattr(m, "_builtin_calc_temp", None)
543
+ return helper(calc, role_detail) if helper else nullcontext()
544
+
545
+
546
+ def _rank_phantom_score(m, calc, role_detail, phantom_score: float) -> float:
547
+ helper = getattr(m, "_builtin_phantom_score", None)
548
+ return helper(calc, role_detail, phantom_score) if helper else phantom_score
549
+
550
+
551
+ def _template_box(m, calc_temp, locale, change_command: str = "") -> dict:
552
+ """评分模板框: 与上游 _template_tips 同规则, 向左加宽不压换装文字, 放不下再缩字号。"""
553
+ label = m.t("评分模板", locale)
554
+ namer = getattr(m, "_template_name", None)
555
+ name = namer(calc_temp, locale) if namer else m.t(calc_temp["name"], locale, partial=True)
556
+ measure = getattr(m, "text_width_with_emoji_fallback", None)
557
+
558
+ def width(text, font):
559
+ return measure(text, font) if measure else font.getlength(text)
560
+
561
+ right = getattr(m, "_TEMPLATE_RIGHT", 1180)
562
+ reserve = 74 + width(change_command, m.waves_font_18) if change_command else 0
563
+ max_w = max(400, min(getattr(m, "_TEMPLATE_MAX_W", 1140), right - reserve))
564
+ label_w, text_w = width(label, m.waves_font_24), width(name, m.waves_font_24)
565
+ need = int(20 + label_w + 30 + text_w + 50)
566
+ box_w = int(min(max(400, need), max_w))
567
+ size = 24
568
+ if need > max_w and text_w > 0:
569
+ size = max(14, int(24 * (max_w - 100 - label_w) / text_w))
570
+ return {"label": label, "name": name, "left": int(right - box_w), "width": box_w, "size": size}
571
+
572
+
573
+ def _prepare_calc(m, role_detail, enemy_detail=None, is_limit=False):
574
+ """与 ph_card_draw 同一套预处理, 只取数不出图。"""
575
+ calc_module = _mod(m, "utils.calc")
576
+ modal = _mod(m, "utils.damage.modal")
577
+ _sibling(m, "role_info_change").ensure_default_modal(role_detail)
578
+
579
+ calc = calc_module.WuWaCalc(role_detail, enemy_detail, is_limit=is_limit)
580
+ if role_detail.phantomData and role_detail.phantomData.equipPhantomList:
581
+ calc.phantom_pre = calc.prepare_phantom()
582
+ calc.phantom_card = calc.enhance_summation_phantom_value(calc.phantom_pre)
583
+ calc.calc_temp = m.get_calc_map(
584
+ calc.phantom_card,
585
+ role_detail.role.roleName,
586
+ role_detail.role.roleId,
587
+ modal.get_role_modal(role_detail),
588
+ )
589
+ return calc
590
+
591
+
592
+ def _damage_rows(m, calc, role_detail, damage_list, locale) -> List[dict]:
593
+ rows = []
594
+ for damage in damage_list:
595
+ title = damage["title"]
596
+ attribute = copy.deepcopy(calc.damageAttribute)
597
+ setattr(attribute, "_log_title", title)
598
+ crit, expected = damage["func"](attribute, role_detail)
599
+ # 数值为 0 的条目(如命座未解锁的技能)不出行, 与画布版一致
600
+ if (not crit or str(crit) == "0") and str(expected) == "0":
601
+ continue
602
+ rows.append(
603
+ {
604
+ "name": m.t(title, locale, partial=True),
605
+ "crit": str(crit) if crit and expected else "",
606
+ "expected": str(expected),
607
+ "single": not (crit and expected),
608
+ }
609
+ )
610
+ return rows
611
+
612
+
613
+ def _rank_rows(m, one_rank, locale) -> List[dict]:
614
+ rows = []
615
+ labels = [("评分排名", "估计评分排名"), ("伤害排名", "估计伤害排名")]
616
+ for index, item in enumerate(one_rank.data[:2]):
617
+ exact, estimated = labels[index]
618
+ rows.append(
619
+ {
620
+ "name": m.t(exact if item.rank > 0 else estimated, locale),
621
+ "value": f"{item.rank}" if item.rank > 0 else str(item.inter_rank),
622
+ "single": True,
623
+ "gold": True,
624
+ }
625
+ )
626
+ return rows
627
+
628
+
629
+ # ── 面板 / 查询 / 伤害 ──────────────────────────────────────────────────
630
+
631
+
632
+ async def _render_detail(
633
+ m, ev, uid, char, user_id, waves_id, is_force_avatar, change_list_regex, is_limit_query, show_score
634
+ ):
635
+ locale = await m.WavesLangSettings.get_lang(ev.user_id)
636
+ user_pref = await m.get_hide_uid_pref(waves_id or uid, user_id, ev.bot_id)
637
+ char, damage_id = m.parse_text_and_number(char)
638
+
639
+ char_name = m.alias_to_char_name(char)
640
+ char_id = m.char_name_to_char_id(char)
641
+ if char_name == "漂泊者" and not waves_id:
642
+ rover_canon = await m._rawdata_rover_canon(uid)
643
+ if rover_canon:
644
+ char_id = rover_canon
645
+ if not char_id or len(char_id) != 4 or not char_id.isdigit():
646
+ return "未找到指定角色, 请检查输入是否正确!"
647
+
648
+ damage_detail = m.DamageDetailRegister.find_class(char_id)
649
+ if damage_detail and not m.WutheringWavesConfig.get_config("WavesToken").data:
650
+ damage_detail = None
651
+
652
+ is_draw = False if damage_id and damage_detail else True
653
+ # 单条查询的编号跟随列表显示顺序(0 值条目不占编号), 得等面板数据齐了才能算, 推迟到下面选
654
+ if is_draw and damage_id and not damage_detail:
655
+ return f"[鸣潮] 角色【{char_name}】暂不支持伤害计算!\n"
656
+
657
+ ck = ""
658
+ if waves_id:
659
+ uid = waves_id
660
+
661
+ if not is_limit_query:
662
+ info, ck, _ = await m.base_info_cache.load_account_context(
663
+ uid, user_id, ev.bot_id, force_ck=bool(waves_id)
664
+ )
665
+ if isinstance(info, str):
666
+ if not ck:
667
+ return info
668
+ account_info = None
669
+ else:
670
+ account_info = info
671
+ force_resource_id = None
672
+ else:
673
+ account_info = m.AccountBaseInfo.model_validate(
674
+ {"name": "库洛交个朋友", "id": uid, "level": 100, "worldLevel": 10, "creatTime": 1739375719}
675
+ )
676
+ force_resource_id = char_id
677
+
678
+ avatar, role_detail = await m.get_role_need(
679
+ ev, char_id, ck, uid, char_name, waves_id, is_force_avatar, force_resource_id, is_limit_query,
680
+ change_list_regex,
681
+ )
682
+ if isinstance(role_detail, str):
683
+ return role_detail
684
+
685
+ change_command = ""
686
+ enemy_detail = m.EnemyDetailData()
687
+ if change_list_regex:
688
+ backup = copy.deepcopy(role_detail)
689
+ try:
690
+ role_detail, change_command = await m.change_role_detail(
691
+ uid, ck, role_detail, enemy_detail, change_list_regex, user_id=str(user_id), bot_id=ev.bot_id
692
+ )
693
+ if change_command and change_command.startswith("[鸣潮]"):
694
+ return change_command
695
+ except Exception as e:
696
+ logger.warning(f"[面板] 角色数据转换失败: {e}")
697
+ role_detail = backup
698
+
699
+ phantom_dirty = _sibling(m, "role_info_change").is_phantom_dirty(change_command)
700
+ calc = _prepare_calc(m, role_detail, enemy_detail, is_limit_query or phantom_dirty)
701
+ calc.role_card = calc.enhance_summation_card_value(calc.phantom_card)
702
+
703
+ has_phantom = bool(role_detail.phantomData and role_detail.phantomData.equipPhantomList)
704
+ icons = Icons()
705
+
706
+ # 单条伤害视图: 只出该类型的暴击/期望与 buff 列表
707
+ single_damage = None
708
+ if not is_draw and damage_detail and has_phantom:
709
+ calc.damageAttribute = calc.card_sort_map_to_attribute(calc.role_card)
710
+ shown = 0
711
+ damage_calc = None
712
+ attribute = None
713
+ for item in damage_detail:
714
+ probe = copy.deepcopy(calc.damageAttribute)
715
+ setattr(probe, "_log_title", item["title"])
716
+ crit, expected = item["func"](probe, role_detail)
717
+ if (not crit or str(crit) == "0") and str(expected) == "0":
718
+ continue
719
+ shown += 1
720
+ if shown == int(damage_id):
721
+ damage_calc, attribute = item, probe
722
+ break
723
+ if damage_calc is None or attribute is None:
724
+ return f"[鸣潮] 角色【{char_name}】未找到该伤害类型[{damage_id}], 请先检查输入是否正确!\n"
725
+ single_damage = {
726
+ "row": {
727
+ "name": m.t(damage_calc["title"], locale, partial=True),
728
+ "crit": str(crit) if crit and expected else "",
729
+ "expected": str(expected),
730
+ "single": not (crit and expected),
731
+ },
732
+ "buffs": [
733
+ {"name": m.t(effect.element_msg, locale, partial=True), "value": str(effect.element_value)}
734
+ for effect in attribute.effect
735
+ ],
736
+ }
737
+
738
+ score_report = None
739
+ score_detail = m.ScoreDetailRegister.find_class(char_id)
740
+ if score_detail and show_score and not is_limit_query and has_phantom:
741
+ try:
742
+ score_calc = score_detail[0] if isinstance(score_detail, list) else score_detail
743
+ setattr(calc, "_score_title", score_calc.get("title", f"综合评分-{char_name}"))
744
+ with _builtin_scope(m, calc, role_detail):
745
+ score_report = score_calc["func"](calc, role_detail)
746
+ except Exception as e:
747
+ logger.warning(f"[面板] {char_name} 综合评分失败: {e}")
748
+
749
+ # 声骸卡与总分
750
+ cards = []
751
+ phantom_score = 0.0
752
+ if has_phantom:
753
+ for phantom in role_detail.phantomData.equipPhantomList:
754
+ if not (phantom and phantom.phantomProp):
755
+ continue
756
+ card, score = await _echo_card(m, icons, phantom, calc, role_detail, locale, with_entry=False)
757
+ cards.append(card)
758
+ phantom_score += score
759
+ phantom_score = round(phantom_score, 2)
760
+ if phantom_score > 249.9:
761
+ phantom_score = 250.0
762
+
763
+ # 伤害排行与评分排行
764
+ rank_rows: List[dict] = []
765
+ if not is_limit_query:
766
+ rank_expected = None
767
+ rank_detail = m.DamageRankRegister.find_class(char_id)
768
+ if rank_detail and has_phantom:
769
+ try:
770
+ calc.damageAttribute = calc.card_sort_map_to_attribute(calc.role_card)
771
+ _, expected_str = rank_detail["func"](calc.damageAttribute, role_detail)
772
+ rank_expected = m.comma_separated_number(expected_str)
773
+ except Exception as e:
774
+ logger.warning(f"[面板] 排行伤害计算失败: {e}")
775
+ rank_score = _rank_phantom_score(m, calc, role_detail, phantom_score)
776
+ one_rank = await m.get_one_rank(
777
+ m.OneRankRequest(
778
+ char_id=int(char_id),
779
+ waves_id=uid,
780
+ phantom_score=rank_score if rank_score > 0 else None,
781
+ expected_damage=rank_expected,
782
+ )
783
+ )
784
+ if one_rank and len(one_rank.data) > 0:
785
+ rank_rows = _rank_rows(m, one_rank, locale)
786
+
787
+ if has_phantom and phantom_score > 0:
788
+ grade = m.get_total_score_bg(role_detail.role.roleName, phantom_score, calc.calc_temp)
789
+ grade_ctx = _grade_ctx(
790
+ m, icons, grade, m.t("声骸评级", locale),
791
+ f"{phantom_score:.2f}{m.t('分', locale)}", m.t("声骸评分", locale),
792
+ )
793
+ elif has_phantom:
794
+ grade_ctx = _grade_empty(m, icons, locale)
795
+ else:
796
+ grade_ctx = None
797
+
798
+ damage_rows = []
799
+ if is_draw and damage_detail and has_phantom:
800
+ calc.damageAttribute = calc.card_sort_map_to_attribute(calc.role_card)
801
+ damage_rows = _damage_rows(m, calc, role_detail, damage_detail, locale)
802
+
803
+ if not has_phantom:
804
+ echo_height = 120
805
+ elif is_draw:
806
+ echo_height = 1540
807
+ else:
808
+ echo_height = 370
809
+
810
+ ctx = await _base_ctx(m, icons, account_info, avatar, role_detail, calc, locale, uid, user_pref, char_name)
811
+ ctx["score_band"] = _score_band(m, icons, score_report, locale, f"{m.PREFIX}{char_name}优化")
812
+ ctx["echo"] = {
813
+ "banner": _texture(m, "banner3.png", icons),
814
+ "bar_even": _texture(m, "ph_0.png", icons),
815
+ "bar_odd": _texture(m, "ph_1.png", icons),
816
+ "stat_groups": await _stat_grid(m, icons, calc, calc.phantom_card, role_detail, locale) if has_phantom else [],
817
+ "template": _template_box(m, calc.calc_temp, locale, change_command) if has_phantom else None,
818
+ "change_command": change_command,
819
+ "grade": grade_ctx if is_draw else None,
820
+ "cards": cards if is_draw else [],
821
+ "card_top": 370,
822
+ "card_gap": 600,
823
+ "card_height": 550,
824
+ "height": echo_height,
825
+ "card_cmd": f"{m.PREFIX}声骸",
826
+ }
827
+ # 排名行随伤害总表一起出; 单伤害视图与无伤害数据时都不带总表
828
+ if damage_rows:
829
+ for index, row in enumerate(damage_rows):
830
+ row["cmd"] = f"{m.PREFIX}{char_name}伤害{index + 1}"
831
+ ctx["damage"] = {
832
+ "bar_even": _texture(m, "damage_bar2.png", icons),
833
+ "bar_odd": _texture(m, "damage_bar1.png", icons),
834
+ "head": {
835
+ "name": m.t("伤害类型", locale),
836
+ "crit": m.t("暴击伤害", locale),
837
+ "expected": m.t("期望伤害", locale),
838
+ },
839
+ "rows": damage_rows + rank_rows,
840
+ }
841
+ if single_damage:
842
+ ctx["single_damage"] = {
843
+ "bar_even": _texture(m, "damage_bar2.png", icons),
844
+ "bar_odd": _texture(m, "damage_bar1.png", icons),
845
+ "head": {
846
+ "name": m.t("伤害类型", locale),
847
+ "crit": m.t("暴击伤害", locale),
848
+ "expected": m.t("期望伤害", locale),
849
+ },
850
+ "buff_title": m.t("buff列表", locale),
851
+ "row": single_damage["row"],
852
+ "buffs": single_damage["buffs"],
853
+ }
854
+
855
+ html = await _render(ctx, char_name)
856
+
857
+ # 仅本人查询才写状态与建议, 与上游一致
858
+ is_self = not waves_id and user_id == ev.user_id
859
+ if not is_limit_query and not change_list_regex and is_self:
860
+ try:
861
+ char_state = await m.record_view(uid, char_id)
862
+ if char_state is not None and score_report is not None and score_report.partial_max:
863
+ grade = m.get_panel_score_grade(score_report.score)
864
+ if grade in ("b", "c") and score_report.score > 40 and char_state.get("advice_dirty", True):
865
+ advice = f"[鸣潮] {char_name} 建议提升词条方向: {score_report.partial_max[0]}"
866
+ if await m.record_advice_sent(uid, char_id, advice):
867
+ m.queue_pending_advice(ev, advice)
868
+ except Exception as e:
869
+ logger.warning(f"[面板] 角色状态记录失败: {e}")
870
+ return html
871
+
872
+
873
+ # ── 权重 ────────────────────────────────────────────────────────────────
874
+
875
+
876
+ async def _render_weight(m, ev, uid, char, user_id, waves_id, is_limit_query):
877
+ locale = await m.WavesLangSettings.get_lang(ev.user_id)
878
+ user_pref = await m.get_hide_uid_pref(waves_id or uid, user_id, ev.bot_id)
879
+ char, _damage_id = m.parse_text_and_number(char)
880
+
881
+ char_id = m.char_name_to_char_id(char)
882
+ if not char_id or len(char_id) != 4 or not char_id.isdigit():
883
+ return "未找到指定角色, 请检查输入是否正确!"
884
+ char_name = m.alias_to_char_name(char)
885
+
886
+ ck = ""
887
+ if waves_id:
888
+ uid = waves_id
889
+
890
+ if not is_limit_query:
891
+ info, ck, _ = await m.base_info_cache.load_account_context(
892
+ uid, user_id, ev.bot_id, force_ck=bool(waves_id)
893
+ )
894
+ if isinstance(info, str):
895
+ if not ck:
896
+ return info
897
+ account_info = None
898
+ else:
899
+ account_info = info
900
+ force_resource_id = None
901
+ else:
902
+ account_info = m.AccountBaseInfo.model_validate(
903
+ {"name": "库洛交个朋友", "id": uid, "level": 100, "worldLevel": 10, "creatTime": 1739375719}
904
+ )
905
+ force_resource_id = char_id
906
+
907
+ avatar, role_detail = await m.get_role_need(
908
+ ev, char_id, ck, uid, char_name, waves_id,
909
+ is_force_avatar=False, force_resource_id=force_resource_id, is_limit_query=is_limit_query,
910
+ )
911
+ if isinstance(role_detail, str):
912
+ return role_detail
913
+
914
+ modal = _mod(m, "utils.damage.modal")
915
+ weight_modals = modal.get_modal_options(int(role_detail.role.roleId))
916
+
917
+ calc = _prepare_calc(m, role_detail, None, is_limit_query)
918
+ icons = Icons()
919
+ has_phantom = bool(role_detail.phantomData and role_detail.phantomData.equipPhantomList)
920
+ if is_limit_query and has_phantom:
921
+ calc.role_card = calc.enhance_summation_card_value(calc.phantom_card)
922
+
923
+ cards = []
924
+ phantom_score = 0.0
925
+ tables = []
926
+ stat_groups = []
927
+ if has_phantom:
928
+ for phantom in role_detail.phantomData.equipPhantomList:
929
+ if not (phantom and phantom.phantomProp):
930
+ continue
931
+ card, score = await _echo_card(m, icons, phantom, calc, role_detail, locale, with_entry=True)
932
+ cards.append(card)
933
+ phantom_score += score
934
+ phantom_score = round(phantom_score, 2)
935
+ if phantom_score > 249.9:
936
+ phantom_score = 250.0
937
+
938
+ panel_card = calc.role_card if is_limit_query and getattr(calc, "role_card", None) else calc.phantom_card
939
+ stat_groups = await _stat_grid(m, icons, calc, panel_card, role_detail, locale)
940
+
941
+ shuxing = f"{role_detail.role.attributeName}伤害加成"
942
+ custom_id = _custom_id(calc.calc_temp)
943
+ if custom_id:
944
+ weight_temps = [(calc.calc_temp, f"自定义 {custom_id}")]
945
+ elif weight_modals:
946
+ weight_temps = [
947
+ (
948
+ m.get_calc_map(
949
+ calc.phantom_card, role_detail.role.roleName, role_detail.role.roleId, option["key"]
950
+ ),
951
+ option["name"],
952
+ )
953
+ for option in weight_modals
954
+ ]
955
+ else:
956
+ weight_temps = [(calc.calc_temp, "")]
957
+ for calc_temp, modal_name in weight_temps:
958
+ tables.append(_weight_table(m, calc_temp, shuxing, role_detail.role.roleName, modal_name))
959
+
960
+ if has_phantom and phantom_score > 0:
961
+ grade = m.get_total_score_bg(role_detail.role.roleName, phantom_score, calc.calc_temp)
962
+ grade_ctx = _grade_ctx(
963
+ m, icons, grade, m.t("声骸评级", locale),
964
+ f"{phantom_score:.2f}{m.t('分', locale)}", m.t("声骸评分", locale),
965
+ )
966
+ elif has_phantom:
967
+ grade_ctx = _grade_empty(m, icons, locale)
968
+ else:
969
+ grade_ctx = None
970
+
971
+ ctx = await _base_ctx(
972
+ m, icons, account_info, avatar, role_detail, calc, locale, uid, user_pref, char_name, light=True
973
+ )
974
+ ctx["stat_column"] = {
975
+ "bar_even": _texture(m, "ph_0.png", icons),
976
+ "bar_odd": _texture(m, "ph_1.png", icons),
977
+ "groups": stat_groups,
978
+ }
979
+ ctx["echo"] = {
980
+ "banner": _texture(m, "banner3.png", icons),
981
+ "bar_even": _texture(m, "ph_0.png", icons),
982
+ "bar_odd": _texture(m, "ph_1.png", icons),
983
+ "stat_groups": [],
984
+ "template": _template_box(m, calc.calc_temp, locale) if has_phantom else None,
985
+ "change_command": "",
986
+ "grade": grade_ctx,
987
+ "cards": cards,
988
+ "card_top": 120,
989
+ "card_gap": 630,
990
+ "card_height": 620,
991
+ "height": 1390 if cards else 120,
992
+ "card_cmd": f"{m.PREFIX}声骸",
993
+ }
994
+ ctx["weight_tables"] = tables
995
+ return await _render(ctx, char_name)
996
+
997
+
998
+ def _weight_table(m, calc_temp, shuxing, role_name, modal_name) -> dict:
999
+ rows = m._build_weight_rows(calc_temp, shuxing)
1000
+ table = []
1001
+ for index, row in enumerate(rows):
1002
+ cells = []
1003
+ for column, cell in enumerate(row.split(",")):
1004
+ color = "#ffffff"
1005
+ if index > 0 and column == 0:
1006
+ name_color, _ = m.get_valid_color(cell, "", calc_temp)
1007
+ color = _color(name_color)
1008
+ cells.append({"text": cell, "color": color})
1009
+ table.append({"cells": cells, "head": index == 0, "odd": index % 2 == 1})
1010
+
1011
+ grade = calc_temp["total_grade"]
1012
+ title = f"#{role_name}词条权重表"
1013
+ if modal_name:
1014
+ title = f"#{role_name}({modal_name})词条权重表"
1015
+ return {
1016
+ "title": title,
1017
+ "rows": table,
1018
+ "notes": [
1019
+ "词条得分:词条数值 * 当前词条权重 / 声骸未对齐最高分 * 对齐分数(50)",
1020
+ f"声骸评分标准:SSS≥{grade[-1] * 250:.2f}分/ SS≥{grade[-2] * 250:.2f}分/S≥{grade[-3] * 250:.2f}分 "
1021
+ f"/ A≥{grade[-4] * 250:.2f}分 / B≥{grade[-5] * 250:.2f}分 / C",
1022
+ "当前角色评分标准仅供参考与娱乐,不代表任何官方或权威的评价。",
1023
+ ],
1024
+ }
1025
+
1026
+
1027
+ # ── 优化 ────────────────────────────────────────────────────────────────
1028
+
1029
+
1030
+ async def _render_optimize(m, ev, uid, char, user_id, waves_id, change_list_regex, is_limit_query):
1031
+ locale = await m.WavesLangSettings.get_lang(ev.user_id)
1032
+ user_pref = await m.get_hide_uid_pref(waves_id or uid, user_id, ev.bot_id)
1033
+
1034
+ char_id = m.char_name_to_char_id(char)
1035
+ if not char_id or len(char_id) != 4 or not char_id.isdigit():
1036
+ return "未找到指定角色, 请检查输入是否正确!"
1037
+ char_name = m.alias_to_char_name(char)
1038
+
1039
+ score_detail = m.ScoreDetailRegister.find_class(char_id)
1040
+ if not score_detail:
1041
+ return f"[鸣潮] {char_name} 暂无综合评分"
1042
+
1043
+ if waves_id:
1044
+ uid = waves_id
1045
+
1046
+ ck = ""
1047
+ force_resource_id = None
1048
+ if not is_limit_query:
1049
+ info, ck, _ = await m.base_info_cache.load_account_context(
1050
+ uid, user_id, ev.bot_id, force_ck=bool(waves_id)
1051
+ )
1052
+ if isinstance(info, str):
1053
+ if not ck:
1054
+ return info
1055
+ account_info = None
1056
+ else:
1057
+ account_info = info
1058
+ else:
1059
+ account_info = m.AccountBaseInfo.model_validate(
1060
+ {"name": "库洛交个朋友", "id": uid, "level": 100, "worldLevel": 10, "creatTime": 1739375719}
1061
+ )
1062
+ force_resource_id = char_id
1063
+
1064
+ avatar, role_detail = await m.get_role_need(
1065
+ ev, char_id, ck, uid, char_name, waves_id,
1066
+ force_resource_id=force_resource_id, is_limit_query=is_limit_query,
1067
+ change_list_regex=change_list_regex,
1068
+ )
1069
+ if isinstance(role_detail, str):
1070
+ return role_detail
1071
+
1072
+ enemy_detail = m.EnemyDetailData()
1073
+ change_command = ""
1074
+ if change_list_regex:
1075
+ backup = copy.deepcopy(role_detail)
1076
+ try:
1077
+ role_detail, change_command = await m.change_role_detail(
1078
+ uid, ck, role_detail, enemy_detail, change_list_regex, user_id=str(user_id), bot_id=ev.bot_id
1079
+ )
1080
+ if change_command and change_command.startswith("[鸣潮]"):
1081
+ return change_command
1082
+ except Exception as e:
1083
+ logger.warning(f"[面板] 角色数据转换失败: {e}")
1084
+ role_detail = backup
1085
+
1086
+ phantom_data = role_detail.phantomData
1087
+ equip_list = phantom_data.equipPhantomList if phantom_data else None
1088
+ if not equip_list or sum(1 for p in equip_list if p and getattr(p, "phantomProp", None)) < 5:
1089
+ return f"[鸣潮] {char_name} 声骸件数不足, 暂无优化建议"
1090
+
1091
+ phantom_dirty = _sibling(m, "role_info_change").is_phantom_dirty(change_command)
1092
+ calc = _prepare_calc(m, role_detail, enemy_detail, phantom_dirty)
1093
+ calc.role_card = calc.enhance_summation_card_value(calc.phantom_card)
1094
+
1095
+ score_report = None
1096
+ try:
1097
+ score_calc = score_detail[0] if isinstance(score_detail, list) else score_detail
1098
+ setattr(calc, "_score_title", score_calc.get("title", f"综合评分-{char_name}"))
1099
+ score_report = score_calc["func"](calc, role_detail)
1100
+ except Exception as e:
1101
+ logger.warning(f"[面板] {char_name} 优化计算失败: {e}")
1102
+ if score_report is None:
1103
+ return f"[鸣潮] {char_name} 优化计算失败,请检查服务器连接状态"
1104
+
1105
+ icons = Icons()
1106
+ partials = sorted(
1107
+ [(name, float(value)) for name, value in (getattr(score_report, "partials", None) or {}).items()
1108
+ if abs(float(value)) > 1e-9],
1109
+ key=lambda item: item[1],
1110
+ reverse=True,
1111
+ )
1112
+
1113
+ def localized(name: str) -> str:
1114
+ base = m.t(name.rstrip("%"), locale, partial=True)
1115
+ return f"{base}%" if name.endswith("%") else base
1116
+
1117
+ is_self = not waves_id and user_id == ev.user_id
1118
+ score_rows = []
1119
+ partial_max = getattr(score_report, "partial_max", None)
1120
+ if partial_max and is_self:
1121
+ score_rows.append((m.t("建议提升词条方向", locale), localized(partial_max[0])))
1122
+ if partials:
1123
+ for index in range(0, len(partials), 3):
1124
+ group = partials[index:index + 3]
1125
+ label = m.t("词条提升收益情况", locale) if index == 0 else ""
1126
+ text = " ".join(f"{localized(name)} {value:+.1f}" for name, value in group)
1127
+ score_rows.append((label, text))
1128
+ else:
1129
+ score_rows.append((m.t("词条提升收益情况", locale), m.t("暂无", locale)))
1130
+ score_rows.extend(
1131
+ (m.t("备注", locale), m.t(str(note), locale, partial=True))
1132
+ for note in (getattr(score_report, "notes", None) or [])
1133
+ )
1134
+
1135
+ rule_lines = []
1136
+ for line in m._SCORE_RULE_LINES:
1137
+ rule_lines.extend(m._wrap_plain(m.t(line, locale), 46))
1138
+
1139
+ cards = []
1140
+ for index, slot in enumerate((getattr(score_report, "best_loadout", None) or [])[:5]):
1141
+ phantom = equip_list[index] if index < len(equip_list) else None
1142
+ cards.append(await _optimal_card(m, icons, slot, phantom, calc, locale))
1143
+
1144
+ ctx = await _base_ctx(
1145
+ m, icons, account_info, avatar, role_detail, calc, locale, uid, user_pref, char_name,
1146
+ diff_props=await _diff_props(m, icons, calc, score_report, role_detail, locale),
1147
+ )
1148
+ ctx["score_band"] = _score_band(m, icons, score_report, locale, f"{m.PREFIX}{char_name}面板")
1149
+ ctx["echo"] = {
1150
+ "banner": _texture(m, "banner3.png", icons),
1151
+ "bar_even": _texture(m, "ph_0.png", icons),
1152
+ "bar_odd": _texture(m, "ph_1.png", icons),
1153
+ "stat_groups": [],
1154
+ "title_bar": _texture(m, "damage_bar1.png", icons),
1155
+ "title": m.t("声骸培养目标参考", locale),
1156
+ "template_label": "",
1157
+ "template_name": "",
1158
+ "change_command": change_command,
1159
+ "grade": _grade_ctx(
1160
+ m, icons, "sss", m.t("综合评级", locale), f"150.00{m.t('分', locale)}", m.t("综合评分", locale)
1161
+ ),
1162
+ "cards": cards,
1163
+ "card_top": 180,
1164
+ "card_gap": 600,
1165
+ "card_height": 550,
1166
+ "height": 1350,
1167
+ "card_cmd": f"{m.PREFIX}声骸",
1168
+ }
1169
+ ctx["optimize"] = {
1170
+ "bar_even": _texture(m, "damage_bar2.png", icons),
1171
+ "bar_odd": _texture(m, "damage_bar1.png", icons),
1172
+ "title": f"{m.t(char_name, locale)} {m.t('综合评分', locale)} {score_report.score:.1f} / 150",
1173
+ "rows": [{"label": left, "text": right} for left, right in score_rows],
1174
+ "rule_title": m.t(m._SCORE_RULE_TITLE, locale, partial=True),
1175
+ "rule_hint": f"{m.t('评分细则请发送', locale)} {m.PREFIX}综合评分说明",
1176
+ "rule_lines": rule_lines,
1177
+ }
1178
+ return await _render(ctx, char_name)
1179
+
1180
+
1181
+ async def _diff_props(m, icons: Icons, calc, score_report, role_detail, locale) -> List[dict]:
1182
+ """优化图右侧: 当前值 → 最优值, 按提升幅度排序取前八。"""
1183
+ shuxing = f"{role_detail.role.attributeName}伤害加成"
1184
+ best_card = getattr(score_report, "best_card", None) or {}
1185
+
1186
+ def percent(value):
1187
+ try:
1188
+ return float(str(value).replace("%", ""))
1189
+ except (ValueError, TypeError):
1190
+ return 0.0
1191
+
1192
+ candidates = ("治疗效果加成", "普攻伤害加成", "重击伤害加成", "共鸣技能伤害加成", "共鸣解放伤害加成")
1193
+ last_slot = max(candidates, key=lambda k: percent(calc.role_card.get(k, "0")))
1194
+ skip = {"谐度破坏增幅", "偏谐值累积效率"}
1195
+ items = [(name, default) for name, default in m.card_sort_name[:-1] if name not in skip]
1196
+ items.append((last_slot, "0.0%"))
1197
+
1198
+ rows = []
1199
+ for name, default in items:
1200
+ key = shuxing if name == "属性伤害加成" else name
1201
+ label = m.t(key, locale)
1202
+ value = calc.role_card.get(key, default)
1203
+ best = best_card.get(key, value)
1204
+ try:
1205
+ delta = float(str(best).replace("%", "")) - float(str(value).replace("%", ""))
1206
+ except (TypeError, ValueError):
1207
+ delta = 0.0
1208
+ is_percent = "%" in str(value) or "%" in str(best)
1209
+ if abs(delta) < 1e-6:
1210
+ delta_text = ""
1211
+ elif is_percent:
1212
+ delta_text = f"{delta:+.1f}%"
1213
+ else:
1214
+ delta_text = f"{int(round(delta)):+d}"
1215
+ rows.append(
1216
+ {
1217
+ "icon": await _prop_icon(m, icons, key),
1218
+ "name": label,
1219
+ "value": f"{value} → {best}",
1220
+ "delta": delta_text,
1221
+ "delta_color": _color(m.SPECIAL_GOLD if delta > 0 else m.GREY),
1222
+ "order": delta,
1223
+ }
1224
+ )
1225
+ rows.sort(key=lambda row: row["order"], reverse=True)
1226
+ return rows[:8]
1227
+
1228
+
1229
+ # ── 公共上下文与渲染 ────────────────────────────────────────────────────
1230
+
1231
+
1232
+ def _score_band(m, icons: Icons, score_report, locale, cmd: str = "") -> Optional[dict]:
1233
+ if score_report is None:
1234
+ return None
1235
+ grade = m.get_panel_score_grade(score_report.score)
1236
+ return {
1237
+ "icon": _texture(m, f"panel_score_{grade}.png", icons),
1238
+ "value": f"{score_report.score:.2f}",
1239
+ "label": m.t("综合评分", locale),
1240
+ "cmd": cmd,
1241
+ }
1242
+
1243
+
1244
+ async def _base_ctx(
1245
+ m, icons: Icons, account_info, avatar, role_detail, calc, locale, uid, user_pref, char_name,
1246
+ light: bool = False, diff_props: Optional[List[dict]] = None,
1247
+ ) -> dict:
1248
+ """头部/立绘/右侧/技能/共鸣链。light=True 时只出头部与立绘(权重图)。"""
1249
+ ctx = {
1250
+ "font_css_url": _font_css_url(),
1251
+ "bg": await _background(m),
1252
+ "mask_uri": _texture_uri(m, "char_mask.png"),
1253
+ "footer": _footer(m, icons),
1254
+ "header": _header_ctx(m, icons, account_info, avatar, uid, user_pref, locale),
1255
+ "pile": await _pile_ctx(m, icons, role_detail, locale, uid, char_name),
1256
+ "icons": icons,
1257
+ "score_band": None,
1258
+ "right": None,
1259
+ "skills": None,
1260
+ "chains": None,
1261
+ "echo": None,
1262
+ "damage": None,
1263
+ "single_damage": None,
1264
+ "stat_column": None,
1265
+ "weight_tables": None,
1266
+ "optimize": None,
1267
+ }
1268
+ if light:
1269
+ ctx["side_strip"] = _texture(m, "char.png", icons)
1270
+ return ctx
1271
+
1272
+ ctx["right"] = {
1273
+ "banner_top": _texture(m, "banner4.png", icons),
1274
+ "banner_weapon": _texture(m, "banner2.png", icons),
1275
+ "prop_bg": _texture(m, "prop_bg_single.png" if diff_props else "prop_bg.png", icons),
1276
+ "props": diff_props if diff_props is not None else await _panel_props(m, icons, calc, role_detail, locale),
1277
+ "diff": diff_props is not None,
1278
+ "weapon": await _weapon_ctx(m, icons, role_detail, locale),
1279
+ }
1280
+ ctx["skills"] = await _skills_ctx(m, icons, role_detail, locale)
1281
+ ctx["chains"] = await _chains_ctx(m, icons, role_detail, locale)
1282
+ return ctx
1283
+
1284
+
1285
+ async def _render(ctx: dict, char_name: str):
1286
+ from rover.html_actions import annotate_html
1287
+ from rover.media import HtmlBytes
1288
+
1289
+ icons: Icons = ctx.pop("icons")
1290
+ ctx["icon_css"] = icons.entries
1291
+ html = _template().render(**ctx)
1292
+ html = annotate_html(html, TEMPLATE_NAME, {})
1293
+ logger.info(f"[面板] {char_name} HTML {len(html) / 1024 / 1024:.2f}MB")
1294
+ return HtmlBytes(html, TEMPLATE_NAME)
1295
+
1296
+
1297
+ # ── 接管 ────────────────────────────────────────────────────────────────
1298
+
1299
+
1300
+ def _install(m, name: str, func) -> None:
1301
+ """模块内与已按值绑定的上层包一起替换。"""
1302
+ setattr(m, name, func)
1303
+ parent = sys.modules.get(m.__name__.rsplit(".", 1)[0])
1304
+ if parent is not None and hasattr(parent, name):
1305
+ setattr(parent, name, func)
1306
+
1307
+
1308
+ def _patch(m) -> None:
1309
+ original_detail = m.draw_char_detail_img
1310
+ original_score = m.draw_char_score_img
1311
+ original_optimize = m.draw_char_optimize_img
1312
+
1313
+ async def draw_char_detail_img(
1314
+ ev, uid, char, user_id, waves_id=None, need_convert_img=True, is_force_avatar=False,
1315
+ change_list_regex=None, is_limit_query=False, show_score=True, fallback_to_generic=False,
1316
+ role_detail_override=None,
1317
+ ):
1318
+ args = (ev, uid, char, user_id, waves_id, need_convert_img, is_force_avatar, change_list_regex,
1319
+ is_limit_query, show_score, fallback_to_generic, role_detail_override)
1320
+ # 拼图/预览等路径要的是 PIL 画布, 交回原实现
1321
+ if not need_convert_img or fallback_to_generic or role_detail_override is not None:
1322
+ return await original_detail(*args)
1323
+ try:
1324
+ return await _render_detail(
1325
+ m, ev, uid, char, user_id, waves_id, is_force_avatar, change_list_regex,
1326
+ is_limit_query, show_score,
1327
+ )
1328
+ except Exception as e:
1329
+ logger.exception(f"[面板] HTML 渲染失败, 回落原图: {e}")
1330
+ return await original_detail(*args)
1331
+
1332
+ async def draw_char_score_img(ev, uid, char, user_id, waves_id=None, is_limit_query=False):
1333
+ try:
1334
+ return await _render_weight(m, ev, uid, char, user_id, waves_id, is_limit_query)
1335
+ except Exception as e:
1336
+ logger.exception(f"[面板] 权重 HTML 渲染失败, 回落原图: {e}")
1337
+ return await original_score(ev, uid, char, user_id, waves_id, is_limit_query)
1338
+
1339
+ async def draw_char_optimize_img(
1340
+ ev, uid, char, user_id, waves_id=None, change_list_regex=None, is_limit_query=False
1341
+ ):
1342
+ try:
1343
+ return await _render_optimize(m, ev, uid, char, user_id, waves_id, change_list_regex, is_limit_query)
1344
+ except Exception as e:
1345
+ logger.exception(f"[面板] 优化 HTML 渲染失败, 回落原图: {e}")
1346
+ return await original_optimize(ev, uid, char, user_id, waves_id, change_list_regex, is_limit_query)
1347
+
1348
+ _install(m, "draw_char_detail_img", draw_char_detail_img)
1349
+ _install(m, "draw_char_score_img", draw_char_score_img)
1350
+ _install(m, "draw_char_optimize_img", draw_char_optimize_img)
1351
+
1352
+
1353
+ PATCHES = {"XutheringWavesUID": {"wutheringwaves_charinfo.draw_char_card": _patch}}