feat: complete equipment, inventory, mail, and talent flows
This commit is contained in:
@@ -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
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user