Files
bd2/tools/python/dev_mail_grant.py

762 lines
40 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""Local-only BD2 development mail and browser tool.
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 `
--game-data E:\\bd2\\dl\\GameData --game-data-version 20260923193640 `
--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 `
--attachment 4:0:10000 --attachment 3:0:100
"""
from __future__ import annotations
import argparse
from contextlib import contextmanager
import errno
import html
import json
import os
from pathlib import Path
import sqlite3
import sys
import tempfile
import threading
import time
import uuid
from http import HTTPStatus
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from typing import Any
# The grant command requires only the Python standard library. GameData's
# optional decryptor dependency is imported only by the browser's data readers.
sys.path.insert(0, str(Path(__file__).resolve().parent))
def _gamedata():
import gamedata_db
return gamedata_db
VERSION = "2.35.10"
MAX_INT32 = (1 << 31) - 1
# 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, "资源", "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({2, 3, 4, 12, 20})
# The standalone grant CLI intentionally offers only the two audited draw
# 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:
if not isinstance(value, int) or value < 0:
raise ValueError("expected a non-negative protobuf varint")
return value
def fields(proto: bytes) -> dict[int, list[Any]]:
result: dict[int, list[Any]] = {}
for number, wire_type, value in _gamedata().walk_wire(proto):
if wire_type != 0 and wire_type != 2:
continue
result.setdefault(number, []).append(value)
return result
def first_varint(values: dict[int, list[Any]], number: int) -> int:
entries = values.get(number, [])
if len(entries) != 1:
return 0
return _varint(entries[0])
def first_text(values: dict[int, list[Any]], number: int) -> str:
entries = values.get(number, [])
if len(entries) != 1 or not isinstance(entries[0], bytes):
return ""
return entries[0].decode("utf-8")
def packed_varints(values: dict[int, list[Any]], number: int) -> list[int]:
"""Decode proto3 packed/repeated uint fields without guessing their shape."""
result: list[int] = []
for entry in values.get(number, []):
if isinstance(entry, int):
result.append(_varint(entry))
continue
if not isinstance(entry, bytes):
raise ValueError(f"field {number} is not a protobuf varint")
offset = 0
while offset < len(entry):
value = 0
for shift in range(0, 70, 7):
if offset >= len(entry):
raise ValueError(f"truncated packed protobuf field {number}")
byte = entry[offset]
offset += 1
value |= (byte & 0x7f) << shift
if byte < 0x80:
result.append(value)
break
else:
raise ValueError(f"oversized packed protobuf field {number}")
return result
def open_readonly_database(root: Path, version: str) -> tuple[sqlite3.Connection, Path]:
"""Open the current common database in a private read-only SQLite file."""
plain = _gamedata().read_database(root, version, "quest")
handle = tempfile.NamedTemporaryFile(prefix="bd2-dev-mail-", suffix=".db", delete=False)
path = Path(handle.name)
try:
handle.write(plain)
handle.flush()
finally:
handle.close()
try:
connection = sqlite3.connect(path.resolve().as_uri() + "?mode=ro", uri=True)
connection.execute("PRAGMA query_only=ON")
return connection, path
except Exception:
path.unlink(missing_ok=True)
raise
def _usable_display_name(value: str) -> bool:
return bool(value) and not value.startswith("<未找到本地化文本 #") and value not in {"(不使用)", "(不使用)"}
def _localized_names(connection: sqlite3.Connection, table: str) -> dict[int, str]:
"""Read the common localized-text protobuf shape from its owning table."""
result: dict[int, str] = {}
for row_id, proto in connection.execute(f'SELECT id, ProtoBuf FROM "{table}"'):
decoded = fields(proto)
text_id = first_varint(decoded, 2) or int(row_id)
name = first_text(decoded, 4) or first_text(decoded, 5) or first_text(decoded, 3)
if name:
result[text_id] = name
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.
if item["element_type"] == 9:
return False
# ResourceTable Type=2 rows are field-object presentation sentinels, not
# inventory materials. 90045/90046 even point at costume IDs for their
# field popup and crash ItemInfoPopupUI when presented as normal resources.
if item.get("source_table") == "ResourceTable" and item.get("resource_type") == 2:
return False
return _usable_display_name(item["name"])
def map_fixed_boxes_to_direct_items(
items: list[dict[str, Any]],
fixed_boxes: dict[int, tuple[int, int, int]],
product_aliases: dict[int, dict[str, int]],
) -> list[dict[str, Any]]:
"""Hide deterministic boxes and expose their contained ItemDBInfo directly.
The quantity entered in the development form is the final material count;
the source box multiplier is deliberately informational and is not applied.
"""
by_key = {(item["element_type"], item["id"]): item for item in items}
target_aliases: dict[tuple[int, int], dict[str, int]] = {}
box_aliases: dict[tuple[int, int], dict[str, int]] = {}
source_boxes: dict[tuple[int, int], list[tuple[int, int]]] = {}
for box_id, (reward_type, reward_id, reward_count) in fixed_boxes.items():
target_key = (reward_type, reward_id)
target = by_key.get(target_key)
box = by_key.get((9, box_id))
if target is None or box is None:
continue
source_boxes.setdefault(target_key, []).append((box_id, reward_count))
if _usable_display_name(box["name"]):
aliases = box_aliases.setdefault(target_key, {})
aliases[box["name"]] = aliases.get(box["name"], 0) + 1
aliases = target_aliases.setdefault(target_key, {})
for alias, frequency in product_aliases.get(box_id, {}).items():
if _usable_display_name(alias):
aliases[alias] = aliases.get(alias, 0) + frequency
for target_key, boxes in source_boxes.items():
target = by_key[target_key]
original_name = target["name"]
aliases = target_aliases.get(target_key, {})
canonical = max(aliases, key=lambda value: (aliases[value], -len(value), value)) if aliases else ""
box_names = box_aliases.get(target_key, {})
# When every named deterministic wrapper agrees on one material name,
# that name is the most specific GameData label for an otherwise valid
# generic resource row. Keep the generic table name searchable as an
# alias. If the resource itself has no usable name, retain the product
# fallback below because a lone wrapper can still have a generic label.
box_name = next(iter(box_names)) if len(box_names) == 1 else ""
if _usable_display_name(original_name) and box_name and box_name != original_name:
target["name"] = box_name
elif not _usable_display_name(original_name):
if canonical:
target["name"] = canonical
# Deterministic RandomBox names come from RandomBoxTextTable and describe
# the actual contained material. Product labels remain a secondary
# fallback because some products describe expiry/conversion behavior.
target["aliases"] = []
for value in (original_name, box_name, canonical):
if _usable_display_name(value) and value != target["name"] and value not in target["aliases"]:
target["aliases"].append(value)
preview = "、".join(str(box_id) for box_id, _ in boxes[:4])
if len(boxes) > 4:
preview += f" 等 {len(boxes)} 个"
details = ["开发邮件直接发放此物品(无需开箱)"]
if original_name != target["name"] and _usable_display_name(original_name):
details.append("原始资源名:" + original_name)
if box_name and box_name != target["name"]:
details.append("确定性箱名称:" + box_name)
if target["aliases"]:
if canonical and canonical != target["name"]:
details.append("商品名:" + canonical)
details.append("固定箱映射:" + preview)
target["details"] = ";".join(details)
return [item for item in items if _safe_direct_mail_item(item)]
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:
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 静态表为空")
# A RandomBox is itself a legitimate ItemDBInfo attachment, but its
# visible name is often a generic box name while players search for a
# guaranteed material inside it (for example 女神之泪). Follow only
# one-entry RewardGroupTable definitions: that is a real, deterministic
# GameData relationship, not an invented unpack result. Cash-product
# display names are aliases as well, so names such as 光明圣石 which are
# used by a product but not by the ResourceTable row remain searchable.
by_key = {(item["element_type"], item["id"]): item for item in items}
groups: dict[int, tuple[int, int, int] | None] = {}
for group_id, proto in connection.execute("SELECT id, ProtoBuf FROM RewardGroupTable"):
decoded = fields(proto)
reward_ids = packed_varints(decoded, 5)
reward_types = packed_varints(decoded, 6)
reward_counts = packed_varints(decoded, 4)
if len(reward_ids) != 1 or len(reward_types) != 1 or len(reward_counts) != 1:
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 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]] = {}
for box_id, proto in connection.execute("SELECT id, ProtoBuf FROM RandomBoxTable"):
decoded = fields(proto)
reward_group_id = first_varint(decoded, 9)
reward = groups.get(reward_group_id)
if reward is None:
continue
reward_type, reward_id, reward_count = reward
contained = by_key.get((reward_type, reward_id))
if contained is None:
continue
fixed_boxes[int(box_id)] = (reward_type, reward_id, reward_count)
product_aliases: dict[int, dict[str, int]] = {}
for (proto,) in connection.execute("SELECT ProtoBuf FROM CashProductTable"):
decoded = fields(proto)
box_id = first_varint(decoded, 14)
product_text_id = first_varint(decoded, 11)
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
# A deterministic type-9 wrapper is unsuitable for this developer
# mailbox: the user wants the material count they entered, immediately
# usable after MailOpen. Replace such choices with their authoritative
# contained ItemDBInfo rather than requiring /UseRandomBox afterwards.
return map_fixed_boxes_to_direct_items(items, fixed_boxes, product_aliases)
finally:
connection.close()
temporary.unlink(missing_ok=True)
def atomic_json(path: Path, value: dict[str, Any]) -> None:
path = path.resolve()
path.parent.mkdir(parents=True, exist_ok=True)
descriptor, name = tempfile.mkstemp(prefix=f".{path.name}.", suffix=".tmp", dir=path.parent)
temporary = Path(name)
try:
with os.fdopen(descriptor, "w", encoding="utf-8", newline="\n") as stream:
json.dump(value, stream, ensure_ascii=False, indent=2)
stream.write("\n")
stream.flush()
os.fsync(stream.fileno())
for attempt in range(20):
try:
os.replace(temporary, path)
break
except PermissionError:
# Windows virus scanners and indexers can briefly open the
# destination without delete sharing. The grant-file lock
# already serializes writers, so retry only this transient OS
# condition and never fall back to a non-atomic overwrite.
if os.name != "nt" or attempt == 19:
raise
time.sleep(0.025 * (attempt + 1))
finally:
temporary.unlink(missing_ok=True)
@contextmanager
def grant_file_lock(path: Path):
"""Serialize each complete read/append/replace across CLI processes.
Keep the sidecar lock file: deleting it would allow another process to lock
a different file while a waiting process still owns the original inode.
"""
path = path.resolve()
path.parent.mkdir(parents=True, exist_ok=True)
with path.with_name(path.name + ".lock").open("a+b") as stream:
if os.name == "nt":
import msvcrt
stream.seek(0, os.SEEK_END)
if stream.tell() == 0:
stream.write(b"\0")
stream.flush()
while True:
stream.seek(0)
try:
msvcrt.locking(stream.fileno(), msvcrt.LK_NBLCK, 1)
break
except OSError as exc:
if exc.errno not in {errno.EACCES, errno.EAGAIN, errno.EDEADLK}:
raise
time.sleep(0.05)
try:
yield
finally:
stream.seek(0)
msvcrt.locking(stream.fileno(), msvcrt.LK_UNLCK, 1)
else:
import fcntl
fcntl.flock(stream.fileno(), fcntl.LOCK_EX)
try:
yield
finally:
fcntl.flock(stream.fileno(), fcntl.LOCK_UN)
def _validate_reward(value: Any) -> dict[str, int]:
if not isinstance(value, dict) or set(value) != {"type", "id", "count"}:
raise ValueError("附件必须只包含 type、id、count")
if type(value["type"]) is not int or type(value["id"]) is not int:
raise ValueError("附件 type 和 id 必须是整数")
if value["type"] in MAIL_CURRENCY_TYPES:
if value["id"] != 0:
raise ValueError("货币附件 id 必须为 0")
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"] 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)
def attachment(value: str) -> dict[str, int]:
try:
parts = value.split(":")
if len(parts) != 3:
raise ValueError("附件格式必须是 TYPE:ID:COUNT")
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
def _validate_grant(value: Any) -> dict[str, Any]:
if not isinstance(value, dict) or set(value) != {"identity", "title", "body", "sent_at", "rewards"}:
raise ValueError("发放记录必须只包含 identity、title、body、sent_at、rewards")
identity, title, body = value["identity"], value["title"], value["body"]
if not isinstance(identity, str) or not identity.strip() or len(identity) > 500:
raise ValueError("identity 不能为空且不超过 500 字符")
if not isinstance(title, str) or not title.strip() or len(title) > 500:
raise ValueError("标题不能为空且不超过 500 字符")
if not isinstance(body, str) or not body.strip() or len(body) > 5000:
raise ValueError("正文不能为空且不超过 5000 字符")
if type(value["sent_at"]) is not int or not 1 <= value["sent_at"] <= (1 << 63) - 1:
raise ValueError("sent_at 必须是正 int64 毫秒时间戳")
if not isinstance(value["rewards"], list) or not value["rewards"]:
raise ValueError("至少需要一个附件")
return {**value, "rewards": [_validate_reward(reward) for reward in value["rewards"]]}
def load_grants(path: Path) -> dict[str, Any]:
if not path.exists():
return {"version": 1, "grants": []}
try:
value = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
raise ValueError(f"无法读取邮件发放文件 {path}: {exc}") from exc
if not isinstance(value, dict) or set(value) != {"version", "grants"} or type(value["version"]) is not int or value["version"] != 1 or not isinstance(value["grants"], list):
raise ValueError("邮件发放文件必须只包含 version=1 和 grants 数组")
grants = [_validate_grant(entry) for entry in value["grants"]]
identities = [entry["identity"] for entry in grants]
if len(set(identities)) != len(identities):
raise ValueError("邮件发放文件包含重复 identity")
return {"version": 1, "grants": grants}
def grant(args: argparse.Namespace) -> int:
entry = _validate_grant({
"identity": args.identity.strip() if args.identity is not None else str(uuid.uuid4()),
"title": args.title.strip(),
"body": args.body.strip(),
"sent_at": time.time_ns() // 1_000_000,
"rewards": args.attachment,
})
output = args.output.resolve()
with grant_file_lock(output):
value = load_grants(output)
existing = next((item for item in value["grants"] if item["identity"] == entry["identity"]), None)
if existing is not None:
if any(existing[key] != entry[key] for key in ("title", "body", "rewards")):
raise ValueError(f"identity {entry['identity']!r} 已存在且内容不同")
entry, created = existing, False
else:
value["grants"].append(entry)
atomic_json(output, value)
created = True
print(json.dumps({"grant": entry, "output": str(output), "created": created}, ensure_ascii=False))
return 0
class MailGrantStore:
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.lock = threading.Lock()
if not self.output.exists():
atomic_json(self.output, {"version": 1, "grants": []})
def grant(self, payload: Any) -> dict[str, Any]:
with self.lock:
return self._grant_locked(payload)
def _grant_locked(self, payload: Any) -> dict[str, Any]:
if not isinstance(payload, dict):
raise ValueError("请求必须是 JSON 对象")
item_id = payload.get("item_id")
element_type = payload.get("element_type")
count = payload.get("count")
if not isinstance(item_id, int) or not isinstance(element_type, int) or (element_type, item_id) not in self.item_keys:
raise ValueError("element_type 与 item_id 必须是当前直接邮件列表中的安全组合")
if not isinstance(count, int) or not 1 <= count <= MAX_INT32:
raise ValueError(f"数量必须是 1 到 {MAX_INT32}")
title = payload.get("title", "开发测试物品")
body = payload.get("body", "由本地开发邮件工具发放。")
if not isinstance(title, str) or not isinstance(body, str):
raise ValueError("标题和正文必须是字符串")
title, body = title.strip(), body.strip()
if not title or len(title) > 500 or len(body) > 5000:
raise ValueError("标题不能为空且不超过 500 字符;正文不超过 5000 字符")
now = int(time.time() * 1000)
entry = _validate_grant({
"identity": str(uuid.uuid4()),
"title": title,
"body": body,
"sent_at": now,
"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:
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>只列出可由当前邮件链路直接领取的安全物品和货币。固定内容随机箱已映射成真实内容物;其他随机箱与“遗失物品”等内部哨兵不会显示。货币直接叠加到钱包。提交会原子追加到动态邮件队列,由服务端唯一分配邮件 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 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=[],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):
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"})
def do_POST(self) -> None: # noqa: N802
if self.path != "/api/grants":
self.reply_json(HTTPStatus.NOT_FOUND, {"error": "not found"})
return
try:
length = int(self.headers.get("Content-Length", "0"))
if length <= 0 or length > 32_768:
raise ValueError("请求体大小无效")
result = self.store.grant(json.loads(self.rfile.read(length)))
self.reply_json(HTTPStatus.CREATED, result)
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)
def reply_json(self, status: HTTPStatus, value: dict[str, Any]) -> None:
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-tools:", format % args)
def serve(args: argparse.Namespace) -> int:
items = load_items(args.game_data, args.game_data_version)
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"开发工具配置:{settings.path}")
print("此服务不修改 data/state;bd2server 每次 /MailInfo 导入队列并唯一分配邮件 ID。")
try:
server.serve_forever()
except KeyboardInterrupt:
print("\nBD2 开发工具已停止。")
finally:
server.server_close()
return 0
def parser() -> argparse.ArgumentParser:
result = argparse.ArgumentParser(description=__doc__)
commands = result.add_subparsers(dest="command", required=True)
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("--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.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")
command.add_argument("--attachment", type=attachment, action="append", required=True, metavar="TYPE:ID:COUNT", help="supported currency or draw ticket reward; repeat to include multiple attachments in one mail")
command.add_argument("--identity", help="stable idempotency identity (default: a new UUID)")
command.add_argument("--title", default="开发测试物品", help="mail title (maximum 500 characters)")
command.add_argument("--body", default="由本地开发邮件工具发放。", help="mail body (maximum 5000 characters)")
command.set_defaults(run=grant)
return result
def main() -> int:
args = parser().parse_args()
try:
if args.command == "serve" and args.listen_host not in {"127.0.0.1", "localhost", "::1"}:
raise ValueError("开发工具只允许监听本机回环地址")
return args.run(args)
except (OSError, ValueError, sqlite3.Error) as exc:
print(f"dev_tools: {exc}", file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())