@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.
- package/DSH-GO-VERIFICATION.md +57 -0
- package/ICON.png +0 -0
- package/LICENSE +674 -0
- package/README.md +251 -2
- package/cordis.patch.yml +8 -0
- package/install.ps1 +0 -0
- package/install.sh +209 -0
- package/lib/client.js +1511 -0
- package/lib/host/aliashint.js +70 -0
- package/lib/host/attachments.js +25 -0
- package/lib/host/command.js +34 -0
- package/lib/host/consts.js +16 -0
- package/lib/host/dataguide.js +35 -0
- package/lib/host/imagebridge.js +41 -0
- package/lib/host/native-result.js +91 -0
- package/lib/host/native.js +191 -0
- package/lib/host/proxy.js +59 -0
- package/lib/host/results.js +67 -0
- package/lib/host/service.js +263 -0
- package/lib/host/tool.js +185 -0
- package/lib/host/troubleshoot.js +31 -0
- package/lib/host/worldbook.js +80 -0
- package/lib/index.js +74 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +136 -4
- package/service/ICON.png +0 -0
- package/service/ai/AI_DATA.md +221 -0
- package/service/knowledge/additional/manual.jsonl +5 -0
- package/service/knowledge/wiki/baike.jsonl +1231 -0
- package/service/knowledge/wiki/chengjiu.jsonl +36 -0
- package/service/knowledge/wiki/gonglve.jsonl +383 -0
- package/service/knowledge/wiki/huodong.jsonl +427 -0
- package/service/knowledge/wiki/renwu.jsonl +412 -0
- package/service/knowledge/wiki/tujian.jsonl +2889 -0
- package/service/knowledge/wiki/zixun.jsonl +47 -0
- package/service/plugins/__init__.py +0 -0
- package/service/pyproject.toml +40 -0
- package/service/rover/__init__.py +0 -0
- package/service/rover/ai_core/__init__.py +0 -0
- package/service/rover/ai_core/models.py +50 -0
- package/service/rover/ai_core/rag/__init__.py +5 -0
- package/service/rover/ai_core/rag/knowledge.py +23 -0
- package/service/rover/ai_core/register.py +86 -0
- package/service/rover/ai_core/resource.py +6 -0
- package/service/rover/ai_core/skills/__init__.py +0 -0
- package/service/rover/ai_core/skills/operations.py +8 -0
- package/service/rover/api.py +720 -0
- package/service/rover/api_render.py +161 -0
- package/service/rover/app_life.py +154 -0
- package/service/rover/aps.py +47 -0
- package/service/rover/avatar.py +85 -0
- package/service/rover/bot.py +133 -0
- package/service/rover/builtin.py +152 -0
- package/service/rover/cards/__init__.py +26 -0
- package/service/rover/cards/_shared.py +129 -0
- package/service/rover/cards/char_list.py +258 -0
- package/service/rover/cards/char_panel.py +1353 -0
- package/service/rover/cards/echo_list.py +206 -0
- package/service/rover/cards/matrix_roles.py +25 -0
- package/service/rover/cards/rank_boards.py +714 -0
- package/service/rover/cards/rank_total.py +664 -0
- package/service/rover/compat.py +215 -0
- package/service/rover/config.py +163 -0
- package/service/rover/custom_panel.py +220 -0
- package/service/rover/data_store.py +31 -0
- package/service/rover/gss.py +19 -0
- package/service/rover/handler.py +191 -0
- package/service/rover/help/__init__.py +0 -0
- package/service/rover/help/draw_new_plugin_help.py +332 -0
- package/service/rover/help/model.py +16 -0
- package/service/rover/help/utils.py +7 -0
- package/service/rover/hooks.py +420 -0
- package/service/rover/html_actions.py +236 -0
- package/service/rover/i18n.py +19 -0
- package/service/rover/knowledge.py +235 -0
- package/service/rover/logger.py +102 -0
- package/service/rover/main.py +33 -0
- package/service/rover/media.py +222 -0
- package/service/rover/message_models.py +23 -0
- package/service/rover/models.py +92 -0
- package/service/rover/native_media.py +64 -0
- package/service/rover/ocr.py +79 -0
- package/service/rover/pool.py +43 -0
- package/service/rover/restart.py +36 -0
- package/service/rover/segment.py +143 -0
- package/service/rover/server.py +131 -0
- package/service/rover/status/__init__.py +0 -0
- package/service/rover/status/models.py +10 -0
- package/service/rover/status/plugin_status.py +17 -0
- package/service/rover/subscribe.py +148 -0
- package/service/rover/sv.py +403 -0
- package/service/rover/templates/char_list.html +182 -0
- package/service/rover/templates/char_panel.html +465 -0
- package/service/rover/templates/echo_list.html +221 -0
- package/service/rover/templates/rank_all_total.html +193 -0
- package/service/rover/templates/rank_board.html +107 -0
- package/service/rover/templates/rank_level_total.html +57 -0
- package/service/rover/templates/rank_matrix_total.html +66 -0
- package/service/rover/templates/rank_phantom_total.html +180 -0
- package/service/rover/templates/rank_slash_total.html +38 -0
- package/service/rover/trigger.py +98 -0
- package/service/rover/utils/__init__.py +0 -0
- package/service/rover/utils/boardcast/__init__.py +0 -0
- package/service/rover/utils/boardcast/models.py +15 -0
- package/service/rover/utils/cookie_manager/__init__.py +0 -0
- package/service/rover/utils/cookie_manager/qrlogin.py +24 -0
- package/service/rover/utils/database/__init__.py +0 -0
- package/service/rover/utils/database/base_models.py +1771 -0
- package/service/rover/utils/database/models.py +80 -0
- package/service/rover/utils/database/startup.py +60 -0
- package/service/rover/utils/default_bg/bg.jpg +0 -0
- package/service/rover/utils/download_resource/__init__.py +0 -0
- package/service/rover/utils/download_resource/download_core.py +164 -0
- package/service/rover/utils/download_resource/download_file.py +46 -0
- package/service/rover/utils/fonts/__init__.py +0 -0
- package/service/rover/utils/fonts/fonts.py +33 -0
- package/service/rover/utils/image/__init__.py +0 -0
- package/service/rover/utils/image/convert.py +51 -0
- package/service/rover/utils/image/image_tools.py +30 -0
- package/service/rover/utils/image/utils.py +70 -0
- package/service/rover/utils/plugins_config/__init__.py +0 -0
- package/service/rover/utils/plugins_config/gs_config.py +506 -0
- package/service/rover/utils/plugins_config/models.py +182 -0
- package/service/rover/web_app.py +3 -0
- package/service/rover/webconsole/__init__.py +0 -0
- package/service/rover/webconsole/mount_app.py +26 -0
- package/service/tools/migrate.py +351 -0
- package/service/tools/regression.py +87 -0
- package/service/tools/smoke.py +63 -0
- package/service/tools/update_plugins.py +212 -0
- package/service/tools/verify_actions.py +104 -0
- package/service/tools/verify_card.py +82 -0
- package/service/tools/wwdata.py +665 -0
- package/tests/compat.test.js +181 -0
- package/tests/imagebridge.test.js +58 -0
- package/tests/media.test.js +68 -0
- package/tests/native.test.js +127 -0
- package/tests/runtime.test.js +69 -0
- 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 给用户。
|
|
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()
|