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

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,665 @@
1
+ #!/usr/bin/env python3
2
+ """只读数据访问 + 评分计算, 给脚本用。
3
+
4
+ 用法:
5
+ import sys; sys.path.insert(0, "<service>/tools")
6
+ import wwdata as w
7
+ w.uids() # 有数据的特征码
8
+ w.roles(uid) # 角色一览(等级/链/武器/声骸分)
9
+ w.echoes(uid, sonata="沉日劫明") # 按套装筛已装备声骸
10
+ w.score(uid, "洛可可", echo) # 把某个声骸放到某角色的评分模板下打分
11
+
12
+ 写入一律只允许落在 SCRATCH 里, 本模块不提供任何改用户数据的接口。
13
+ 命令行自检: python tools/wwdata.py
14
+ """
15
+
16
+ import gzip
17
+ import json
18
+ import os
19
+ import sqlite3
20
+ import sys
21
+ import tempfile
22
+ from pathlib import Path
23
+ from typing import Any, Dict, List, Optional
24
+
25
+ SERVICE = Path(__file__).resolve().parents[1]
26
+ DATA = SERVICE / "data"
27
+ WW = DATA / "XutheringWavesUID"
28
+ PLAYERS = WW / "players"
29
+ MAP = WW / "resource" / "map"
30
+ DB = DATA / "WavesData.db"
31
+ # 模拟数据、中间结果、脚本产物都写这里。放系统临时目录: 沙箱只放行临时目录和工作区,
32
+ # data 在两者之外写不进去; tempfile 跟随 TMPDIR/TEMP, Windows 下同样有效。
33
+ SCRATCH = Path(os.environ.get("WW_SCRATCH") or tempfile.gettempdir()) / "dsh-waves-scratch"
34
+
35
+ _booted = False
36
+ _cache: Dict[str, Any] = {}
37
+
38
+
39
+ # ── 基础读取 ────────────────────────────────────────────────────────────
40
+
41
+
42
+ def _json(path: Path) -> Any:
43
+ if path.suffix == ".gz":
44
+ with gzip.open(path, "rt", encoding="utf-8") as file:
45
+ return json.load(file)
46
+ return json.loads(path.read_text("utf-8"))
47
+
48
+
49
+ def scratch(*parts: str) -> Path:
50
+ """唯一可写目录, 返回其中的路径并建好父目录。"""
51
+ path = SCRATCH.joinpath(*parts) if parts else SCRATCH
52
+ (path.parent if parts else path).mkdir(parents=True, exist_ok=True)
53
+ return path
54
+
55
+
56
+ def uids() -> List[str]:
57
+ """本地有面板数据的特征码。"""
58
+ if not PLAYERS.is_dir():
59
+ return []
60
+ return sorted(p.name for p in PLAYERS.iterdir() if p.is_dir() and (p / "rawData.json.gz").is_file())
61
+
62
+
63
+ def bound_uids() -> List[str]:
64
+ """数据库里绑定过的特征码, 按绑定顺序; 第一个就是当前默认账号。"""
65
+ if not DB.is_file():
66
+ return []
67
+ conn = sqlite3.connect(f"file:{DB}?mode=ro&immutable=1", uri=True)
68
+ try:
69
+ out: List[str] = []
70
+ for (cell,) in conn.execute("SELECT uid FROM wavesbind WHERE uid IS NOT NULL"):
71
+ for one in str(cell).split("_"):
72
+ if one and one not in out:
73
+ out.append(one)
74
+ return out
75
+ except sqlite3.Error:
76
+ return []
77
+ finally:
78
+ conn.close()
79
+
80
+
81
+ def default_uid() -> str:
82
+ found = bound_uids() or uids()
83
+ if not found:
84
+ raise RuntimeError("本地没有任何特征码数据, 先跑一次 ww查询 / ww刷新面板")
85
+ return found[0]
86
+
87
+
88
+ def player_file(uid: str, name: str) -> Optional[Path]:
89
+ path = PLAYERS / str(uid) / name
90
+ return path if path.is_file() else None
91
+
92
+
93
+ def base_info(uid: str) -> dict:
94
+ path = player_file(uid, "baseInfo.json")
95
+ return _json(path) if path else {}
96
+
97
+
98
+ def raw_panel(uid: str) -> List[dict]:
99
+ """rawData.json.gz 原样返回: 每项是一个角色的完整面板。"""
100
+ path = player_file(uid, "rawData.json.gz")
101
+ return _json(path) if path else []
102
+
103
+
104
+ def matrix(uid: str) -> dict:
105
+ """终焉矩阵挑战结果。"""
106
+ path = player_file(uid, "matrixData.json.gz")
107
+ return _json(path) if path else {}
108
+
109
+
110
+ def slash(uid: str) -> dict:
111
+ """冥歌海墟挑战结果。"""
112
+ path = player_file(uid, "slashData.json.gz")
113
+ return _json(path) if path else {}
114
+
115
+
116
+ def abyss(uid: str) -> dict:
117
+ """深境区(深塔)挑战结果。"""
118
+ path = player_file(uid, "rover.json.gz")
119
+ return _json(path) if path else {}
120
+
121
+
122
+ def gacha(uid: str) -> dict:
123
+ path = player_file(uid, "gacha_logs.json.gz")
124
+ return {"logs": _json(path) if path else None, "stats": gacha_stats(uid)}
125
+
126
+
127
+ def gacha_stats(uid: str) -> dict:
128
+ path = player_file(uid, "gachaStats.json")
129
+ return _json(path) if path else {}
130
+
131
+
132
+ def char_scores(uid: str) -> Dict[str, float]:
133
+ """角色 id → 声骸总分, 练度统计用的就是它。"""
134
+ path = player_file(uid, "charListData.json")
135
+ return _json(path) if path else {}
136
+
137
+
138
+ # ── 名字与别名 ──────────────────────────────────────────────────────────
139
+
140
+
141
+ def id2name() -> Dict[str, str]:
142
+ if "id2name" not in _cache:
143
+ data = _json(MAP / "CharId2Data.json")
144
+ _cache["id2name"] = {k: v.get("name", "") for k, v in data.items()}
145
+ return _cache["id2name"]
146
+
147
+
148
+ def name(char_id: Any) -> str:
149
+ return id2name().get(str(char_id), str(char_id))
150
+
151
+
152
+ def _alias_map(file_name: str) -> Dict[str, str]:
153
+ """别名 → 正式名。直接读别名表, 不依赖插件那套 import。"""
154
+ key = f"alias:{file_name}"
155
+ if key in _cache:
156
+ return _cache[key]
157
+ table: Dict[str, str] = {}
158
+ path = WW / "alias" / file_name
159
+ if path.is_file():
160
+ for canonical, aliases in _json(path).items():
161
+ table[str(canonical).lower()] = canonical
162
+ for alias in aliases if isinstance(aliases, list) else [aliases]:
163
+ table[str(alias).lower()] = canonical
164
+ _cache[key] = table
165
+ return table
166
+
167
+
168
+ def resolve(text: str) -> Optional[str]:
169
+ """别名/简称 → 角色正式名。要精确匹配时用它, 别自己猜。"""
170
+ found = _alias_map("char_alias.json").get(str(text).strip().lower())
171
+ if found:
172
+ return found
173
+ try:
174
+ convert = _mod("utils.name_convert")
175
+ except Exception:
176
+ return None
177
+ resolved = convert.alias_to_char_name(text)
178
+ return resolved if convert.is_valid_char_name(resolved) else None
179
+
180
+
181
+ def resolve_sonata(text: str) -> str:
182
+ """别名/简称 → 套装正式名, 认不出就原样返回。"""
183
+ found = _alias_map("sonata_alias.json").get(str(text).strip().lower())
184
+ if found:
185
+ return found
186
+ try:
187
+ return _mod("utils.name_convert").alias_to_sonata_name(text) or text
188
+ except Exception:
189
+ return text
190
+
191
+
192
+ def char_id(text: str) -> Optional[str]:
193
+ return _mod("utils.name_convert").char_name_to_char_id(resolve(text) or text)
194
+
195
+
196
+ # ── 角色与声骸视图 ──────────────────────────────────────────────────────
197
+
198
+
199
+ def roles(uid: str) -> List[dict]:
200
+ """角色一览。练度不一时先看 level / chain / weapon.reson / echo_score。"""
201
+ scores = char_scores(uid)
202
+ out = []
203
+ for item in raw_panel(uid):
204
+ role = item.get("role") or {}
205
+ weapon = (item.get("weaponData") or {}).get("weapon") or {}
206
+ chains = item.get("chainList") or []
207
+ out.append(
208
+ {
209
+ "id": str(role.get("roleId", "")),
210
+ "name": role.get("roleName", ""),
211
+ "level": item.get("level") or role.get("level"),
212
+ "attribute": role.get("attributeName", ""),
213
+ "chain": sum(1 for c in chains if c.get("unlocked")),
214
+ "weapon": {
215
+ "name": weapon.get("weaponName", ""),
216
+ "level": (item.get("weaponData") or {}).get("level"),
217
+ "reson": (item.get("weaponData") or {}).get("resonLevel"),
218
+ },
219
+ "skills": {s["skill"]["type"]: s["level"] for s in item.get("skillList") or [] if s.get("skill")},
220
+ "echo_score": scores.get(str(role.get("roleId", "")), 0),
221
+ "sonatas": sonatas_of(item),
222
+ }
223
+ )
224
+ out.sort(key=lambda r: (-(r["echo_score"] or 0), r["name"]))
225
+ return out
226
+
227
+
228
+ def roster(uid: str, limit: int = 0) -> List[str]:
229
+ """持有角色的紧凑一行式, 只想知道"有谁、练到什么程度"时用它, 别拉整份 roles()。
230
+
231
+ 例: 卡提希娅 Lv90 0链 时和裂片5阶 声骸165.4
232
+ """
233
+ out = []
234
+ for role in roles(uid):
235
+ weapon = role["weapon"]
236
+ out.append(
237
+ f"{role['name']} Lv{role['level']} {role['chain']}链 "
238
+ f"{weapon['name']}{weapon['reson']}阶 声骸{role['echo_score']:.1f}"
239
+ )
240
+ return out[:limit] if limit else out
241
+
242
+
243
+ def sonatas_of(panel_item: dict) -> Dict[str, int]:
244
+ """一个角色身上各套装的件数。"""
245
+ counts: Dict[str, int] = {}
246
+ for echo in (panel_item.get("phantomData") or {}).get("equipPhantomList") or []:
247
+ if not echo:
248
+ continue
249
+ key = (echo.get("fetterDetail") or {}).get("name") or ""
250
+ if key:
251
+ counts[key] = counts.get(key, 0) + 1
252
+ return counts
253
+
254
+
255
+ def echoes(uid: str, sonata: Optional[str] = None, char: Optional[str] = None) -> List[dict]:
256
+ """已装备声骸拉平成一张表, 可按套装名或角色名筛。
257
+
258
+ 每项: owner / owner_id / slot / cost / level / sonata / echo(声骸名) /
259
+ main(主词条) / subs(副词条) / raw(原始 dict, 传给 score 用)
260
+ """
261
+ want = resolve_sonata(sonata) if sonata else None
262
+ want_char = resolve(char) if char else None
263
+ out = []
264
+ for item in raw_panel(uid):
265
+ role = item.get("role") or {}
266
+ owner = role.get("roleName", "")
267
+ if want_char and owner != want_char:
268
+ continue
269
+ for slot, echo in enumerate((item.get("phantomData") or {}).get("equipPhantomList") or []):
270
+ if not echo:
271
+ continue
272
+ fetter = (echo.get("fetterDetail") or {}).get("name") or ""
273
+ if want and fetter != want and (sonata or "") not in fetter:
274
+ continue
275
+ mains = echo.get("mainProps") or []
276
+ out.append(
277
+ {
278
+ "owner": owner,
279
+ "owner_id": str(role.get("roleId", "")),
280
+ "slot": slot,
281
+ "cost": echo.get("cost"),
282
+ "level": echo.get("level"),
283
+ "sonata": fetter,
284
+ "echo": (echo.get("phantomProp") or {}).get("name", ""),
285
+ "main": [(p.get("attributeName"), p.get("attributeValue")) for p in mains],
286
+ "subs": [(p.get("attributeName"), p.get("attributeValue")) for p in echo.get("subProps") or []],
287
+ "raw": echo,
288
+ }
289
+ )
290
+ return out
291
+
292
+
293
+ def sonata_pool(uid: str, sonata: str) -> Dict[str, List[dict]]:
294
+ """同一套装的声骸按当前佩戴者归拢, 换装推演的输入。"""
295
+ pool: Dict[str, List[dict]] = {}
296
+ for echo in echoes(uid, sonata=sonata):
297
+ pool.setdefault(echo["owner"], []).append(echo)
298
+ return pool
299
+
300
+
301
+ def matrix_summary(uid: str) -> dict:
302
+ """矩阵战绩摘要: 每层用了谁、打过几个 boss、分数。角色 id 已换成名字。"""
303
+ data = matrix(uid)
304
+ detail = data.get("matrix_data") or {}
305
+ modes = []
306
+ for mode in detail.get("modeDetails") or []:
307
+ teams = []
308
+ for team in mode.get("teams") or []:
309
+ teams.append(
310
+ {
311
+ "roles": [name(r.get("roleId")) for r in team.get("roleList") or []],
312
+ "pass_boss": team.get("passBoss"),
313
+ "boss_count": team.get("bossCount"),
314
+ "buffs": [b.get("buffName") for b in team.get("buffs") or []],
315
+ }
316
+ )
317
+ modes.append(
318
+ {
319
+ "mode_id": mode.get("modeId"),
320
+ "rank": mode.get("rank"),
321
+ "score": mode.get("score"),
322
+ "pass_boss": mode.get("passBoss"),
323
+ "boss_count": mode.get("bossCount"),
324
+ "round": mode.get("round"),
325
+ "teams": teams,
326
+ }
327
+ )
328
+ return {
329
+ "record_time": data.get("record_time"),
330
+ "reward": detail.get("reward"),
331
+ "total_reward": detail.get("totalReward"),
332
+ "modes": modes,
333
+ # "模式_层": [角色id] —— 各层实际匹配到的阵容
334
+ "stage_roles": {
335
+ k: [name(i) for i in v] for k, v in (data.get("matched_char_ids") or {}).items()
336
+ },
337
+ }
338
+
339
+
340
+ def cross_scores(uid: str, sonata: str, chars: Optional[List[str]] = None) -> List[dict]:
341
+ """同套装每件声骸, 在每个候选角色模板下各打一次分。
342
+
343
+ 换装推演的原始证据: 看某件在别人模板下是不是明显更高。
344
+ chars 不给就用这套装当前的佩戴者。
345
+ """
346
+ pool = sonata_pool(uid, sonata)
347
+ targets = [resolve(c) or c for c in chars] if chars else list(pool)
348
+ out = []
349
+ for owner, items in pool.items():
350
+ for echo in items:
351
+ row = {t: score(uid, t, echo)["score"] for t in targets}
352
+ best = max(row, key=row.get) if row else ""
353
+ out.append(
354
+ {
355
+ "owner": owner,
356
+ "echo": echo["echo"],
357
+ "cost": echo["cost"],
358
+ "slot": echo["slot"],
359
+ "main": echo["main"],
360
+ "scores": row,
361
+ "best_for": best,
362
+ "gain_vs_owner": round(row.get(best, 0) - row.get(owner, 0), 2) if best else 0,
363
+ }
364
+ )
365
+ out.sort(key=lambda x: -x["gain_vs_owner"])
366
+ return out
367
+
368
+
369
+ def swap_gains(uid: str, sonata: str, min_gain: float = 0.01) -> List[dict]:
370
+ """同套装跨角色两两互换, 列出总分有净增益的换法(按增益排序)。
371
+
372
+ 只换 cost 相同的件, 因此总 cost 不变(仍在 12 以内); 同一角色身上同名声骸不能重复,
373
+ 会撞名的换法直接排除。分数是各自角色评分模板下的声骸分, 不含伤害模拟。
374
+ """
375
+ pool = sonata_pool(uid, sonata)
376
+ owners = list(pool)
377
+ cache: Dict[tuple, float] = {}
378
+ equipped = {
379
+ role["name"]: {
380
+ (e.get("phantomProp") or {}).get("name", "")
381
+ for e in (next(
382
+ (i for i in raw_panel(uid) if (i.get("role") or {}).get("roleName") == role["name"]),
383
+ {},
384
+ ).get("phantomData") or {}).get("equipPhantomList") or []
385
+ if e
386
+ }
387
+ for role in roles(uid)
388
+ }
389
+
390
+ def clashes(char: str, incoming: dict, outgoing: dict) -> bool:
391
+ """换进来的声骸和这角色身上留着的重名就不行。"""
392
+ keep = equipped.get(char, set()) - {outgoing["echo"]}
393
+ return incoming["echo"] in keep
394
+
395
+ def one(char: str, echo: dict) -> float:
396
+ key = (char, echo["owner"], echo["slot"])
397
+ if key not in cache:
398
+ cache[key] = score(uid, char, echo)["score"]
399
+ return cache[key]
400
+
401
+ out = []
402
+ for i, a in enumerate(owners):
403
+ for b in owners[i + 1 :]:
404
+ for ea in pool[a]:
405
+ for eb in pool[b]:
406
+ if ea["cost"] != eb["cost"]:
407
+ continue
408
+ if clashes(a, eb, ea) or clashes(b, ea, eb):
409
+ continue
410
+ now = one(a, ea) + one(b, eb)
411
+ after = one(a, eb) + one(b, ea)
412
+ if after - now > min_gain:
413
+ out.append(
414
+ {
415
+ "gain": round(after - now, 2),
416
+ "from": {"char": a, "echo": ea["echo"], "cost": ea["cost"], "slot": ea["slot"]},
417
+ "to": {"char": b, "echo": eb["echo"], "cost": eb["cost"], "slot": eb["slot"]},
418
+ "before": round(now, 2),
419
+ "after": round(after, 2),
420
+ }
421
+ )
422
+ out.sort(key=lambda x: -x["gain"])
423
+ return out
424
+
425
+
426
+ # ── 评分 ────────────────────────────────────────────────────────────────
427
+
428
+
429
+ PKG = "plugins.XutheringWavesUID.XutheringWavesUID"
430
+
431
+
432
+ def boot() -> None:
433
+ """装好 import 兼容层, 之后就能 import 插件里的模块。"""
434
+ global _booted
435
+ if _booted:
436
+ return
437
+ if str(SERVICE) not in sys.path:
438
+ sys.path.insert(0, str(SERVICE))
439
+ from rover import compat
440
+
441
+ compat.install([str(SERVICE / "plugins")])
442
+ _booted = True
443
+
444
+
445
+ def _mod(suffix: str):
446
+ """按需取插件内的模块, 例 _mod("utils.name_convert")。"""
447
+ import importlib
448
+
449
+ boot()
450
+ try:
451
+ return importlib.import_module(f"{PKG}.{suffix}")
452
+ except ImportError as e:
453
+ py = SERVICE / ".venv" / ("Scripts" if os.name == "nt" else "bin") / (
454
+ "python.exe" if os.name == "nt" else "python"
455
+ )
456
+ raise RuntimeError(
457
+ f"载入插件模块 {suffix} 失败({e})。评分相关接口要用服务自己的解释器跑: {py}"
458
+ ) from e
459
+
460
+
461
+ def _role_detail(uid: str, char: str):
462
+ RoleDetailData = _mod("utils.api.model").RoleDetailData
463
+
464
+ want = resolve(char) or char
465
+ for item in raw_panel(uid):
466
+ if (item.get("role") or {}).get("roleName") == want:
467
+ return RoleDetailData.model_validate(item)
468
+ raise LookupError(f"{uid} 没有 {want} 的面板数据, 先跑 ww刷新面板")
469
+
470
+
471
+ def calc_temp(uid: str, char: str) -> dict:
472
+ """某角色当前的评分模板(词条权重), 换装推演按它算分。"""
473
+ key = f"temp:{uid}:{char}"
474
+ if key in _cache:
475
+ return _cache[key]
476
+ WuWaCalc = _mod("utils.calc").WuWaCalc
477
+ get_calc_map = _mod("utils.calculate").get_calc_map
478
+
479
+ detail = _role_detail(uid, char)
480
+ calc = WuWaCalc(detail)
481
+ calc.phantom_pre = calc.prepare_phantom()
482
+ calc.phantom_card = calc.enhance_summation_phantom_value(calc.phantom_pre)
483
+ temp = get_calc_map(calc.phantom_card, detail.role.roleName, detail.role.roleId)
484
+ _cache[key] = {"temp": temp, "role_id": detail.role.roleId, "name": temp.get("name", "")}
485
+ return _cache[key]
486
+
487
+
488
+ def score(uid: str, char: str, echo: Any) -> dict:
489
+ """把一个声骸放到 char 的评分模板下打分。
490
+
491
+ echo 可以是 echoes() 的一项、它的 raw、或 make_echo() 造的字典。
492
+ 返回 {score, grade, template, char}
493
+ """
494
+ EquipPhantom = _mod("utils.api.model").EquipPhantom
495
+ calc_phantom_score = _mod("utils.calculate").calc_phantom_score
496
+
497
+ payload = echo.get("raw", echo) if isinstance(echo, dict) else echo
498
+ model = payload if isinstance(payload, EquipPhantom) else EquipPhantom.model_validate(payload)
499
+ ctx = calc_temp(uid, char)
500
+ value, grade = calc_phantom_score(ctx["role_id"], model.get_props(), model.cost, ctx["temp"])
501
+ return {
502
+ "score": round(float(value), 2),
503
+ "grade": grade,
504
+ "template": ctx["name"],
505
+ "char": resolve(char) or char,
506
+ }
507
+
508
+
509
+ def total_score(uid: str, char: str, echo_list: List[Any]) -> float:
510
+ """一整套(通常 5 件)在某角色模板下的总分。"""
511
+ return round(sum(score(uid, char, e)["score"] for e in echo_list), 2)
512
+
513
+
514
+ def challenge_periods() -> dict:
515
+ """深塔/冥海/矩阵当前期号与起止时间(与插件同源的周期规则, 矩阵以游戏内为准)。"""
516
+ from datetime import timedelta
517
+
518
+ period = _mod("wutheringwaves_abyss.period")
519
+ out = {}
520
+ for key, cyc in (("tower", period.TOWER_CYCLE), ("slash", period.SLASH_CYCLE),
521
+ ("matrix", period.MATRIX_CYCLE)):
522
+ start = cyc.cycle_start()
523
+ out[key] = {
524
+ "period": cyc.period_number(),
525
+ "start": start.strftime("%Y-%m-%d %H:%M"),
526
+ "next": (start + timedelta(seconds=cyc.refresh_seconds)).strftime("%Y-%m-%d %H:%M"),
527
+ "days": cyc.refresh_seconds // 86400,
528
+ }
529
+ return out
530
+
531
+
532
+ def _kb():
533
+ if str(SERVICE) not in sys.path:
534
+ sys.path.insert(0, str(SERVICE))
535
+ from rover import knowledge
536
+ return knowledge
537
+
538
+
539
+ def kb(q: str, source: Optional[str] = None, tags: Optional[List[str]] = None,
540
+ limit: int = 5, full: bool = False) -> List[dict]:
541
+ """游戏常识知识库检索(wiki 全站文本等), 按小节返回。先看 snippet 再 kb_get。
542
+
543
+ source 可选 "wiki"; tags 如 ["共鸣者"]。返回 [{id,entry,title,tags,score,snippet|content}]
544
+ """
545
+ return _kb().search(q, source=source, tags=tags, limit=limit, full=full)
546
+
547
+
548
+ def kb_get(kid: str) -> Optional[dict]:
549
+ """传小节 id 取该小节, 传 entry id 取整条(全部小节拼装)。
550
+
551
+ 图片是 ![名](url), 要展示直接把 url 给用户。
552
+ """
553
+ return _kb().get(kid)
554
+
555
+
556
+ _TAG_RE = None
557
+
558
+
559
+ def clean_text(text: Any) -> str:
560
+ """剥掉图鉴文本里的富文本标记(<span style=...>/<color=...>等), 只留正文。"""
561
+ global _TAG_RE
562
+ import html as _html
563
+ import re as _re
564
+
565
+ if _TAG_RE is None:
566
+ _TAG_RE = _re.compile(r"<[^<>]{1,120}>")
567
+ s = _TAG_RE.sub("", str(text or ""))
568
+ s = _html.unescape(s)
569
+ return _re.sub(r"\n{3,}", "\n\n", s).strip()
570
+
571
+
572
+ def detail_file(kind: str, name_or_id: Any) -> Optional[Path]:
573
+ """detail_json 源文件路径(char/weapon/echo/sonata...), 要抠原始字段时再去读它。"""
574
+ kind_dir = MAP / "detail_json" / kind
575
+ key = str(name_or_id)
576
+ if not key.isdigit():
577
+ def _weapon_id(n):
578
+ convert = _mod("utils.name_convert")
579
+ return convert.weapon_name_to_weapon_id(convert.alias_to_weapon_name(n))
580
+
581
+ def _echo_id(n):
582
+ convert = _mod("utils.name_convert")
583
+ return convert.echo_name_to_echo_id(convert.alias_to_echo_name(n))
584
+
585
+ key = {"char": char_id, "weapon": _weapon_id, "echo": _echo_id}.get(
586
+ kind, lambda n: None
587
+ )(key) or key
588
+ p = kind_dir / f"{key}.json"
589
+ return p if p.is_file() else None
590
+
591
+
592
+ def char_info(char: str, parts: Optional[List[str]] = None) -> dict:
593
+ """角色图鉴精简版: 技能/共鸣链等文本已净化, 只传有效信息。
594
+
595
+ parts 可加 "stats"/"ascensions" 取成长数值; 更细的原始字段读返回值里 file 指向的源文件。
596
+ """
597
+ cid = char_id(char)
598
+ if not cid:
599
+ raise LookupError(f"不认识的角色: {char}")
600
+ path = MAP / "detail_json" / "char" / f"{cid}.json"
601
+ d = _json(path)
602
+ const = _mod("utils.resource.constant")
603
+ out: Dict[str, Any] = {
604
+ "id": int(cid),
605
+ "name": d.get("name"),
606
+ "star": d.get("starLevel"),
607
+ "attribute": const.ATTRIBUTE_ID_MAP.get(d.get("attributeId")),
608
+ "weapon_type": const.WEAPON_TYPE_ID_MAP.get(d.get("weaponTypeId")),
609
+ "skills": [
610
+ {"type": s.get("type"), "name": s.get("name"), "desc": clean_text(s.get("desc"))}
611
+ for node in (d.get("skillTree") or {}).values()
612
+ for s in [node.get("skill") or {}] if s
613
+ ],
614
+ "chains": {
615
+ k: {"name": v.get("name"), "desc": clean_text(v.get("desc"))}
616
+ for k, v in (d.get("chains") or {}).items()
617
+ },
618
+ "file": str(path),
619
+ }
620
+ for extra in parts or []:
621
+ if extra in d:
622
+ out[extra] = d[extra]
623
+ return out
624
+
625
+
626
+ def make_echo(cost: int, sonata: str, echo_name: str, main: list, subs: list, level: int = 25) -> dict:
627
+ """按截图/文本转录出来的声骸, 造成可以打分的形状。
628
+
629
+ main / subs 是 [(词条名, 值字符串)], 值带 % 就照抄, 例: ("暴击伤害", "21.0%")
630
+ """
631
+ def props(pairs):
632
+ return [{"attributeName": str(n), "attributeValue": str(v)} for n, v in pairs]
633
+
634
+ return {
635
+ "phantomProp": {"phantomPropId": 0, "phantomId": 0, "name": echo_name, "quality": 5,
636
+ "cost": cost, "iconUrl": "", "skillDescription": ""},
637
+ "cost": int(cost),
638
+ "quality": 5,
639
+ "level": int(level),
640
+ "fetterDetail": {"groupId": 0, "name": sonata, "num": 5},
641
+ "mainProps": props(main),
642
+ "subProps": props(subs),
643
+ }
644
+
645
+
646
+ # ── 自检 ────────────────────────────────────────────────────────────────
647
+
648
+
649
+ def _selftest() -> None:
650
+ uid = default_uid()
651
+ info = base_info(uid)
652
+ print(f"uid={uid} {info.get('name','')} Lv.{info.get('level','')} 角色={len(raw_panel(uid))}")
653
+ print("练度前三:", roster(uid, 3))
654
+ pool = sonata_pool(uid, "沉日劫明")
655
+ print("沉日劫明持有者:", {k: len(v) for k, v in pool.items()})
656
+ if pool:
657
+ owner = next(iter(pool))
658
+ echo = pool[owner][0]
659
+ print(f"样例声骸 {echo['echo']}({echo['cost']}c) 现属 {owner}:", score(uid, owner, echo))
660
+ print("矩阵:", "有" if matrix(uid) else "无", "| 深塔:", "有" if abyss(uid) else "无")
661
+ print("别名: 洛可可 ->", resolve("洛可可"), "| 千咲 ->", resolve("千咲"))
662
+
663
+
664
+ if __name__ == "__main__":
665
+ _selftest()