dna-builder-sdk 0.1.0__py3-none-any.whl
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.
- dna_builder_sdk/__init__.py +27 -0
- dna_builder_sdk/backend.py +202 -0
- dna_builder_sdk/calc/__init__.py +13 -0
- dna_builder_sdk/calc/ast.py +254 -0
- dna_builder_sdk/calc/build.py +694 -0
- dna_builder_sdk/calc/curves.py +246 -0
- dna_builder_sdk/calc/damage.py +634 -0
- dna_builder_sdk/calc/engine.py +1107 -0
- dna_builder_sdk/calc/entities.py +635 -0
- dna_builder_sdk/calc/evaluate.py +364 -0
- dna_builder_sdk/calc/gamedata.py +112 -0
- dna_builder_sdk/calc/jscode.py +613 -0
- dna_builder_sdk/data_pack.py +263 -0
- dna_builder_sdk/errors.py +26 -0
- dna_builder_sdk/game_data.py +118 -0
- dna_builder_sdk/module_store.py +220 -0
- dna_builder_sdk/snapshots.py +141 -0
- dna_builder_sdk-0.1.0.dist-info/METADATA +29 -0
- dna_builder_sdk-0.1.0.dist-info/RECORD +22 -0
- dna_builder_sdk-0.1.0.dist-info/WHEEL +5 -0
- dna_builder_sdk-0.1.0.dist-info/licenses/LICENSE +21 -0
- dna_builder_sdk-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""dna-builder Python SDK:自建后端公开读接口 + 数据双加载 + 纯表达式计算。"""
|
|
2
|
+
|
|
3
|
+
from .backend import BackendClient
|
|
4
|
+
from .calc.gamedata import TableSource, resolve_tables
|
|
5
|
+
from .data_pack import DataPackStore, revive_packed_value
|
|
6
|
+
from .errors import DobApiError, DobGraphQLError, DobHttpError
|
|
7
|
+
from .game_data import GameDataClient
|
|
8
|
+
from .module_store import ModuleStore
|
|
9
|
+
from .snapshots import BuildSnapshot, snapshot_from_online, snapshot_from_settings
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"BackendClient",
|
|
13
|
+
"GameDataClient",
|
|
14
|
+
"DataPackStore",
|
|
15
|
+
"ModuleStore",
|
|
16
|
+
"TableSource",
|
|
17
|
+
"BuildSnapshot",
|
|
18
|
+
"resolve_tables",
|
|
19
|
+
"snapshot_from_online",
|
|
20
|
+
"snapshot_from_settings",
|
|
21
|
+
"revive_packed_value",
|
|
22
|
+
"DobApiError",
|
|
23
|
+
"DobHttpError",
|
|
24
|
+
"DobGraphQLError",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""自建后端(Elysia)传输层 + 公开读接口。
|
|
2
|
+
|
|
3
|
+
只覆盖无需登录的 Query:评论 / 构筑 / 攻略 / 时间轴 / 榜单 / 脚本 /
|
|
4
|
+
MOD / DPS / gameData*。Mutation、admin、shop 私有、my* 一律不实现。
|
|
5
|
+
标准库 urllib 实现零额外 HTTP 依赖,302 默认跟随。
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import urllib.error
|
|
12
|
+
import urllib.parse
|
|
13
|
+
import urllib.request
|
|
14
|
+
|
|
15
|
+
from .errors import DobGraphQLError, DobHttpError
|
|
16
|
+
|
|
17
|
+
_DEFAULT_TIMEOUT = 30
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _post_json(url: str, payload: dict, timeout: float) -> tuple[int, dict | list, str]:
|
|
21
|
+
"""POST JSON 并返回 (status, body, final_url),302 由 opener 自动跟随。"""
|
|
22
|
+
data = json.dumps(payload).encode("utf-8")
|
|
23
|
+
req = urllib.request.Request(
|
|
24
|
+
url,
|
|
25
|
+
data=data,
|
|
26
|
+
headers={"content-type": "application/json", "accept": "application/json"},
|
|
27
|
+
method="POST",
|
|
28
|
+
)
|
|
29
|
+
try:
|
|
30
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
31
|
+
return resp.status, json.loads(resp.read().decode("utf-8")), resp.geturl()
|
|
32
|
+
except urllib.error.HTTPError as exc:
|
|
33
|
+
try:
|
|
34
|
+
body = json.loads(exc.read().decode("utf-8"))
|
|
35
|
+
except Exception:
|
|
36
|
+
body = {"message": str(exc)}
|
|
37
|
+
raise DobHttpError(f"HTTP {exc.code}: {url}", status=exc.code, payload=body) from exc
|
|
38
|
+
except urllib.error.URLError as exc:
|
|
39
|
+
raise DobHttpError(f"连接失败: {url} ({exc.reason})", status=0) from exc
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _get_json(url: str, timeout: float):
|
|
43
|
+
req = urllib.request.Request(url, headers={"accept": "application/json"}, method="GET")
|
|
44
|
+
try:
|
|
45
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
46
|
+
return json.loads(resp.read().decode("utf-8"))
|
|
47
|
+
except urllib.error.HTTPError as exc:
|
|
48
|
+
raise DobHttpError(f"HTTP {exc.code}: {url}", status=exc.code) from exc
|
|
49
|
+
except urllib.error.URLError as exc:
|
|
50
|
+
raise DobHttpError(f"连接失败: {url} ({exc.reason})", status=0) from exc
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class BackendClient:
|
|
54
|
+
"""自建后端客户端,默认线上端点;本地联调传 http://localhost:8887。"""
|
|
55
|
+
|
|
56
|
+
def __init__(self, base_url: str = "https://api.dna-builder.cn", timeout: float = _DEFAULT_TIMEOUT):
|
|
57
|
+
self.base_url = base_url.rstrip("/")
|
|
58
|
+
self.timeout = timeout
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def graphql_url(self) -> str:
|
|
62
|
+
return f"{self.base_url}/graphql"
|
|
63
|
+
|
|
64
|
+
def graphql(self, query: str, variables: dict | None = None) -> dict:
|
|
65
|
+
"""裸 GraphQL 调用,errors 非空时抛 DobGraphQLError。"""
|
|
66
|
+
_, body, _ = _post_json(self.graphql_url, {"query": query, "variables": variables or {}}, self.timeout)
|
|
67
|
+
if not isinstance(body, dict):
|
|
68
|
+
raise DobGraphQLError(f"网关返回非对象: {body!r}", errors=[body])
|
|
69
|
+
if body.get("errors"):
|
|
70
|
+
first = body["errors"][0]
|
|
71
|
+
msg = first.get("message", "graphql error") if isinstance(first, dict) else str(first)
|
|
72
|
+
raise DobGraphQLError(msg, errors=body["errors"])
|
|
73
|
+
return body.get("data") or {}
|
|
74
|
+
|
|
75
|
+
# ---- 评论(读) ----
|
|
76
|
+
def comments(self, target_id: str, limit: int = 50, offset: int = 0) -> list:
|
|
77
|
+
data = self.graphql(
|
|
78
|
+
"query($t:String!,$l:Int,$o:Int){comments(targetId:$t,limit:$l,offset:$o){id targetId content createdAt user{id name}}}",
|
|
79
|
+
{"t": target_id, "l": limit, "o": offset},
|
|
80
|
+
)
|
|
81
|
+
return data.get("comments") or []
|
|
82
|
+
|
|
83
|
+
def comments_count(self, target_id: str) -> int:
|
|
84
|
+
data = self.graphql("query($t:String!){commentsCount(targetId:$t)}", {"t": target_id})
|
|
85
|
+
return int(data.get("commentsCount") or 0)
|
|
86
|
+
|
|
87
|
+
# ---- 构筑(读) ----
|
|
88
|
+
def builds(self, search=None, char_id=None, user_id=None, limit=20, offset=0, sort_by="latest") -> list:
|
|
89
|
+
data = self.graphql(
|
|
90
|
+
"query($s:String,$c:Int,$u:String,$l:Int,$o:Int,$sb:String)"
|
|
91
|
+
"{builds(search:$s,charId:$c,userId:$u,limit:$l,offset:$o,sortBy:$sb)"
|
|
92
|
+
"{id title desc charId userId views likes isRecommended isPinned createdAt updateAt targetValue charSettings}}",
|
|
93
|
+
{"s": search, "c": char_id, "u": user_id, "l": limit, "o": offset, "sb": sort_by},
|
|
94
|
+
)
|
|
95
|
+
return data.get("builds") or []
|
|
96
|
+
|
|
97
|
+
def builds_count(self, search=None, char_id=None) -> int:
|
|
98
|
+
data = self.graphql(
|
|
99
|
+
"query($s:String,$c:Int){buildsCount(search:$s,charId:$c)}", {"s": search, "c": char_id}
|
|
100
|
+
)
|
|
101
|
+
return int(data.get("buildsCount") or 0)
|
|
102
|
+
|
|
103
|
+
def build(self, build_id: str) -> dict | None:
|
|
104
|
+
data = self.graphql(
|
|
105
|
+
"query($id:String!){build(id:$id){id title desc charId charSettings userId views likes createdAt updateAt targetValue}}",
|
|
106
|
+
{"id": build_id},
|
|
107
|
+
)
|
|
108
|
+
return data.get("build")
|
|
109
|
+
|
|
110
|
+
def recommended_builds(self, limit=10) -> list:
|
|
111
|
+
return (self.graphql("query($l:Int){recommendedBuilds(limit:$l){id title charId likes views}}", {"l": limit}).get("recommendedBuilds") or [])
|
|
112
|
+
|
|
113
|
+
def trending_builds(self, limit=10) -> list:
|
|
114
|
+
return (self.graphql("query($l:Int){trendingBuilds(limit:$l){id title charId likes views}}", {"l": limit}).get("trendingBuilds") or [])
|
|
115
|
+
|
|
116
|
+
# ---- 攻略(读) ----
|
|
117
|
+
def guides(self, search=None, type=None, char_id=None, user_id=None, limit=20, offset=0) -> list:
|
|
118
|
+
data = self.graphql(
|
|
119
|
+
"query($s:String,$t:String,$c:Int,$u:String,$l:Int,$o:Int)"
|
|
120
|
+
"{guides(search:$s,type:$t,charId:$c,userId:$u,limit:$l,offset:$o){id title type charId views likes createdAt updateAt}}",
|
|
121
|
+
{"s": search, "t": type, "c": char_id, "u": user_id, "l": limit, "o": offset},
|
|
122
|
+
)
|
|
123
|
+
return data.get("guides") or []
|
|
124
|
+
|
|
125
|
+
def guide(self, guide_id: str) -> dict | None:
|
|
126
|
+
data = self.graphql(
|
|
127
|
+
"query($id:String!){guide(id:$id){id title type content images charId buildId views likes createdAt updateAt}}",
|
|
128
|
+
{"id": guide_id},
|
|
129
|
+
)
|
|
130
|
+
return data.get("guide")
|
|
131
|
+
|
|
132
|
+
# ---- 时间轴(读) ----
|
|
133
|
+
def timelines(self, search=None, char_id=None, user_id=None, limit=20, offset=0, sort_by="recent") -> list:
|
|
134
|
+
data = self.graphql(
|
|
135
|
+
"query($s:String,$c:Int,$u:String,$l:Int,$o:Int,$sb:String)"
|
|
136
|
+
"{timelines(search:$s,charId:$c,userId:$u,limit:$l,offset:$o,sortBy:$sb){id title charId views likes createdAt updateAt}}",
|
|
137
|
+
{"s": search, "c": char_id, "u": user_id, "l": limit, "o": offset, "sb": sort_by},
|
|
138
|
+
)
|
|
139
|
+
return data.get("timelines") or []
|
|
140
|
+
|
|
141
|
+
def timeline(self, timeline_id: str) -> dict | None:
|
|
142
|
+
data = self.graphql(
|
|
143
|
+
"query($id:String!){timeline(id:$id){id title charId views likes createdAt updateAt}}",
|
|
144
|
+
{"id": timeline_id},
|
|
145
|
+
)
|
|
146
|
+
return data.get("timeline")
|
|
147
|
+
|
|
148
|
+
# ---- 榜单(读) ----
|
|
149
|
+
def ranking_lists(self) -> list:
|
|
150
|
+
return (self.graphql("{rankingLists{id name desc createdAt updateAt}}").get("rankingLists") or [])
|
|
151
|
+
|
|
152
|
+
def ranking_list(self, list_id: str) -> dict | None:
|
|
153
|
+
data = self.graphql(
|
|
154
|
+
"query($id:String!){rankingList(id:$id){id name desc items{id charId buildId sortOrder}}}",
|
|
155
|
+
{"id": list_id},
|
|
156
|
+
)
|
|
157
|
+
return data.get("rankingList")
|
|
158
|
+
|
|
159
|
+
def ranking_list_items(self, ranking_list_id: str) -> list:
|
|
160
|
+
data = self.graphql(
|
|
161
|
+
"query($id:String!){rankingListItems(rankingListId:$id){id charId buildId sortOrder}}",
|
|
162
|
+
{"id": ranking_list_id},
|
|
163
|
+
)
|
|
164
|
+
return data.get("rankingListItems") or []
|
|
165
|
+
|
|
166
|
+
# ---- 脚本 / MOD / DPS(读) ----
|
|
167
|
+
def scripts(self, search=None, category=None, user_id=None, limit=20, offset=0) -> list:
|
|
168
|
+
data = self.graphql(
|
|
169
|
+
"query($s:String,$c:String,$u:String,$l:Int,$o:Int)"
|
|
170
|
+
"{scripts(search:$s,category:$c,userId:$u,limit:$l,offset:$o){id title category createdAt updateAt}}",
|
|
171
|
+
{"s": search, "c": category, "u": user_id, "l": limit, "o": offset},
|
|
172
|
+
)
|
|
173
|
+
return data.get("scripts") or []
|
|
174
|
+
|
|
175
|
+
def script(self, script_id: str, preview: bool = False) -> dict | None:
|
|
176
|
+
data = self.graphql(
|
|
177
|
+
"query($id:String!,$p:Boolean){script(id:$id,preview:$p){id title category content}}",
|
|
178
|
+
{"id": script_id, "p": preview},
|
|
179
|
+
)
|
|
180
|
+
return data.get("script")
|
|
181
|
+
|
|
182
|
+
def game_mods(self, search=None, category=None, entity=None, limit=20, offset=0, sort_by=None) -> list:
|
|
183
|
+
data = self.graphql(
|
|
184
|
+
"query($s:String,$c:String,$e:String,$l:Int,$o:Int,$sb:String)"
|
|
185
|
+
"{gameMods(search:$s,category:$c,entity:$e,limit:$l,offset:$o,sortBy:$sb){id title category entity status createdAt updateAt}}",
|
|
186
|
+
{"s": search, "c": category, "e": entity, "l": limit, "o": offset, "sb": sort_by},
|
|
187
|
+
)
|
|
188
|
+
return data.get("gameMods") or []
|
|
189
|
+
|
|
190
|
+
def game_mod(self, mod_id: str) -> dict | None:
|
|
191
|
+
data = self.graphql(
|
|
192
|
+
"query($id:String!){gameMod(id:$id){id title category entity status}}", {"id": mod_id}
|
|
193
|
+
)
|
|
194
|
+
return data.get("gameMod")
|
|
195
|
+
|
|
196
|
+
def dps_list(self, char_id=None, build_id=None, timeline_id=None, limit=20, offset=0, sort_by="dpsValue") -> list:
|
|
197
|
+
data = self.graphql(
|
|
198
|
+
"query($c:Int,$b:String,$t:String,$l:Int,$o:Int,$sb:String)"
|
|
199
|
+
"{dpsList(charId:$c,buildId:$b,timelineId:$t,limit:$l,offset:$o,sortBy:$sb){id charId buildId dpsValue createdAt}}",
|
|
200
|
+
{"c": char_id, "b": build_id, "t": timeline_id, "l": limit, "o": offset, "sb": sort_by},
|
|
201
|
+
)
|
|
202
|
+
return data.get("dpsList") or []
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""calc 包:BD 装配 + 聚合引擎 + 表达式/伤害结算(无编辑语义)。"""
|
|
2
|
+
|
|
3
|
+
from .ast import AstError, parse_ast, tokenize_ast
|
|
4
|
+
from .build import build_state, parse_trace_levels
|
|
5
|
+
from .damage import DamageContext
|
|
6
|
+
from .engine import Engine
|
|
7
|
+
from .evaluate import evaluate
|
|
8
|
+
from .gamedata import GameDataTables, TableSource, resolve_tables
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"AstError", "DamageContext", "Engine", "GameDataTables", "TableSource",
|
|
12
|
+
"build_state", "parse_trace_levels", "parse_ast", "tokenize_ast", "evaluate", "resolve_tables",
|
|
13
|
+
]
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
"""AST 词法/语法:逐行对齐 src/data/ast.ts(Tokenizer + Parser)。
|
|
2
|
+
|
|
3
|
+
节点为普通 dict,type 取值:binary / unary / property / function /
|
|
4
|
+
member_access / temporary_attributes / number,与 TS 字段名一致
|
|
5
|
+
(含 namespace / forceAttr / skillContext 占位,后者 SDK 不绑定)。
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class AstError(ValueError):
|
|
12
|
+
pass
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class _Tokenizer:
|
|
16
|
+
def __init__(self, text: str, macros: dict[str, str]):
|
|
17
|
+
self.text = text
|
|
18
|
+
self.pos = 0
|
|
19
|
+
self.macros = macros
|
|
20
|
+
|
|
21
|
+
def next_token(self):
|
|
22
|
+
self._skip_ws()
|
|
23
|
+
if self.pos >= len(self.text):
|
|
24
|
+
return {"kind": "EOF", "value": "", "pos": self.pos}
|
|
25
|
+
ch = self.text[self.pos]
|
|
26
|
+
if ch.isdigit():
|
|
27
|
+
start = self.pos
|
|
28
|
+
buf = ""
|
|
29
|
+
while self.pos < len(self.text) and (self.text[self.pos].isdigit() or self.text[self.pos] == "."):
|
|
30
|
+
buf += self.text[self.pos]
|
|
31
|
+
self.pos += 1
|
|
32
|
+
return {"kind": "NUMBER", "value": buf, "pos": start}
|
|
33
|
+
if ch.isalpha() or ch == "_" or ch == "[" or "\u4e00" <= ch <= "\u9fa5" or ch == "·":
|
|
34
|
+
start = self.pos
|
|
35
|
+
buf = ""
|
|
36
|
+
while self.pos < len(self.text):
|
|
37
|
+
c = self.text[self.pos]
|
|
38
|
+
if c.isalnum() or c == "_" or "\u4e00" <= c <= "\u9fa5" or c in ("·", "[", "]"):
|
|
39
|
+
buf += c
|
|
40
|
+
self.pos += 1
|
|
41
|
+
else:
|
|
42
|
+
break
|
|
43
|
+
if buf in self.macros:
|
|
44
|
+
repl = self.macros[buf]
|
|
45
|
+
self.text = self.text[: start] + repl + self.text[self.pos :]
|
|
46
|
+
self.pos = start
|
|
47
|
+
return self.next_token()
|
|
48
|
+
return {"kind": "IDENT", "value": buf, "pos": start}
|
|
49
|
+
if ch == "/" and self.pos + 1 < len(self.text) and self.text[self.pos + 1] == "/":
|
|
50
|
+
self.pos += 2
|
|
51
|
+
return {"kind": "OP", "value": "//", "pos": self.pos - 2}
|
|
52
|
+
if ch in ("+", "-", "*", "/", "%"):
|
|
53
|
+
self.pos += 1
|
|
54
|
+
return {"kind": "OP", "value": ch, "pos": self.pos - 1}
|
|
55
|
+
if ch == ".":
|
|
56
|
+
self.pos += 1
|
|
57
|
+
return {"kind": "DOT", "value": ".", "pos": self.pos - 1}
|
|
58
|
+
if ch == ":":
|
|
59
|
+
if self.pos + 1 < len(self.text) and self.text[self.pos + 1] == ":":
|
|
60
|
+
self.pos += 2
|
|
61
|
+
return {"kind": "DCOLON", "value": "::", "pos": self.pos - 2}
|
|
62
|
+
self.pos += 1
|
|
63
|
+
return {"kind": "COLON", "value": ":", "pos": self.pos - 1}
|
|
64
|
+
simple = {"(": "LPAREN", ")": "RPAREN", ",": "COMMA", "{": "LBRACE", "}": "RBRACE", "!": "BANG"}
|
|
65
|
+
if ch in simple:
|
|
66
|
+
self.pos += 1
|
|
67
|
+
return {"kind": simple[ch], "value": ch, "pos": self.pos - 1}
|
|
68
|
+
raise AstError(f"未知字符 '{ch}' 位于位置 {self.pos}")
|
|
69
|
+
|
|
70
|
+
def _skip_ws(self):
|
|
71
|
+
while self.pos < len(self.text) and self.text[self.pos].isspace():
|
|
72
|
+
self.pos += 1
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class _Parser:
|
|
76
|
+
def __init__(self, text: str, macros: dict[str, str]):
|
|
77
|
+
tok = _Tokenizer(text, macros)
|
|
78
|
+
self.tokens: list[dict] = []
|
|
79
|
+
while True:
|
|
80
|
+
t = tok.next_token()
|
|
81
|
+
if t["kind"] == "EOF":
|
|
82
|
+
break
|
|
83
|
+
self.tokens.append(t)
|
|
84
|
+
self.cur = 0
|
|
85
|
+
|
|
86
|
+
def parse(self):
|
|
87
|
+
if not self.tokens:
|
|
88
|
+
raise AstError("表达式为空")
|
|
89
|
+
node = self._expr()
|
|
90
|
+
if self.cur < len(self.tokens):
|
|
91
|
+
if self._peek()["kind"] == "COLON":
|
|
92
|
+
raise AstError("单个冒号 ':' 不支持,请使用 '::' 进行命名空间访问")
|
|
93
|
+
raise AstError(f"表达式末尾发现意外的标记 '{self._peek()['value']}'")
|
|
94
|
+
return node
|
|
95
|
+
|
|
96
|
+
def _expr(self):
|
|
97
|
+
left = self._term()
|
|
98
|
+
while self._match("OP", "+") or self._match("OP", "-"):
|
|
99
|
+
op = self._prev()["value"]
|
|
100
|
+
if self._at_end() or self._check("OP") or self._check("RPAREN") or self._check("COMMA"):
|
|
101
|
+
raise AstError(f"运算符 '{op}' 后缺少操作数")
|
|
102
|
+
left = {"type": "binary", "operator": op, "left": left, "right": self._term()}
|
|
103
|
+
return left
|
|
104
|
+
|
|
105
|
+
def _term(self):
|
|
106
|
+
left = self._unary()
|
|
107
|
+
while (
|
|
108
|
+
self._match("OP", "*") or self._match("OP", "/") or self._match("OP", "//") or self._match("OP", "%")
|
|
109
|
+
):
|
|
110
|
+
op = self._prev()["value"]
|
|
111
|
+
if self._at_end() or self._check("OP") or self._check("RPAREN") or self._check("COMMA"):
|
|
112
|
+
raise AstError(f"运算符 '{op}' 后缺少操作数")
|
|
113
|
+
left = {"type": "binary", "operator": op, "left": left, "right": self._unary()}
|
|
114
|
+
return left
|
|
115
|
+
|
|
116
|
+
def _unary(self):
|
|
117
|
+
if self._match("OP", "-") or self._match("OP", "+"):
|
|
118
|
+
return {"type": "unary", "operator": self._prev()["value"], "argument": self._unary()}
|
|
119
|
+
return self._factor()
|
|
120
|
+
|
|
121
|
+
def _factor(self):
|
|
122
|
+
if self._match("NUMBER"):
|
|
123
|
+
node: dict = {"type": "number", "value": float(self._prev()["value"])}
|
|
124
|
+
elif self._match("IDENT"):
|
|
125
|
+
name = self._prev()["value"]
|
|
126
|
+
ns = None
|
|
127
|
+
if self._match("DCOLON"):
|
|
128
|
+
ns = name
|
|
129
|
+
nxt = self._consume("IDENT", "命名空间 '::' 后缺少标识符")
|
|
130
|
+
name = nxt["value"]
|
|
131
|
+
if self._match("LPAREN"):
|
|
132
|
+
node = {"type": "function", "name": name, "namespace": ns, "args": self._arg_list()}
|
|
133
|
+
else:
|
|
134
|
+
node = {"type": "property", "name": name, "namespace": ns}
|
|
135
|
+
node = self._force_suffix(node)
|
|
136
|
+
elif self._match("LPAREN"):
|
|
137
|
+
node = {"type": "function", "name": name, "args": self._arg_list()}
|
|
138
|
+
else:
|
|
139
|
+
node = {"type": "property", "name": name}
|
|
140
|
+
node = self._force_suffix(node)
|
|
141
|
+
elif self._match("LPAREN"):
|
|
142
|
+
node = self._expr()
|
|
143
|
+
self._consume("RPAREN", "表达式后缺少 ')'")
|
|
144
|
+
else:
|
|
145
|
+
raise AstError(f"意外的标记: {self._peek()['value']}")
|
|
146
|
+
while True:
|
|
147
|
+
if self._match("DOT"):
|
|
148
|
+
prop = self._consume("IDENT", "成员访问 '.' 后缺少属性名称")
|
|
149
|
+
node = {"type": "member_access", "object": node, "property": prop["value"]}
|
|
150
|
+
continue
|
|
151
|
+
if self._match("LBRACE"):
|
|
152
|
+
if node["type"] not in ("property", "member_access", "temporary_attributes"):
|
|
153
|
+
raise AstError("临时属性只能应用于字段")
|
|
154
|
+
attrs: list[dict] = []
|
|
155
|
+
seen: set[str] = set()
|
|
156
|
+
if self._check("RBRACE"):
|
|
157
|
+
raise AstError("临时属性不能为空")
|
|
158
|
+
while True:
|
|
159
|
+
a = self._consume("IDENT", "临时属性缺少属性名")
|
|
160
|
+
self._consume("COLON", f"临时属性 '{a['value']}' 后缺少 ':'")
|
|
161
|
+
if a["value"] in seen:
|
|
162
|
+
raise AstError(f"临时属性 '{a['value']}' 重复")
|
|
163
|
+
seen.add(a["value"])
|
|
164
|
+
attrs.append({"name": a["value"], "value": self._expr()})
|
|
165
|
+
if not self._match("COMMA"):
|
|
166
|
+
break
|
|
167
|
+
self._consume("RBRACE", "临时属性后缺少 '}'")
|
|
168
|
+
node = {"type": "temporary_attributes", "target": node, "attributes": attrs}
|
|
169
|
+
continue
|
|
170
|
+
break
|
|
171
|
+
return node
|
|
172
|
+
|
|
173
|
+
def _arg_list(self):
|
|
174
|
+
args = []
|
|
175
|
+
if not self._check("RPAREN"):
|
|
176
|
+
while True:
|
|
177
|
+
args.append(self._expr())
|
|
178
|
+
if not self._match("COMMA"):
|
|
179
|
+
break
|
|
180
|
+
self._consume("RPAREN", "函数参数后缺少 ')'")
|
|
181
|
+
return args
|
|
182
|
+
|
|
183
|
+
def _force_suffix(self, node):
|
|
184
|
+
if self._match("BANG"):
|
|
185
|
+
node = {**node, "forceAttr": True}
|
|
186
|
+
return node
|
|
187
|
+
|
|
188
|
+
# -- 游标 --
|
|
189
|
+
def _match(self, kind, value=None):
|
|
190
|
+
if self._check(kind, value):
|
|
191
|
+
self.cur += 1
|
|
192
|
+
return True
|
|
193
|
+
return False
|
|
194
|
+
|
|
195
|
+
def _check(self, kind, value=None):
|
|
196
|
+
if self._at_end():
|
|
197
|
+
return False
|
|
198
|
+
t = self._peek()
|
|
199
|
+
if t["kind"] != kind:
|
|
200
|
+
return False
|
|
201
|
+
return value is None or t["value"] == value
|
|
202
|
+
|
|
203
|
+
def _at_end(self):
|
|
204
|
+
return self.cur >= len(self.tokens)
|
|
205
|
+
|
|
206
|
+
def _peek(self):
|
|
207
|
+
return self.tokens[self.cur] if self.cur < len(self.tokens) else {"kind": "EOF", "value": ""}
|
|
208
|
+
|
|
209
|
+
def _prev(self):
|
|
210
|
+
return self.tokens[self.cur - 1]
|
|
211
|
+
|
|
212
|
+
def _consume(self, kind, message):
|
|
213
|
+
if self._check(kind):
|
|
214
|
+
self.cur += 1
|
|
215
|
+
return self._prev()
|
|
216
|
+
raise AstError(message)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _normalize_macros(macros) -> dict[str, str]:
|
|
220
|
+
if not macros:
|
|
221
|
+
return {}
|
|
222
|
+
if isinstance(macros, dict):
|
|
223
|
+
return dict(macros)
|
|
224
|
+
raise AstError("macros 仅支持 dict(TS 侧 Map/Record 口径)")
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
# CharBuild.macros:AST 表达式宏替换(TS 侧每次 parseAST 传入)
|
|
228
|
+
CHAR_MACROS = {
|
|
229
|
+
"ATK": "攻击", "DEF": "防御", "HP": "生命", "SP": "神智",
|
|
230
|
+
"DPH": "or(多重,1)*伤害", "总伤": "max(1,召唤物攻击次数)*伤害",
|
|
231
|
+
"暴击伤害": "伤害.暴击", "DPS": "or(攻速,1+技能速度)*or(多重,1)*伤害",
|
|
232
|
+
"范围收益": "技能范围*伤害", "耐久收益": "技能耐久*伤害", "效益收益": "技能效益*伤害",
|
|
233
|
+
"每神智DPH": "1/神智消耗*伤害", "每持续神智DPH": "1/每秒神智消耗*伤害",
|
|
234
|
+
"每神智DPS": "or(攻速,1+技能速度)/神智消耗*伤害",
|
|
235
|
+
"每持续神智DPS": "or(攻速,1+技能速度)/每秒神智消耗*伤害",
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def parse_ast(expr: str, macros: dict[str, str] | None = None) -> dict:
|
|
240
|
+
"""解析表达式为 AST dict(缺省沿用 CharBuild.macros,与 TS 各调用点一致)。"""
|
|
241
|
+
return _Parser(expr, _normalize_macros(CHAR_MACROS if macros is None else macros)).parse()
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def tokenize_ast(expr: str, max_length: int | None = None) -> list[dict]:
|
|
245
|
+
"""按 AST 词法扫描(宏名原样返回,不替换),供位置扫描使用。"""
|
|
246
|
+
limit = max_length if max_length is not None else float("inf")
|
|
247
|
+
tok = _Tokenizer(expr, {})
|
|
248
|
+
out = []
|
|
249
|
+
while True:
|
|
250
|
+
t = tok.next_token()
|
|
251
|
+
if t["kind"] == "EOF" or t["pos"] >= limit:
|
|
252
|
+
break
|
|
253
|
+
out.append(t)
|
|
254
|
+
return out
|