"""1.20.3+ 的聊天是 **NBT 文本组件**(不是纯字符串)。 ## 为什么值得单独一个模块 服务端回的 `system_chat` 里 `content` 是一棵组件树: ```json {"translate": "commands.give.success.single", "with": [{"": 1}, {"...": "block.minecraft.dirt"}, {"...": "mcfold_act"}]} ``` 我先前只取 `text` 字段 ⇒ 拿到 `""`(空串), agent 看到的聊天全是空的 —— **看起来「服务端没回话」,其实是解析漏了**。 再往前一步,我连 `translate` 都只当字符串原样吐出 (`commands.give.success.single`),agent 读到的不是人话。 ## 本模块做两件事 1. **渲染**:把组件树摊平成可读文本(`Gave 1 Dirt to mcfold_act`) 2. **诚实降级**:取不到就**说取不到**(保留原值),绝不返回空串 —— 空串会让 agent 以为「什么也没发生」。 ## 翻译表 `minecraft-data` 的 `language.json`(6881 条)**本机就有**(客户端 jar 也带), 所以 `translate` 键可以直接查表变人话,不必联网。 查不到时保留键名并标注 `⟨?⟩` —— 键名本身仍有诊断价值。 """ from __future__ import annotations import functools import json import os from typing import Any LANG_ROOT = ('/home/newqqagent/plugin_data/mc/runtime/node_modules/' 'minecraft-data/minecraft-data/data/pc') @functools.lru_cache(maxsize=8) def _lang(version: str) -> dict[str, str]: p = os.path.join(LANG_ROOT, version, "language.json") if not os.path.exists(p): return {} with open(p, "r", encoding="utf-8") as f: return json.load(f) def render_text(comp: Any, version: str = "1.21.1", depth: int = 0) -> str: """把 NBT 文本组件渲染成可读字符串。 ★ 永不返回空串来「代替」看不懂的内容: 看不懂就保留原文/键名,让 agent 知道有东西但我们没解析出来。 """ if depth > 12: return "⟨嵌套过深⟩" if comp is None: return "" if isinstance(comp, str): return comp if isinstance(comp, list): return "".join(render_text(c, version, depth + 1) for c in comp) if not isinstance(comp, dict): return str(comp) lang = _lang(version) out = "" # 1) translate:查语言表,并把 with 里的参数按 %s 填回去 key = comp.get("translate") if isinstance(key, str): tmpl = lang.get(key) if tmpl is None: # 查不到模板 ⇒ 保留键名(比丢掉强:键名能定位问题) out = f"⟨{key}⟩" else: args = comp.get("with") if isinstance(args, list): vals = [render_text(a, version, depth + 1) for a in args] try: out = tmpl % tuple(vals) if vals else tmpl except (TypeError, ValueError): # 占位符个数对不上时不要崩,如实拼接 out = tmpl + " " + " ".join(vals) else: out = tmpl # 2) text:字面量 elif "text" in comp: out = str(comp.get("text") or "") # 2b) 空键 "":1.20.3+ 的 NBT 里,简单参数/数字就用空字符串作键 # (我用 {"": 1} 的形状存 1)。不读它就会把 %s 填成空, # 表现为「Gave Dirt to 」——看着像翻译失败,其实是参数没取到。 elif "" in comp: out = str(comp.get("")) # 3) 没有文本意图,但有 hoverEvent.contents.id ⇒ 物品/实体名 elif "hoverEvent" in comp: hv = comp.get("hoverEvent") or {} c = hv.get("contents") or {} ident = c.get("id") or c.get("value") if isinstance(ident, str): out = _humanize(ident) else: out = "" # 4) 兜底:有子节点就拼子节点(如上面的 hoverEvent+with 形态) else: out = "" # extra / with 里的子组件接在后面(vanilla 的拼接规则) for key2 in ("extra",): kids = comp.get(key2) if isinstance(kids, list): out += "".join(render_text(k, version, depth + 1) for k in kids) return out def _humanize(ident: str) -> str: """`minecraft:stone` → `Stone`;`item.minecraft.dirt` → `Dirt`。""" s = ident.split(":")[-1] for pre in ("block.", "item.", "entity.", "item.minecraft."): if s.startswith(pre): s = s[len(pre):] break s = s.replace("_", " ").strip() return s[:1].upper() + s[1:] if s else ident def flatten_with(args: Any, version: str = "1.21.1") -> list[str]: """把 translate 的 `with` 参数列表摊平成字符串列表。 ★ 单独一个函数是因为这几步容易出隐蔽错: 1. 数字参数存成 `{"": 1}`(空键) 2. 物品参数是 `{"translate": "block.minecraft.dirt"}` 3. 也可能直接是字符串 我先前把 1 漏了 ⇒ 语言模板的 %s 全填成空。 """ if not isinstance(args, list): return [] return [render_text(a, version, 1) for a in args]