feat(all): add resilient server recovery and talent upgrades

This commit is contained in:
2026-10-03 15:09:31 +08:00
parent db1eab764e
commit ce03fe7289
70 changed files with 3664 additions and 419 deletions
+6 -7
View File
@@ -26,7 +26,7 @@ python .\tools\python\extract_client_proto.py `
--output .\tmp\client-proto
```
反混淆输出根下的 `.bd2-deobfuscate-manifest.json` 记录有效映射、冲突改名、未处理警告和替换统计。映射含命名空间或路径时,工具取末段并规范为一个合法标识符;发生同名时加入稳定后缀。该镜像用于阅读与检索,并不承诺可编译。
反混淆输出根下的 `.bd2-deobfuscate-manifest.json` 记录有效映射、冲突改名、未处理警告和替换统计。映射含命名空间或路径时,工具取末段并规范为一个合法标识符;发生同名时加入稳定后缀。反编译器在源码里留下的派生局部名也会回推:`映射名 + 数字后缀` 按最长映射前缀还原,`camelCase(映射名)`(首段大写字母整体小写)按来源唯一的逆变换还原;两者计入 `derived_identifier_replacements`。注释与字面量始终保持原样(其中的名字须回查映射表)。该镜像用于阅读与检索,并不承诺可编译。
Proto 输出根按 descriptor 原始文件名保存可读 `.proto`;`client-descriptors.pb` 是无损 `FileDescriptorSet`,用于保留文本渲染器暂未展开的复杂 options。`.bd2-proto-extract-manifest.json` 记录 package、依赖、源 Reflection、descriptor/.proto SHA-256 和渲染警告。
@@ -97,27 +97,26 @@ python .\tools\python\import_seed.py mail .\decoded\MailInfo.pb `
无需 GameData 或第三方 Python 包的 `grant` 子命令可追加一封含多个附件的动态邮件。服务端使用 `--mail-grant-spool` 读取该 JSON,每次 `/MailInfo` 导入未发放的 identity;已发放 identity 在领取和重启后仍保持幂等。CLI 允许付费钻石 `2:0:数量`、免费钻石 `3:0:数量`、金币 `4:0:数量`、天赋神药 `12:0:数量`、金线 `20:0:数量`,以及当前 2.35.10 已核实的抽抽乐券 `8:1000:数量` 和 UR 专用装备抽抽乐券 `8:1104:数量`,每个附件数量均限制为 `1..2147483647`。例如 `python tools/python/dev_mail_grant.py grant --output data/dev/currency-grants.json --identity test-paid-and-tickets-1 --attachment 2:0:100000000 --attachment 8:1000:100000000 --attachment 8:1104:100000000 --attachment 4:0:1000000000`。两种券通过 `ItemDBInfo` 写入背包,钻石和金币直接叠加账户余额;发放前应检查同类券现有库存加附件后的总量仍在客户端 `int32` 范围内。
启动工具时,`--mail-seed` 是只读的当前基础邮件种子;`--output` 是新生成的完整临时种子;`--settings-output` 保存与账号存档分离的开发选项。工具启动后在浏览器打开 `http://127.0.0.1:8765/`:
启动工具时,`--output` 是 `version=1` 的动态邮件 grant spool;网页只追加带稳定 identity 的待发放请求,实际 `mail_id` 由服务端统一分配,因此不会与任务补偿等动态邮件撞号。`--settings-output` 保存与账号存档分离的开发选项。页面会在一次提交完成前禁用按钮,避免浏览器双击生成重复请求。工具启动后在浏览器打开 `http://127.0.0.1:8765/`:
```powershell
python .\tools\python\dev_mail_grant.py serve `
--game-data "E:\bd2\dl\GameData" `
--game-data-version "20260923193640" `
--mail-seed .\go\seed\v2_35_10\mail.json `
--output .\data\dev\mail-grants.json `
--output .\data\dev\mail-grants-spool.json `
--settings-output .\data\dev\dev-tools.json
```
工具启动时立即原子写出规范化的完整 `--output`(尚未发放也一样),因此首次启用时可先启动工具、再让本地服务端监听这个输出文件。每次发放同样原子更新该文件。`bd2server` 的邮件服务会在下一次正常 `/MailInfo` 请求检查它:网页发放后重新打开或刷新游戏邮箱即可看到新邮件,**不需要每次重启服务器**。服务仅在启动时需要加入(或替换为)以下参数:
工具首次提交及后续每次发放都会原子更新 `--output`。`bd2server` 的邮件服务会在下一次正常 `/MailInfo` 请求检查它:网页发放后重新打开或刷新游戏邮箱即可看到新邮件,**不需要每次重启服务器**。服务启动时加入:
```powershell
--mail-seed ".\data\dev\mail-grants.json"
--mail-grant-spool ".\data\dev\mail-grants-spool.json"
--dev-tools-config ".\data\dev\dev-tools.json"
```
页面下方的“无限背包容量”不会写入账号状态,也不会使用任意巨大容量;它从当前 GameData 读取客户端安全上限(2.35.10 当前为普通道具 500、装备 2000)。切换后无需重启服务端,但客户端必须重新登录以重新取得 `UserDBInfo`。普通背包、普通仓库、装备背包和装备仓库的正式扩充接口仍按 `GameDefaultTable` 的逐格阶梯价格扣除金币并持久化;开发开关不覆盖或删除已购买容量。
客户端 `MailDBInfo.ItemType`、`ItemId` 和 `ItemCount` 均为 `int32`,所以该工具把单附件数量限制为 `1..2147483647`;每封工具邮件固定只有一个附件。当前官方样本中单封最多观察到 5 个附件,但没有证据证明这是协议上限,因此工具不据此宣称或实施“5 件”上限。客户端邮箱 UI 按一次请求加载最多 100 封普通邮件,现有本地服务目前回传全部未开封邮件,故大量历史未领取邮件的实际 UI 表现尚待验证。现有本地 `/MailOpen` 对同一邮件 ID 的领取由 `data/state/mail.json` 的 `opened` 集合持久化,重试不会重复发奖;工具会在完整种子中分配唯一递增邮件 ID。
客户端 `MailDBInfo.ItemType`、`ItemId` 和 `ItemCount` 均为 `int32`,所以该工具把单附件数量限制为 `1..2147483647`。当前官方样本中单封最多观察到 5 个附件,但没有证据证明这是协议上限,因此工具不据此宣称或实施“5 件”上限。服务端统一分配动态邮件 ID,并持久化 spool identity、领取状态和领取历史;同一邮件重试不会重复发奖。
这不是“所有 GameData 表都可发放”的虚假承诺:角色(元素类型 6)、装备(10)、服装(11)和我的房间奖杯(28)在客户端 `RewardDBInfoBundle` 中分别必须使用 `CharDBInfo`、`EquipDBInfo`、`CostumeDBInfo`、`MyRoomTrophyDBInfo`,而当前本地邮件服务尚未连接相应领域存档,工具不会提供它们;直接伪装成 `ItemDBInfo` 会造成客户端状态错误。付费/普通货币之外的特殊货币亦不在当前本地钱包实现范围内。`ContentTicket`(19)仅为已核实的满月一次性专用券开放 `19:450030:1`:客户端 `CommonPacket.AddItemInfo` 将其交给 `AddContentTicketItem`,服务端按实例存入物品存档并沿用邮件领取去重;其他内容券 ID 和其他数量均拒绝。浏览器道具列表不开放内容券,`grant` 子命令可使用这一严格限定的附件。`LobbySettingItem`(25)仍未提供;`GetItemInfo` 的显示分支不足以证明可安全存储。若要补齐其余类型,需要先实现对应的服务端存储、去重及正确 reward-bundle 字段,不需要客户端 patch。
+119 -27
View File
@@ -193,6 +193,70 @@ def build_replacements(entries: Iterable[dict]) -> tuple[dict[str, str], list[di
return replacements, report, warnings
def camel_case(name: str) -> str:
"""Lowercase the leading run of uppercase letters.
The decompiler names locals after their type, lowering the type's leading
uppercase run (``<>c__DisplayClass`` becomes ``c__DisplayClass``); the
result is a token the mapping never contains verbatim.
"""
index = 0
while index < len(name) and name[index].isupper():
index += 1
return name[:index].lower() + name[index:] if index else name
class ReplacementIndex:
"""Exact mappings plus the decompiler-derived forms they can appear as.
Only tokens that contain a non-ASCII character are considered: every
mapping source is an obfuscated (non-ASCII) identifier, so ordinary code
tokens never reach the derived rules and pay nothing for them.
"""
EXACT = "exact"
SUFFIXED = "suffixed"
CAMEL = "camel"
def __init__(self, replacements: dict[str, str]):
self.replacements = replacements
self.derived_names: dict[str, list[str]] = {}
for source in replacements:
key = camel_case(source)
if key != source:
self.derived_names.setdefault(key, []).append(source)
def _base_replacement(self, token: str) -> str | None:
"""A mapped name's replacement, or its unambiguous camel-case form."""
replacement = self.replacements.get(token)
if replacement is not None:
return replacement
sources = self.derived_names.get(token)
if sources is not None and len(sources) == 1:
return camel_case(self.replacements[sources[0]])
return None
def resolve(self, token: str) -> tuple[str | None, str | None]:
"""Return ``(replacement, kind)`` for one token, or ``(None, None)``."""
replacement = self._base_replacement(token)
if replacement is not None:
kind = self.EXACT if token in self.replacements else self.CAMEL
return replacement, kind
if token.isascii():
return None, None
# ``<base><digits>``: the decompiler disambiguated a local by appending
# a counter, where the base is a mapped name or its camel-case form.
# Take the longest base so mapped names ending in digits are not
# truncated.
for cut in range(len(token) - 1, 0, -1):
head, tail = token[:cut], token[cut:]
if tail.isdigit():
base = self._base_replacement(head)
if base is not None:
return base + tail, self.SUFFIXED
return None, None
def _consume_quoted(text: str, start: int, quote: str) -> int:
"""Return the index after a C# string/character/raw-string literal."""
quotes = 0
@@ -216,39 +280,51 @@ def _consume_quoted(text: str, start: int, quote: str) -> int:
return len(text)
def replace_csharp_identifiers(text: str, replacements: dict[str, str]) -> tuple[str, int]:
"""Replace code identifiers only; comments and literal payloads stay exact."""
def replace_csharp_identifiers(
text: str, replacements: dict[str, str] | ReplacementIndex
) -> tuple[str, int, int]:
"""Replace code identifiers only; comments and literal payloads stay exact.
Returns ``(text, changed, derived)`` where ``derived`` counts the
replacements resolved through :class:`ReplacementIndex`'s suffix/camel
rules rather than an exact mapping source.
"""
index = replacements if isinstance(replacements, ReplacementIndex) else ReplacementIndex(replacements)
output: list[str] = []
index = changed = 0
position = changed = derived = 0
length = len(text)
while index < length:
if text.startswith("//", index):
end = text.find("\n", index)
while position < length:
if text.startswith("//", position):
end = text.find("\n", position)
end = length if end < 0 else end
output.append(text[index:end])
index = end
elif text.startswith("/*", index):
end = text.find("*/", index + 2)
output.append(text[position:end])
position = end
elif text.startswith("/*", position):
end = text.find("*/", position + 2)
end = length if end < 0 else end + 2
output.append(text[index:end])
index = end
elif text[index] in "\"'":
end = _consume_quoted(text, index, text[index])
output.append(text[index:end])
index = end
elif is_identifier_start(text[index]):
end = index + 1
output.append(text[position:end])
position = end
elif text[position] in "\"'":
end = _consume_quoted(text, position, text[position])
output.append(text[position:end])
position = end
elif is_identifier_start(text[position]):
end = position + 1
while end < length and is_identifier_continue(text[end]):
end += 1
token = text[index:end]
replacement = replacements.get(token, token)
token = text[position:end]
replacement, kind = index.resolve(token)
if replacement is None:
replacement = token
output.append(replacement)
changed += replacement != token
index = end
if replacement != token:
changed += 1
derived += kind != ReplacementIndex.EXACT
position = end
else:
output.append(text[index])
index += 1
return "".join(output), changed
output.append(text[position])
position += 1
return "".join(output), changed, derived
def read_csharp(path: Path) -> tuple[str, str] | None:
@@ -297,6 +373,13 @@ def deobfuscate(source: Path, mapping: Path, output: Path) -> dict:
entries, warnings = parse_mapping(mapping)
replacements, mapping_report, replacement_warnings = build_replacements(entries)
warnings.extend(replacement_warnings)
index = ReplacementIndex(replacements)
for key, sources in index.derived_names.items():
if len(sources) > 1:
warnings.append(
f"derived name {key!r}: ignored ambiguous camel-case sources "
f"({len(sources)} candidates)"
)
stage = prepare_stage(output)
files: list[dict] = []
occupied: set[Path] = set()
@@ -316,9 +399,13 @@ def deobfuscate(source: Path, mapping: Path, output: Path) -> dict:
warnings.append(f"{relative}: copied without replacement (unsupported encoding)")
else:
text, encoding = decoded
translated, changed = replace_csharp_identifiers(text, replacements)
translated, changed, derived = replace_csharp_identifiers(text, index)
write_csharp(destination_path, translated, encoding)
record.update({"action": "translated", "identifier_replacements": changed})
record.update({
"action": "translated",
"identifier_replacements": changed,
"derived_identifier_replacements": derived,
})
else:
shutil.copy2(input_path, destination_path)
record["action"] = "copied"
@@ -335,10 +422,15 @@ def deobfuscate(source: Path, mapping: Path, output: Path) -> dict:
"files": len(files),
"csharp_files": sum(item["source"].casefold().endswith(".cs") for item in files),
"identifier_replacements": sum(item.get("identifier_replacements", 0) for item in files),
"derived_identifier_replacements": sum(
item.get("derived_identifier_replacements", 0) for item in files
),
},
"limitations": [
"Only C# identifier tokens are changed; comments and literal contents are preserved.",
"Qualified mapping values become their terminal identifier component.",
"Decompiler-derived locals (mapped name plus a numeric suffix, or the "
"camel-cased mapped name) are resolved back through those two derived rules.",
"This is a searchable mirror, not a promise that the transformed source compiles.",
],
}
+28 -68
View File
@@ -8,7 +8,7 @@ by an explicitly configured local bd2server.
Example:
python tools/python/dev_mail_grant.py serve `
--game-data E:\\bd2\\dl\\GameData --game-data-version 20260923193640 `
--mail-seed go\\seed\\v2_35_10\\mail.json --output data\\dev\\mail-grants.json
--output data\\dev\\mail-grants-spool.json
python tools/python/dev_mail_grant.py grant `
--output data\\dev\\currency-grants.json --identity test-grant-1 `
@@ -72,6 +72,7 @@ MAIL_CURRENCY_TYPES = frozenset({2, 3, 4, 12, 20})
# ticket resources, rather than accepting arbitrary GameData item IDs.
MAIL_DRAW_TICKET_IDS = frozenset({1000, 1104})
MAIL_CONTENT_TICKET_ID = 450030
MAIL_ITEM_TYPES = frozenset({5, 7, 8, 9, 13, 14, 17, 27, 29})
def _varint(value: Any) -> int:
@@ -385,34 +386,6 @@ def load_items(root: Path, version: str) -> list[dict[str, Any]]:
temporary.unlink(missing_ok=True)
def load_seed(path: Path) -> dict[str, Any]:
try:
value = json.loads(path.read_text(encoding="utf-8"))
except OSError as exc:
raise ValueError(f"无法读取邮件种子 {path}: {exc}") from exc
except json.JSONDecodeError as exc:
raise ValueError(f"邮件种子不是 JSON: {exc}") from exc
if value.get("version") != VERSION or not isinstance(value.get("mails"), list):
raise ValueError(f"邮件种子必须是 version={VERSION} 且含 mails 数组")
ids: set[int] = set()
for entry in value["mails"]:
mail_id = entry.get("mail_id")
if not isinstance(mail_id, int) or mail_id <= 0 or mail_id in ids:
raise ValueError("邮件种子含零、非整数或重复的 mail_id")
ids.add(mail_id)
return value
def normalise_seed(seed: dict[str, Any]) -> dict[str, Any]:
"""Make the server sentinel fields agree with the complete mail list."""
result = dict(seed)
result["version"] = VERSION
result["mails"] = list(seed["mails"])
result["mail_count"] = len(result["mails"]) + 1
result["max_mail_id"] = max((entry["mail_id"] for entry in result["mails"]), default=0)
return result
def atomic_json(path: Path, value: dict[str, Any]) -> None:
path = path.resolve()
path.parent.mkdir(parents=True, exist_ok=True)
@@ -490,8 +463,8 @@ def _validate_reward(value: Any) -> dict[str, int]:
elif value["type"] == 19:
if value["id"] != MAIL_CONTENT_TICKET_ID or type(value["count"]) is not int or value["count"] != 1:
raise ValueError("内容券附件只允许满月甄选券 type19、id450030、count1")
elif value["type"] != 8 or value["id"] not in MAIL_DRAW_TICKET_IDS:
raise ValueError("附件只允许货币类型 2、3、4、12、20 的 id0,资源类型 8 的抽抽乐券 id1000、UR 专用装备抽抽乐券 id1104,或内容券 type19、id450030、count1")
elif value["type"] not in MAIL_ITEM_TYPES or value["id"] <= 0:
raise ValueError("物品附件必须使用已支持的 ItemDBInfo 类型和正数 id")
if type(value["count"]) is not int or not 1 <= value["count"] <= MAX_INT32:
raise ValueError(f"附件 count 必须是 1 到 {MAX_INT32} 的整数")
return dict(value)
@@ -502,7 +475,12 @@ def attachment(value: str) -> dict[str, int]:
parts = value.split(":")
if len(parts) != 3:
raise ValueError("附件格式必须是 TYPE:ID:COUNT")
return _validate_reward(dict(zip(("type", "id", "count"), map(int, parts))))
reward = _validate_reward(dict(zip(("type", "id", "count"), map(int, parts))))
if reward["type"] not in MAIL_CURRENCY_TYPES and not (
reward["type"] == 8 and reward["id"] in MAIL_DRAW_TICKET_IDS
) and not (reward["type"] == 19 and reward["id"] == MAIL_CONTENT_TICKET_ID):
raise ValueError("命令行附件只开放已审计的货币、抽抽乐券和满月甄选券")
return reward
except ValueError as exc:
raise argparse.ArgumentTypeError(str(exc)) from exc
@@ -565,18 +543,13 @@ def grant(args: argparse.Namespace) -> int:
class MailGrantStore:
def __init__(self, source: Path, output: Path, items: list[dict[str, Any]], expires_days: int):
self.source = source.resolve()
def __init__(self, output: Path, items: list[dict[str, Any]]):
self.output = output.resolve()
self.items = items
self.item_keys = {(item["element_type"], item["id"]) for item in items}
self.expires_days = expires_days
self.lock = threading.Lock()
self.seed = normalise_seed(load_seed(self.output if self.output.exists() else self.source))
# Write the complete baseline immediately. The game server can
# therefore begin watching --output before the first browser grant.
if not self.output.exists():
atomic_json(self.output, self.seed)
atomic_json(self.output, {"version": 1, "grants": []})
def grant(self, payload: Any) -> dict[str, Any]:
with self.lock:
@@ -600,28 +573,19 @@ class MailGrantStore:
if not title or len(title) > 500 or len(body) > 5000:
raise ValueError("标题不能为空且不超过 500 字符;正文不超过 5000 字符")
current_ids = {entry["mail_id"] for entry in self.seed["mails"]}
mail_id = max(current_ids, default=13_000_000_000) + 1
# MailDBInfo's InvenIndex is int64 in the 2.35.10 client descriptor.
if mail_id > (1 << 63) - 1:
raise ValueError("没有可用的正 int64 邮件 ID")
now = int(time.time() * 1000)
expires = now + self.expires_days * 24 * 60 * 60 * 1000
entry = {
"mail_id": mail_id,
"mail_type": 2,
entry = _validate_grant({
"identity": str(uuid.uuid4()),
"title": title,
"body": body,
"expires_at": expires,
"reward_types": [element_type],
"reward_ids": [item_id],
"reward_counts": [count],
"sent_at": now,
}
next_seed = normalise_seed({**self.seed, "mails": [*self.seed["mails"], entry]})
atomic_json(self.output, next_seed)
self.seed = next_seed
return {"mail": entry, "output": str(self.output), "restart_required": False}
"rewards": [{"type": element_type, "id": item_id, "count": count}],
})
with grant_file_lock(self.output):
value = load_grants(self.output)
value["grants"].append(entry)
atomic_json(self.output, value)
return {"grant": entry, "output": str(self.output), "restart_required": False}
class DevelopmentSettingsStore:
@@ -678,11 +642,11 @@ class DevelopmentSettingsStore:
PAGE = """<!doctype html><meta charset=utf-8><title>BD2 开发工具</title>
<style>body{font:14px system-ui;max-width:1060px;margin:2rem auto;padding:0 1rem}input,textarea,button{font:inherit;padding:.4rem}input{width:100%}input[type=checkbox]{width:auto;transform:scale(1.2);margin-right:.5rem}section{border-top:1px solid #ddd;margin-top:2rem;padding-top:1rem}table{border-collapse:collapse;width:100%;margin:0}th,td{border:1px solid #ccc;padding:.4rem;text-align:left}tr:hover{background:#f5f5f5}#status,#inventory-status{white-space:pre-wrap;margin:1rem 0}.small{color:#555}.pick{white-space:nowrap}#item-picker{margin:1rem 0;border:1px solid #ccc;border-radius:.35rem;padding:.55rem}#item-picker summary{cursor:pointer;font-weight:600}#item-picker[open] summary{margin-bottom:.75rem}.item-list{max-height:min(40vh,28rem);overflow:auto;border:1px solid #ccc;margin-top:1rem}.item-list thead th{position:sticky;top:0;background:#fff}.item-list table{min-width:760px}</style>
<h1>BD2 开发工具</h1><section><h2>开发邮件发放</h2><p class=small>只列出可由当前邮件链路直接领取的安全物品和货币。固定内容随机箱已映射成真实内容物;其他随机箱与“遗失物品”等内部哨兵不会显示。货币直接叠加到钱包。提交会原子写入临时邮件种子;重新打开或刷新游戏邮箱即可热载,无需重启服务端。</p>
<h1>BD2 开发工具</h1><section><h2>开发邮件发放</h2><p class=small>只列出可由当前邮件链路直接领取的安全物品和货币。固定内容随机箱已映射成真实内容物;其他随机箱与“遗失物品”等内部哨兵不会显示。货币直接叠加到钱包。提交会原子追加到动态邮件队列,由服务端唯一分配邮件 ID;重新打开或刷新游戏邮箱即可热载,无需重启服务端。</p>
<details id=item-picker><summary>选择开发测试物品 <span id=count class=small></span></summary><label>搜索(ID、名称、类别、固定箱映射)<input id=q></label><div class=item-list><table><thead><tr><th>ID</th><th>类型</th><th>名称</th><th>类别/内容</th><th></th></tr></thead><tbody id=items></tbody></table></div></details>
<h3>发放一个附件</h3><form id=form><label>物品 ID<input id=item_id required readonly></label><input id=element_type required readonly type=hidden><label>数量(1–2147483647)<input id=quantity type=number min=1 max=2147483647 value=1 required></label><label>邮件标题<input id=title value="开发测试物品" required maxlength=500></label><label>正文<textarea id=body maxlength=5000>由本地开发工具发放。</textarea></label><p><button>写入临时邮件种子</button></p></form><pre id=status></pre></section>
<h3>发放一个附件</h3><form id=form><label>物品 ID<input id=item_id required readonly></label><input id=element_type required readonly type=hidden><label>数量(1–2147483647)<input id=quantity type=number min=1 max=2147483647 value=1 required></label><label>邮件标题<input id=title value="开发测试物品" required maxlength=500></label><label>正文<textarea id=body maxlength=5000>由本地开发工具发放。</textarea></label><p><button id=grant-submit>加入动态邮件队列</button></p></form><pre id=status></pre></section>
<section><h2>背包容量</h2><label><input id=unlimited-inventory type=checkbox>无限背包容量</label><p id=inventory-limits class=small></p><p class=small>使用当前客户端 GameData 的安全上限,不写入账号存档。切换后无需重启服务端,但必须重新登录客户端才会生效。</p><pre id=inventory-status></pre></section>
<script>let all=[];const $=id=>document.getElementById(id);function render(){let q=$('q').value.toLowerCase();let matches=all.filter(x=>(x.id+' '+x.element_type+' '+x.name+' '+x.category+' '+(x.aliases||[]).join(' ')+' '+(x.details||'')).toLowerCase().includes(q));let rows=matches.slice(0,500);$('count').textContent=`(匹配 ${matches.length} / ${all.length} 项;显示前 ${rows.length} 项)`; $('items').innerHTML=rows.map(x=>`<tr><td>${x.id}</td><td>${x.element_type}</td><td>${esc(x.name)}</td><td>${esc(x.category+(x.details?':'+x.details:''))}</td><td class=pick><button onclick="pick(${x.element_type},${x.id})">选择</button></td></tr>`).join('')}function esc(s){let d=document.createElement('div');d.textContent=s;return d.innerHTML}function pick(t,id){$('item_id').value=id;$('element_type').value=t;$('item-picker').open=false;$('quantity').focus();$('form').scrollIntoView({block:'nearest',behavior:'smooth'})}function showSettings(x){$('unlimited-inventory').checked=x.inventory.unlimited;$('inventory-limits').textContent=`关闭时:道具 ${x.inventory.baseline.items}、装备 ${x.inventory.baseline.equipment};开启时:道具 ${x.inventory.enabled_limits.items}、装备 ${x.inventory.enabled_limits.equipment}`}$('q').oninput=render;$('form').onsubmit=async e=>{e.preventDefault();let r=await fetch('/api/grants',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({item_id:+$('item_id').value,element_type:+$('element_type').value,count:+$('quantity').value,title:$('title').value,body:$('body').value})});let x=await r.json();$('status').textContent=r.ok?`已写入邮件 #${x.mail.mail_id}。\n重新打开或刷新游戏邮箱即可看到并领取;服务端无需重启。\n货币会直接叠加,固定箱映射会直接发放内容物。\n热载文件:${x.output}`:x.error};$('unlimited-inventory').onchange=async e=>{let box=e.target,old=!box.checked;box.disabled=true;let r=await fetch('/api/settings/inventory',{method:'PUT',headers:{'content-type':'application/json'},body:JSON.stringify({unlimited:box.checked})});let x=await r.json();box.disabled=false;if(r.ok){showSettings(x);$('inventory-status').textContent='设置已保存。无需重启服务端;请重新登录客户端后生效。'}else{box.checked=old;$('inventory-status').textContent=x.error}};Promise.all([fetch('/api/items').then(r=>r.json()),fetch('/api/settings').then(r=>r.json())]).then(([x,s])=>{all=x.items;render();showSettings(s)});</script>"""
<script>let all=[],submitting=false;const $=id=>document.getElementById(id);function render(){let q=$('q').value.toLowerCase();let matches=all.filter(x=>(x.id+' '+x.element_type+' '+x.name+' '+x.category+' '+(x.aliases||[]).join(' ')+' '+(x.details||'')).toLowerCase().includes(q));let rows=matches.slice(0,500);$('count').textContent=`(匹配 ${matches.length} / ${all.length} 项;显示前 ${rows.length} 项)`; $('items').innerHTML=rows.map(x=>`<tr><td>${x.id}</td><td>${x.element_type}</td><td>${esc(x.name)}</td><td>${esc(x.category+(x.details?':'+x.details:''))}</td><td class=pick><button onclick="pick(${x.element_type},${x.id})">选择</button></td></tr>`).join('')}function esc(s){let d=document.createElement('div');d.textContent=s;return d.innerHTML}function pick(t,id){$('item_id').value=id;$('element_type').value=t;$('item-picker').open=false;$('quantity').focus();$('form').scrollIntoView({block:'nearest',behavior:'smooth'})}function showSettings(x){$('unlimited-inventory').checked=x.inventory.unlimited;$('inventory-limits').textContent=`关闭时:道具 ${x.inventory.baseline.items}、装备 ${x.inventory.baseline.equipment};开启时:道具 ${x.inventory.enabled_limits.items}、装备 ${x.inventory.enabled_limits.equipment}`}$('q').oninput=render;$('form').onsubmit=async e=>{e.preventDefault();if(submitting)return;submitting=true;$('grant-submit').disabled=true;let r=await fetch('/api/grants',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({item_id:+$('item_id').value,element_type:+$('element_type').value,count:+$('quantity').value,title:$('title').value,body:$('body').value})});let x=await r.json();$('status').textContent=r.ok?`已加入动态邮件队列,标识 ${x.grant.identity}。\n重新打开或刷新游戏邮箱即可看到并领取;服务端无需重启。\n货币会直接叠加,固定箱映射会直接发放内容物。\n队列文件:${x.output}`:x.error;submitting=false;$('grant-submit').disabled=false};$('unlimited-inventory').onchange=async e=>{let box=e.target,old=!box.checked;box.disabled=true;let r=await fetch('/api/settings/inventory',{method:'PUT',headers:{'content-type':'application/json'},body:JSON.stringify({unlimited:box.checked})});let x=await r.json();box.disabled=false;if(r.ok){showSettings(x);$('inventory-status').textContent='设置已保存。无需重启服务端;请重新登录客户端后生效。'}else{box.checked=old;$('inventory-status').textContent=x.error}};Promise.all([fetch('/api/items').then(r=>r.json()),fetch('/api/settings').then(r=>r.json())]).then(([x,s])=>{all=x.items;render();showSettings(s)});</script>"""
class Handler(BaseHTTPRequestHandler):
@@ -741,19 +705,17 @@ class Handler(BaseHTTPRequestHandler):
def serve(args: argparse.Namespace) -> int:
if args.expires_days < 1 or args.expires_days > 3650:
raise ValueError("--expires-days 必须是 1 到 3650")
items = load_items(args.game_data, args.game_data_version)
store = MailGrantStore(args.mail_seed, args.output, items, args.expires_days)
store = MailGrantStore(args.output, items)
settings = DevelopmentSettingsStore(args.settings_output, load_inventory_limits(args.game_data, args.game_data_version))
Handler.store = store
Handler.settings = settings
server = ThreadingHTTPServer((args.listen_host, args.listen_port), Handler)
print(f"已读取 {len(items)} 个可由 ItemDBInfo 领取的 GameData 物品。")
print(f"浏览器打开:http://{args.listen_host}:{args.listen_port}/")
print(f"临时邮件种子:{store.output}")
print(f"动态邮件队列:{store.output}")
print(f"开发工具配置:{settings.path}")
print("此服务不修改 data/state;bd2server 指向该 seed 后,每次 /MailInfo 自动热载。")
print("此服务不修改 data/state;bd2server 每次 /MailInfo 导入队列并唯一分配邮件 ID。")
try:
server.serve_forever()
except KeyboardInterrupt:
@@ -769,12 +731,10 @@ def parser() -> argparse.ArgumentParser:
command = commands.add_parser("serve", help="start the loopback browser UI")
command.add_argument("--game-data", type=Path, required=True, help="GameData root")
command.add_argument("--game-data-version", required=True, help="validated GameData version")
command.add_argument("--mail-seed", type=Path, required=True, help="base mail seed; read only")
command.add_argument("--output", type=Path, required=True, help="generated development mail seed")
command.add_argument("--output", type=Path, required=True, help="version=1 dynamic mail grant spool")
command.add_argument("--settings-output", type=Path, default=Path("data/dev/dev-tools.json"), help="development settings JSON")
command.add_argument("--listen-host", default="127.0.0.1", help="loopback host (default: 127.0.0.1)")
command.add_argument("--listen-port", default=8765, type=int, help="loopback port (default: 8765)")
command.add_argument("--expires-days", default=365, type=int, help="development mail validity (default: 365)")
command.set_defaults(run=serve)
command = commands.add_parser("grant", help="append one durable currency or draw ticket mail grant (standard library only)")
command.add_argument("--output", type=Path, required=True, help="version=1 development mail grants JSON")
+17 -30
View File
@@ -219,17 +219,11 @@ class DevelopmentMailGrantToolTests(unittest.TestCase):
def test_gold_currency_mail_uses_type_four_id_zero_and_requested_count(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
source = root / "mail.json"
output = root / "generated.json"
source.write_text(json.dumps({
"version": "2.35.10", "mails": [], "mail_count": 1, "max_mail_id": 0,
}), encoding="utf-8")
gold = {"id": 0, "element_type": 4, "name": "金币"}
store = dev_mail_grant.MailGrantStore(source, output, [gold], 365)
store = dev_mail_grant.MailGrantStore(output, [gold])
result = store.grant({"item_id": 0, "element_type": 4, "count": 123456789})
self.assertEqual(result["mail"]["reward_types"], [4])
self.assertEqual(result["mail"]["reward_ids"], [0])
self.assertEqual(result["mail"]["reward_counts"], [123456789])
self.assertEqual(result["grant"]["rewards"], [{"type": 4, "id": 0, "count": 123456789}])
def test_packed_varints_accepts_repeated_and_packed_fields(self):
self.assertEqual(dev_mail_grant.packed_varints({4: [3, b"\x80\x01\x02"]}, 4), [3, 128, 2])
@@ -278,40 +272,33 @@ class DevelopmentMailGrantToolTests(unittest.TestCase):
self.assertIn("重新登录客户端后生效", dev_mail_grant.PAGE)
self.assertIn("/api/settings/inventory", dev_mail_grant.PAGE)
def test_grant_writes_complete_seed_without_state_mutation(self):
def test_browser_grant_appends_spool_without_allocating_mail_id(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
source = root / "source-mail.json"
output = root / "generated-mail.json"
source.write_text(json.dumps({
"version": "2.35.10",
"mails": [{
"mail_id": 100, "mail_type": 2, "title": "base", "body": "base",
"expires_at": 200, "reward_types": [8], "reward_ids": [7],
"reward_counts": [1], "sent_at": 100,
}],
"mail_count": 2, "max_mail_id": 100,
}), encoding="utf-8")
original_source = source.read_text(encoding="utf-8")
store = dev_mail_grant.MailGrantStore(source, output, [{
output = root / "mail-grants-spool.json"
store = dev_mail_grant.MailGrantStore(output, [{
"id": 9, "element_type": 8, "name": "slime",
}], 365)
}])
self.assertTrue(output.is_file())
self.assertEqual(json.loads(output.read_text(encoding="utf-8"))["mail_count"], 2)
self.assertEqual(json.loads(output.read_text(encoding="utf-8")), {"version": 1, "grants": []})
result = store.grant({"item_id": 9, "element_type": 8, "count": 123, "title": "test", "body": "body"})
written = json.loads(output.read_text(encoding="utf-8"))
self.assertEqual(source.read_text(encoding="utf-8"), original_source)
self.assertEqual(written["mail_count"], 3)
self.assertEqual(written["max_mail_id"], 101)
self.assertEqual(written["mails"][-1]["reward_types"], [8])
self.assertEqual(written["mails"][-1]["reward_ids"], [9])
self.assertEqual(written["mails"][-1]["reward_counts"], [123])
self.assertEqual(written["version"], 1)
self.assertEqual(len(written["grants"]), 1)
self.assertEqual(written["grants"][0]["rewards"], [{"type": 8, "id": 9, "count": 123}])
self.assertNotIn("mail_id", written["grants"][0])
self.assertEqual(result["grant"], written["grants"][0])
self.assertFalse(result["restart_required"])
with self.assertRaises(ValueError):
store.grant({"item_id": 999, "element_type": 8, "count": 1})
with self.assertRaises(ValueError):
store.grant({"item_id": 9, "element_type": 8, "count": dev_mail_grant.MAX_INT32 + 1})
def test_browser_form_blocks_duplicate_submit_until_response(self):
self.assertIn("if(submitting)return", dev_mail_grant.PAGE)
self.assertIn("$('grant-submit').disabled=true", dev_mail_grant.PAGE)
self.assertIn("x.grant.identity", dev_mail_grant.PAGE)
class ClientSourceToolTests(unittest.TestCase):
def test_generated_tree_staging_uses_parent_inheriting_directory_mode(self):