feat: complete equipment, inventory, mail, and talent flows

This commit is contained in:
2026-09-25 01:36:49 +08:00
parent f94cfa1cf3
commit 7cb5e36c2a
33 changed files with 3309 additions and 93 deletions
+9 -5
View File
@@ -91,26 +91,30 @@ python .\tools\python\import_seed.py mail .\decoded\MailInfo.pb `
}
```
## 临时邮件物品发放
## BD2 开发工具
`dev_mail_grant.py` 是独立的、仅监听环回地址的开发期浏览器工具。它只读取指定版本的 GameData,列出已验证、可走 `ItemDBInfo` 领取路径的有名道具:`FoodTable`(类型 5)、`CookingTable`(7)、`ResourceTable`(8)、`QuestItemTable`(13)、`UseItemTable`(14)、`CollectionTable`(17)、`MyRoomItemTable`(27)和 `InstantUseItemTable`(29)。物品名按来源表引用的文本命名空间解析;确定性随机箱使用 `RandomBoxTextTable` 的真实名称作为内容物搜索别名,因此不依赖固定物品 ID。`ResourceTable.Type=2` 的场景/展示哨兵和无可用名称行不提供;固定内容随机箱只用于反查内容物的真实 ID,所有 type 9 随机箱均不作为邮件选项。另提供单一金币货币条目 `type4/id0`,填写的数量在领取后直接叠加至钱包,不再发送“金币随机箱”。工具本身既不属于 `bd2server.exe`,也不修改 `data/state` 的九份账号状态。
`dev_mail_grant.py` 是独立的、仅监听环回地址的开发期浏览器工具。它只读取指定版本的 GameData,列出已验证、可走 `ItemDBInfo` 领取路径的有名道具:`FoodTable`(类型 5)、`CookingTable`(7)、`ResourceTable`(8)、`QuestItemTable`(13)、`UseItemTable`(14)、`CollectionTable`(17)、`MyRoomItemTable`(27)和 `InstantUseItemTable`(29)。普通物品的 `ItemNameTextId` 按客户端行为从 `NameTextTable` 解析;随机箱使用独立的 `RandomBoxTextTable`,现金商品搜索别名仍从其声明的 `LocalTextTable` 解析,三者不会依靠碰巧相同的数字文本 ID 串表。确定性随机箱使用真实名称作为内容物搜索别名,因此不依赖固定物品 ID。`ResourceTable.Type=2` 的场景/展示哨兵和无可用名称行不提供;固定内容随机箱只用于反查内容物的真实 ID,所有 type 9 随机箱均不作为邮件选项。金币和天赋神药等服务端已实现的账户货币从当前 `CurrencyTable` 发现,以各自元素类型和 `id0` 直接入账;名称与物品 ID 均不硬编码。工具本身既不属于 `bd2server.exe`,也不修改 `data/state` 的九份账号状态。
启动工具时,`--mail-seed` 是只读的当前基础邮件种子;`--output` 是新生成的完整临时种子。工具启动后在浏览器打开 `http://127.0.0.1:8765/`:
启动工具时,`--mail-seed` 是只读的当前基础邮件种子;`--output` 是新生成的完整临时种子;`--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 "20260910162539" `
--game-data-version "20260923193640" `
--mail-seed .\go\seed\v2_34_13\mail.json `
--output .\data\dev\mail-grants.json
--output .\data\dev\mail-grants.json `
--settings-output .\data\dev\dev-tools.json
```
工具启动时立即原子写出规范化的完整 `--output`(尚未发放也一样),因此首次启用时可先启动工具、再让本地服务端监听这个输出文件。每次发放同样原子更新该文件。`bd2server` 的邮件服务会在下一次正常 `/MailInfo` 请求检查它:网页发放后重新打开或刷新游戏邮箱即可看到新邮件,**不需要每次重启服务器**。服务仅在启动时需要加入(或替换为)以下参数:
```powershell
--mail-seed ".\data\dev\mail-grants.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。
这不是“所有 GameData 表都可发放”的虚假承诺:角色(元素类型 6)、装备(10)、服装(11)和我的房间奖杯(28)在客户端 `RewardDBInfoBundle` 中分别必须使用 `CharDBInfo`、`EquipDBInfo`、`CostumeDBInfo`、`MyRoomTrophyDBInfo`,而当前本地邮件服务尚未连接相应领域存档,工具不会提供它们;直接伪装成 `ItemDBInfo` 会造成客户端状态错误。付费/普通货币之外的特殊货币亦不在当前本地钱包实现范围内。客户端 `DataManager.GetItemDTO` 对 `ContentTicket`(19)和 `LobbySettingItem`(25)没有可用于 `ItemDBInfo` 领取的 DTO 分支,故也没有提供;`GetItemInfo` 的显示分支不足以证明可安全存储。若要补齐这些类型,需要先实现对应的服务端存储、去重及正确 reward-bundle 字段,不需要客户端 patch。
+184 -66
View File
@@ -1,11 +1,9 @@
#!/usr/bin/env python3
"""Local-only browser tool for adding ItemDBInfo-compatible GameData items to mail.
"""Local-only BD2 development browser tool.
This program deliberately is not part of bd2server. It reads the selected
2.34.13 GameData archive and writes a complete replacement mail seed using an
atomic rename. Start bd2server once with --mail-seed pointing at the same
--output; subsequent grants are hot-loaded by the normal /MailInfo request.
It never reads or changes data/state.
It provides development mail grants and loopback-only runtime settings without
reading or changing account state. Files are replaced atomically and consumed
by an explicitly configured local bd2server.
Example:
python tools/python/dev_mail_grant.py serve `
@@ -37,22 +35,27 @@ from gamedata_db import read_database, walk_wire # noqa: E402
VERSION = "2.34.13"
MAX_INT32 = (1 << 31) - 1
# These are the local server's ItemDBInfo-backed ElementTypes. The mapping is
# checked against 2.34.13 DataManager.GetItemInfo; characters, equipment,
# costumes, and trophies use separate RewardDBInfoBundle fields and are not
# falsely offered by this tool.
# These are the local server's ItemDBInfo-backed ElementTypes. Item names use
# distinct GameData text namespaces, so the owning namespace is part of this
# data-driven mapping instead of being guessed from numeric text IDs.
ITEM_SOURCES = (
("ResourceTable", 8, 4, 7, "资源"),
("FoodTable", 5, 7, 10, "料理"),
("CookingTable", 7, 3, 11, "烹饪配方"),
("RandomBoxTable", 9, 4, 7, "随机箱"),
("QuestItemTable", 13, 2, 5, "任务物品"),
("UseItemTable", 14, 3, 6, "使用物品"),
("CollectionTable", 17, 3, 7, "收藏品"),
("MyRoomItemTable", 27, 7, 17, "我的房间物品"),
("InstantUseItemTable", 29, 1, 3, "即时使用物品"),
("ResourceTable", 8, 4, 7, "资源", "NameTextTable"),
("FoodTable", 5, 7, 10, "料理", "NameTextTable"),
("CookingTable", 7, 3, 11, "烹饪配方", "NameTextTable"),
("RandomBoxTable", 9, 4, 7, "随机箱", "RandomBoxTextTable"),
("QuestItemTable", 13, 2, 5, "任务物品", "NameTextTable"),
("UseItemTable", 14, 3, 6, "使用物品", "NameTextTable"),
("CollectionTable", 17, 3, 7, "收藏品", "NameTextTable"),
("MyRoomItemTable", 27, 7, 17, "我的房间物品", "NameTextTable"),
("InstantUseItemTable", 29, 1, 3, "即时使用物品", "NameTextTable"),
)
# CurrencyTable.id is the EElementType. Only currencies whose durable wallet
# and mail-claim path are implemented by this server are offered. Their mail
# reward ID is zero; names still come from the current GameData rather than
# being embedded here.
MAIL_CURRENCY_TYPES = frozenset({3, 4, 12})
def _varint(value: Any) -> int:
if not isinstance(value, int) or value < 0:
@@ -144,6 +147,76 @@ def _localized_names(connection: sqlite3.Connection, table: str) -> dict[int, st
return result
def _static_items(connection: sqlite3.Connection) -> list[dict[str, Any]]:
"""Load ItemDBInfo tables through their declared text namespaces."""
text_tables = {source[5] for source in ITEM_SOURCES}
localized_names = {
table: _localized_names(connection, table)
for table in text_tables
}
items: list[dict[str, Any]] = []
for table, element_type, id_field, name_field, category, text_table in ITEM_SOURCES:
text_names = localized_names[text_table]
for row_id, proto in connection.execute(f'SELECT id, ProtoBuf FROM "{table}" ORDER BY id'):
decoded = fields(proto)
item_id = first_varint(decoded, id_field) or int(row_id)
name_text_id = first_varint(decoded, name_field)
items.append({
"id": item_id,
"element_type": element_type,
"name": text_names.get(name_text_id, f"<未找到本地化文本 #{name_text_id}>"),
"category": category,
"source_table": table,
"name_text_id": name_text_id,
"resource_type": first_varint(decoded, 13) if table == "ResourceTable" else None,
})
return items
def _mail_currencies(connection: sqlite3.Connection) -> list[dict[str, Any]]:
"""Load server-supported account currencies from CurrencyTable."""
names = _localized_names(connection, "NameTextTable")
result: list[dict[str, Any]] = []
for row_id, proto in connection.execute('SELECT id, ProtoBuf FROM "CurrencyTable" ORDER BY id'):
decoded = fields(proto)
element_type = first_varint(decoded, 3) or int(row_id)
if element_type not in MAIL_CURRENCY_TYPES:
continue
name_text_id = first_varint(decoded, 5)
result.append({
"id": 0,
"element_type": element_type,
"name": names.get(name_text_id, f"<未找到本地化文本 #{name_text_id}>"),
"category": "货币(直接入账)",
"source_table": "CurrencyTable",
"name_text_id": name_text_id,
"resource_type": None,
"details": "邮件领取后直接叠加到账户余额,不生成背包物品或随机箱",
})
return result
def load_inventory_limits(root: Path, version: str) -> dict[str, dict[str, int]]:
connection, temporary = open_readonly_database(root, version)
try:
row = connection.execute('SELECT ProtoBuf FROM "GameDefaultTable" WHERE id=0').fetchone()
if row is None:
raise ValueError("GameDefaultTable[0] 不存在")
decoded = fields(row[0])
result = {
"baseline": {"items": first_varint(decoded, 34), "equipment": first_varint(decoded, 30)},
"enabled_limits": {"items": first_varint(decoded, 80), "equipment": first_varint(decoded, 73)},
}
if any(value <= 0 for group in result.values() for value in group.values()):
raise ValueError("GameData 背包容量配置无效")
if result["baseline"]["items"] > result["enabled_limits"]["items"] or result["baseline"]["equipment"] > result["enabled_limits"]["equipment"]:
raise ValueError("GameData 背包默认容量超过最大值")
return result
finally:
connection.close()
temporary.unlink(missing_ok=True)
def _safe_direct_mail_item(item: dict[str, Any]) -> bool:
# RandomBox requires a second protocol and its entered count is not the
# final reward count, so this direct-mail form never exposes type 9.
@@ -231,42 +304,13 @@ def load_items(root: Path, version: str) -> list[dict[str, Any]]:
"""Return every safe ItemDBInfo-backed static item, with Chinese names."""
connection, temporary = open_readonly_database(root, version)
try:
# Most inventory tables refer to LocalTextTable. RandomBoxTable owns a
# separate RandomBoxTextTable namespace with the same localized-text
# protobuf shape; resolving it through LocalTextTable silently loses
# material names when numeric text IDs do not overlap.
names = _localized_names(connection, "LocalTextTable")
random_box_names = _localized_names(connection, "RandomBoxTextTable")
items: list[dict[str, Any]] = []
for table, element_type, id_field, name_field, category in ITEM_SOURCES:
text_names = random_box_names if table == "RandomBoxTable" else names
for row_id, proto in connection.execute(f"SELECT id, ProtoBuf FROM {table} ORDER BY id"):
decoded = fields(proto)
item_id = first_varint(decoded, id_field) or int(row_id)
name_text_id = first_varint(decoded, name_field)
items.append({
"id": item_id,
"element_type": element_type,
"name": text_names.get(name_text_id, f"<未找到本地化文本 #{name_text_id}>"),
"category": category,
"source_table": table,
"name_text_id": name_text_id,
"resource_type": first_varint(decoded, 13) if table == "ResourceTable" else None,
})
# Currency is not an ItemDBInfo row. MailOpen and RewardDBInfoBundle
# represent it as type=Gold(4), id=0 and the entered count, which the
# wallet atomically adds to the existing balance.
items.append({
"id": 0,
"element_type": 4,
"name": "金币",
"category": "货币(直接入账)",
"source_table": "Currency",
"name_text_id": 0,
"resource_type": None,
"details": "邮件领取后直接叠加到金币余额,不生成背包物品或随机箱",
})
items = _static_items(connection)
# CashProductTable.ProductLocalTextId is intentionally a LocalTextTable
# reference, unlike the ItemNameTextId fields handled above.
local_names = _localized_names(connection, "LocalTextTable")
# Supported account currencies are discovered from CurrencyTable. The
# mail protocol identifies them by ElementType with reward ID zero.
items.extend(_mail_currencies(connection))
if not items:
raise ValueError("可领取的 ItemDBInfo 静态表为空")
@@ -288,7 +332,7 @@ def load_items(root: Path, version: str) -> list[dict[str, Any]]:
groups[int(group_id)] = None
continue
reward_id, reward_type, reward_count = reward_ids[0], reward_types[0], reward_counts[0]
valid_id = reward_id == 0 if reward_type in {3, 4} else reward_id != 0
valid_id = reward_id == 0 if reward_type in MAIL_CURRENCY_TYPES else reward_id != 0
groups[int(group_id)] = (reward_type, reward_id, reward_count) if reward_type and valid_id and reward_count else None
fixed_boxes: dict[int, tuple[int, int, int]] = {}
@@ -309,7 +353,7 @@ def load_items(root: Path, version: str) -> list[dict[str, Any]]:
decoded = fields(proto)
box_id = first_varint(decoded, 14)
product_text_id = first_varint(decoded, 11)
product_name = names.get(product_text_id, "")
product_name = local_names.get(product_text_id, "")
if box_id and product_name and box_id in fixed_boxes:
aliases = product_aliases.setdefault(box_id, {})
aliases[product_name] = aliases.get(product_name, 0) + 1
@@ -427,22 +471,78 @@ class MailGrantStore:
return {"mail": entry, "output": str(self.output), "restart_required": False}
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%}table{border-collapse:collapse;width:100%;margin:0}th,td{border:1px solid #ccc;padding:.4rem;text-align:left}tr:hover{background:#f5f5f5}#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><p class=small>只列出可由当前邮件链路直接领取的安全物品和货币。固定内容随机箱已映射成真实内容物;其他随机箱与“遗失物品”等内部哨兵不会显示。金币直接叠加到钱包。提交会原子写入临时邮件种子;重新打开或刷新游戏邮箱即可热载,无需重启服务端。</p>
class DevelopmentSettingsStore:
def __init__(self, path: Path, limits: dict[str, dict[str, int]]):
self.path = path.resolve()
self.limits = limits
self.lock = threading.Lock()
if self.path.exists():
self.settings = self._load()
else:
self.settings = {"version": 1, "inventory": {"unlimited": False}}
atomic_json(self.path, self.settings)
@staticmethod
def _validate(value: Any) -> dict[str, Any]:
if not isinstance(value, dict) or set(value) != {"version", "inventory"} or value.get("version") != 1:
raise ValueError("开发工具配置必须是 version=1 的严格对象")
inventory = value.get("inventory")
if not isinstance(inventory, dict) or set(inventory) != {"unlimited"} or type(inventory.get("unlimited")) is not bool:
raise ValueError("inventory.unlimited 必须是布尔值且不能包含额外字段")
return {"version": 1, "inventory": {"unlimited": inventory["unlimited"]}}
def _load(self) -> dict[str, Any]:
try:
return self._validate(json.loads(self.path.read_text(encoding="utf-8")))
except OSError as exc:
raise ValueError(f"无法读取开发工具配置 {self.path}: {exc}") from exc
except json.JSONDecodeError as exc:
raise ValueError(f"开发工具配置不是 JSON: {exc}") from exc
def snapshot(self) -> dict[str, Any]:
with self.lock:
return self._snapshot_locked()
def _snapshot_locked(self) -> dict[str, Any]:
return {
"inventory": {
"unlimited": self.settings["inventory"]["unlimited"],
**self.limits,
"effective_after": "next_login",
},
"output": str(self.path),
}
def set_inventory(self, payload: Any) -> dict[str, Any]:
if not isinstance(payload, dict) or set(payload) != {"unlimited"} or type(payload.get("unlimited")) is not bool:
raise ValueError("请求必须只包含布尔字段 unlimited")
with self.lock:
next_settings = {"version": 1, "inventory": {"unlimited": payload["unlimited"]}}
atomic_json(self.path, next_settings)
self.settings = next_settings
return self._snapshot_locked()
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>
<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>
<h2>发放一个附件</h2><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>
<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.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'})}$('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};fetch('/api/items').then(r=>r.json()).then(x=>{all=x.items;render()});</script>"""
<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>
<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>"""
class Handler(BaseHTTPRequestHandler):
store: MailGrantStore
settings: DevelopmentSettingsStore
def do_GET(self) -> None: # noqa: N802
if self.path == "/":
self.reply(HTTPStatus.OK, "text/html; charset=utf-8", PAGE.encode())
elif self.path == "/api/items":
self.reply_json(HTTPStatus.OK, {"items": self.store.items})
elif self.path == "/api/settings":
self.reply_json(HTTPStatus.OK, self.settings.snapshot())
else:
self.reply_json(HTTPStatus.NOT_FOUND, {"error": "not found"})
@@ -459,10 +559,24 @@ class Handler(BaseHTTPRequestHandler):
except (OSError, ValueError, json.JSONDecodeError) as exc:
self.reply_json(HTTPStatus.BAD_REQUEST, {"error": str(exc)})
def do_PUT(self) -> None: # noqa: N802
if self.path != "/api/settings/inventory":
self.reply_json(HTTPStatus.NOT_FOUND, {"error": "not found"})
return
try:
length = int(self.headers.get("Content-Length", "0"))
if length <= 0 or length > 1024:
raise ValueError("请求体大小无效")
result = self.settings.set_inventory(json.loads(self.rfile.read(length)))
self.reply_json(HTTPStatus.OK, result)
except (OSError, ValueError, json.JSONDecodeError) as exc:
self.reply_json(HTTPStatus.BAD_REQUEST, {"error": str(exc)})
def reply(self, status: HTTPStatus, content_type: str, body: bytes) -> None:
self.send_response(status)
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(len(body)))
self.send_header("Cache-Control", "no-store")
self.end_headers()
self.wfile.write(body)
@@ -470,7 +584,7 @@ class Handler(BaseHTTPRequestHandler):
self.reply(status, "application/json; charset=utf-8", json.dumps(value, ensure_ascii=False).encode())
def log_message(self, format: str, *args: object) -> None:
print("dev-mail:", format % args)
print("dev-tools:", format % args)
def serve(args: argparse.Namespace) -> int:
@@ -478,16 +592,19 @@ def serve(args: argparse.Namespace) -> int:
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)
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"开发工具配置:{settings.path}")
print("此服务不修改 data/state;bd2server 指向该 seed 后,每次 /MailInfo 自动热载。")
try:
server.serve_forever()
except KeyboardInterrupt:
print("\n开发邮件工具已停止。")
print("\nBD2 开发工具已停止。")
finally:
server.server_close()
return 0
@@ -501,6 +618,7 @@ def parser() -> argparse.ArgumentParser:
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("--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)")
@@ -512,10 +630,10 @@ def main() -> int:
args = parser().parse_args()
try:
if args.listen_host not in {"127.0.0.1", "localhost", "::1"}:
raise ValueError("开发邮件服务只允许监听本机回环地址")
raise ValueError("开发工具只允许监听本机回环地址")
return args.run(args)
except (OSError, ValueError, sqlite3.Error) as exc:
print(f"dev_mail_grant: {exc}", file=sys.stderr)
print(f"dev_tools: {exc}", file=sys.stderr)
return 1
+91 -1
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
import base64
import inspect
import json
from pathlib import Path
import sys
@@ -151,6 +152,63 @@ class DevelopmentMailGrantToolTests(unittest.TestCase):
self.assertEqual(dev_mail_grant._localized_names(connection, "RandomBoxTextTable"), {7: "精炼粉末"})
connection.close()
def test_static_items_use_each_sources_declared_text_namespace(self):
import sqlite3
def text_proto(text_id: int, value: str) -> bytes:
return import_seed.encode_field(2, 0, text_id) + import_seed.encode_field(4, 2, value.encode("utf-8"))
connection = sqlite3.connect(":memory:")
for table in {source[0] for source in dev_mail_grant.ITEM_SOURCES}:
connection.execute(f'CREATE TABLE "{table}" (id INTEGER, ProtoBuf BLOB)')
for table in {source[5] for source in dev_mail_grant.ITEM_SOURCES}:
connection.execute(f'CREATE TABLE "{table}" (id INTEGER, ProtoBuf BLOB)')
connection.execute("INSERT INTO NameTextTable VALUES (?, ?)", (32401, text_proto(32401, "普通布料")))
connection.execute("INSERT INTO NameTextTable VALUES (?, ?)", (32501, text_proto(32501, "原木")))
connection.execute("INSERT INTO RandomBoxTextTable VALUES (?, ?)", (77, text_proto(77, "材料选择箱")))
connection.execute(
"INSERT INTO ResourceTable VALUES (?, ?)",
(401, import_seed.encode_field(4, 0, 401) + import_seed.encode_field(7, 0, 32401)),
)
connection.execute(
"INSERT INTO ResourceTable VALUES (?, ?)",
(501, import_seed.encode_field(4, 0, 501) + import_seed.encode_field(7, 0, 32501)),
)
connection.execute(
"INSERT INTO RandomBoxTable VALUES (?, ?)",
(700, import_seed.encode_field(4, 0, 700) + import_seed.encode_field(7, 0, 77)),
)
items = dev_mail_grant._static_items(connection)
by_key = {(item["element_type"], item["id"]): item["name"] for item in items}
self.assertEqual(by_key[(8, 401)], "普通布料")
self.assertEqual(by_key[(8, 501)], "原木")
self.assertEqual(by_key[(9, 700)], "材料选择箱")
connection.close()
def test_mail_currencies_are_discovered_from_currency_table(self):
import sqlite3
def proto_field(number: int, value: int) -> bytes:
return import_seed.encode_field(number, 0, value)
connection = sqlite3.connect(":memory:")
connection.execute("CREATE TABLE NameTextTable (id INTEGER, ProtoBuf BLOB)")
connection.execute("CREATE TABLE CurrencyTable (id INTEGER, ProtoBuf BLOB)")
for text_id, value in ((264, "天赋神药"), (999, "未支持货币")):
encoded = value.encode("utf-8")
connection.execute(
"INSERT INTO NameTextTable VALUES (?, ?)",
(text_id, proto_field(2, text_id) + import_seed.encode_field(4, 2, encoded)),
)
connection.execute("INSERT INTO CurrencyTable VALUES (?, ?)", (12, proto_field(3, 12) + proto_field(5, 264)))
connection.execute("INSERT INTO CurrencyTable VALUES (?, ?)", (99, proto_field(3, 99) + proto_field(5, 999)))
currencies = dev_mail_grant._mail_currencies(connection)
self.assertEqual([(item["element_type"], item["id"], item["name"]) for item in currencies], [(12, 0, "天赋神药")])
connection.close()
def test_internal_lost_resource_is_not_mail_safe(self):
self.assertFalse(dev_mail_grant._safe_direct_mail_item({
"id": 90045, "element_type": 8, "name": "金币遗失物品",
@@ -185,7 +243,39 @@ class DevelopmentMailGrantToolTests(unittest.TestCase):
self.assertIn("$('item-picker').open=false", dev_mail_grant.PAGE)
def test_picker_search_includes_random_box_aliases(self):
self.assertIn("x.category+' '+(x.details||'')", dev_mail_grant.PAGE)
self.assertIn("(x.aliases||[]).join(' ')", dev_mail_grant.PAGE)
def test_mail_tool_disables_browser_response_caching(self):
self.assertIn('self.send_header("Cache-Control", "no-store")', inspect.getsource(dev_mail_grant.Handler.reply))
def test_development_settings_are_strict_and_atomic(self):
limits = {
"baseline": {"items": 100, "equipment": 500},
"enabled_limits": {"items": 500, "equipment": 2000},
}
with tempfile.TemporaryDirectory() as temporary:
path = Path(temporary) / "dev-tools.json"
store = dev_mail_grant.DevelopmentSettingsStore(path, limits)
self.assertFalse(store.snapshot()["inventory"]["unlimited"])
updated = store.set_inventory({"unlimited": True})
self.assertTrue(updated["inventory"]["unlimited"])
self.assertEqual(json.loads(path.read_text(encoding="utf-8")), {
"version": 1, "inventory": {"unlimited": True},
})
reopened = dev_mail_grant.DevelopmentSettingsStore(path, limits)
self.assertTrue(reopened.snapshot()["inventory"]["unlimited"])
for invalid in ({"unlimited": 1}, {"unlimited": "true"}, {"unlimited": True, "extra": 1}, {}):
with self.assertRaises(ValueError):
store.set_inventory(invalid)
path.write_text('{"version":1,"inventory":{"unlimited":true},"extra":1}', encoding="utf-8")
with self.assertRaises(ValueError):
dev_mail_grant.DevelopmentSettingsStore(path, limits)
def test_development_tool_page_places_inventory_after_mail(self):
self.assertIn("<title>BD2 开发工具</title>", dev_mail_grant.PAGE)
self.assertLess(dev_mail_grant.PAGE.index("开发邮件发放"), dev_mail_grant.PAGE.index("无限背包容量"))
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):
with tempfile.TemporaryDirectory() as temporary: