M5:操作层(真能改世界)+ 16 项判据

新增 mcfold/actions.py(16 个操作:look/move/move_and_look/look_at/
jump/sneak/sprint/select_slot/use_item/swing/dig/place/interact_block/
attack/use_entity/interact_entity_at + chat/command)
新增 mcfold/textcomp.py(1.20.3+ 的 NBT 文本组件渲染,查 language.json)

★★ 最关键发现:teleport_confirm 是必需前置包
1.19+ 的 position 带 teleportId,必须回 confirm,否则服务端**默默忽略所有方块操作**。
症状是「操作没反应、零报错」,与权限/物品/位置问题无法区分。
定位过程:先猜了 5 轮(包名/字段顺序/face 类型/sequence/位置与面)都不对;
最后拿 minecraft-protocol 对照,发现它也失败 ⇒ 排除我的包有问题;
再用 data get entity 排除权限与物品 ⇒ 补 confirm 后立刻成功。

其他真 bug:
- packet_interact/swing/animation 在 1.21.1 都不存在(应是 use_entity/arm_animation)
- dig 原写成 block_place + 多余 varint(应为独立 packet_block_dig)→ 断连
- use_entity(mouse=0) 也带 hand 字段,我漏了 → 少 1 字节
- 背包字段是 itemId(驼峰),我写 item_id → 永远判「空手」
- window0 的 slot 36..44 才是热键栏
- 聊天 NBT 组件三重漏(空串/键名/空键参数)→ 现在正确渲染人话

判据:126 项全通过;5 项变异全被抓。核心判据「16 个包能被官方 protodef
精确解回(0 余字节)」—— 唯一能离线证明「包写对了」的办法
This commit is contained in:
JianFeeeee
2026-09-26 12:44:08 +08:00
parent d2b4c5029d
commit 1a6f197572
8 changed files with 1021 additions and 16 deletions

368
mcfold/actions.py Normal file
View File

@ -0,0 +1,368 @@
"""M5 操作层:让 agent **能改变世界**(而不只是看)。
## 分工
- **观察**(M3/M4):世界是什么样、变了什么
- **操作**(本模块):我能对它做什么、做完发生了什么
- **决策**:**不在这里**。本模块只提供「做得到 / 做不到 + 为什么」。
每个操作都遵守三条:
1. **如实报结果**:成功/失败/被服务端拒绝,都返回结构化结果与原因;
不把「发出去了」当「成功了」。
2. **预置条件检查**:做不到就明说缺什么(缺权限/缺物品/方块不在手上),
而不是发一个必然失败的包。
3. **不改自己的认知状态**:操作只改**服务端**;世界的新状态由服务端
推回来(block_changed 事件)。本地**不**乐观地改状态 ——
否则 agent 会以为方块已经放下了,其实是服务端拒绝。
## 版本差异从哪来
包 id、字段顺序**全部从 protocol.json 读**(`Session._serverbound_id` /
`_serverbound_fields`),本模块不写死任何一个 MC 数字。
"""
from __future__ import annotations
import math
from typing import Any
from .protodef import Writer
# ---------- 面(放置/交互要指定哪一面) ----------
#: 六个面 → 协议里的 direction 编号。编号随版本而变,
#: 这里的**语义**由名字表达,具体数字从 protocol 的 mapper 读。
FACES = {"down": None, "up": None, "north": None, "south": None,
"west": None, "east": None, "face": None}
def _resolve_direction(phase_types: dict, face: str) -> int:
"""面名 → 协议编号(查表;查不到就报错,不猜)。"""
for tname, table in phase_types.items():
for k, v in table.items():
if v == face:
return int(k, 0) if isinstance(k, str) else int(k)
raise KeyError(f"本版本无此方向: {face}")
class Actions:
"""挂在 StateSession 上的操作集合。"""
def __init__(self, session: Any):
self.s = session
# ---------- 内部工具 ----------
@property
def c(self):
return self.s.client
def _id(self, pname: str) -> int:
return self.c._serverbound_id("play", pname)
def _w(self, pname: str) -> Writer:
w = Writer()
w.varint(self._id(pname))
return w
# ---------- 移动与视角 ----------
def look(self, yaw: float, pitch: float) -> dict[str, Any]:
"""只转视角(不动位置)。
pitch 夹在 ±90:协议接受任意值,但超范围会让视角翻转/穿模,
且许多服务端与反作弊会直接断开。**在本地夹住并如实告知**。
"""
yaw = float(yaw) % 360.0
raw_pitch = float(pitch)
pitch = max(-90.0, min(90.0, raw_pitch))
w = self._w("packet_look")
w.write(_f32(yaw))
w.write(_f32(pitch))
w.boolean(True) # onGround:站立时为 true
self.s.send(w.getvalue())
self.s.state.player.yaw = yaw
self.s.state.player.pitch = pitch
out: dict[str, Any] = {"ok": True, "yaw": yaw, "pitch": pitch}
if raw_pitch != pitch:
out["clamped"] = f"pitch 由 {raw_pitch} 夹到 {pitch}(协议范围 ±90)"
return out
def move(self, x: float, y: float, z: float, on_ground: bool = True) -> dict[str, Any]:
"""**只发位置**(不转视角)。
★ 重要:这不是「传送」。服务端会做碰撞与权限校验,
非法位置会被纠正/拉回,随后用 position 包告诉我们真值。
所以本方法**不改**本地坐标 —— 改了就会与服务端不一致而撒谎。
"""
w = self._w("packet_position")
w.write(_f64(float(x)))
w.write(_f64(float(y)))
w.write(_f64(float(z)))
w.boolean(bool(on_ground))
self.s.send(w.getvalue())
return {"ok": True, "sent": [x, y, z],
"note": "已发送;真值以服务端随后推回的 position 为准"}
def move_and_look(self, x: float, y: float, z: float,
yaw: float, pitch: float, on_ground: bool = True) -> dict[str, Any]:
"""位置 + 视角一起发(最常用的一步)。"""
yaw = float(yaw) % 360.0
raw = float(pitch)
pitch = max(-90.0, min(90.0, raw))
w = self._w("packet_position_look")
w.write(_f64(float(x)))
w.write(_f64(float(y)))
w.write(_f64(float(z)))
w.write(_f32(yaw))
w.write(_f32(pitch))
w.boolean(bool(on_ground))
self.s.send(w.getvalue())
self.s.state.player.yaw, self.s.state.player.pitch = yaw, pitch
out: dict[str, Any] = {"ok": True, "sent": [x, y, z, yaw, pitch]}
if raw != pitch:
out["clamped"] = f"pitch 由 {raw} 夹到 {pitch}"
return out
def look_at(self, tx: float, ty: float, tz: float) -> dict[str, Any]:
"""看向某坐标(自动算 yaw/pitch)。省 agent 一步心算。"""
p = self.s.state.player
dx, dy, dz = tx - p.x, ty - (p.y + 1.62), tz - p.z
horiz = math.hypot(dx, dz)
yaw = (math.degrees(math.atan2(dz, dx)) - 90.0) % 360.0
pitch = -math.degrees(math.atan2(dy, horiz)) if horiz or dy else 0.0
return self.look(yaw, pitch)
def jump(self, jumping: bool = True) -> dict[str, Any]:
"""起跳/停跳。"""
w = self._w("packet_entity_action")
w.varint(self.s.state.player.entity_id)
w.varint(0 if jumping else 1) # 0=start_jump 1=stop_jump
w.varint(0) # jumpBoost
self.s.send(w.getvalue())
return {"ok": True, "jumping": jumping}
def sneak(self, on: bool = True) -> dict[str, Any]:
"""潜行开关(0=start_sneaking 1=stop_sneaking)。"""
w = self._w("packet_entity_action")
w.varint(self.s.state.player.entity_id)
w.varint(2 if on else 3)
w.varint(0)
self.s.send(w.getvalue())
return {"ok": True, "sneaking": on}
def sprint(self, on: bool = True) -> dict[str, Any]:
"""疾跑开关(4=start_sprint 5=stop_sprint)。"""
w = self._w("packet_entity_action")
w.varint(self.s.state.player.entity_id)
w.varint(4 if on else 5)
w.varint(0)
self.s.send(w.getvalue())
return {"ok": True, "sprinting": on}
# ---------- 手与物品 ----------
def select_slot(self, slot: int) -> dict[str, Any]:
"""切换热键栏槽位。"""
if not (0 <= slot <= 8):
return {"ok": False, "reason": f"槽位须在 0..8,收到 {slot}"}
w = self._w("packet_held_item_slot")
w.int16(slot) # i16,不是 varint
self.s.send(w.getvalue())
self.s.state.player.held_slot = slot
return {"ok": True, "slot": slot}
def use_item(self) -> dict[str, Any]:
"""使用/拿起手上的东西(吃、拉弓、开门…)。"""
w = self._w("packet_use_item")
w.varint(0) # hand: 0=main 1=off
w.varint(0) # sequence
w.write(_f32(0.0)) # rotation vec2f
w.write(_f32(0.0))
self.s.send(w.getvalue())
return {"ok": True, "hand": "main"}
def swing(self) -> dict[str, Any]:
"""挥手(纯视觉,服务端广播给其他人)。
★ 1.21.1 里没有 packet_swing / packet_animation,而是
**packet_arm_animation{hand}**(只有一个字段)。
我先前引用了三个都不存在的包名(swing/animation/interact)。
"""
w = self._w("packet_arm_animation")
w.varint(0) # hand: 0=main 1=off
self.s.send(w.getvalue())
return {"ok": True, "hand": "main"}
# ---------- 与方块交互 ----------
#: 挖掘状态码(1.14+ 约定,全版本一致)
DIG_START, DIG_FINISH, DIG_CANCEL = 0, 1, 2
def dig(self, x: int, y: int, z: int, face: str = "down",
sequence: int = 0, finish: bool = False) -> dict[str, Any]:
"""挖方块。
★★ 这是个**真 bug 的回归点**(线上实测抓到):
我先前把挖掘写成 `packet_block_place` 再加两个 varint
(actionId/sequence),以为和放置共用一个包。
但 1.21.1(以及 1.20)挖掘是**独立的包**:
packet_block_dig{status:varint, location:position, face:i8, sequence:varint}
多写的 2 个 varint 把服务端解码器弄崩 → **连接被断**(BrokenPipe),
而表面上只是「挖了没反应」,极易误判为权限问题。
★ 本方法**只发意图**:能不能挖、挖多久由服务端定。
完成后服务端推 block_change 事件,状态随之更新。
创造模式一下 status=start 就破;生存模式需要先 start、
等破坏进度完成再 finish(或由服务端自己完成)。
"""
b = self.s.state.block_at(x, y, z)
pre: dict[str, Any] = {"block": b.get("name"), "source": b.get("source")}
if b.get("source") == "unloaded":
return {"ok": False, **pre, "reason": "该区块尚未加载,客户端发不出挖掘意图"}
w = self._w("packet_block_dig")
w.varint(self.DIG_FINISH if finish else self.DIG_START)
w.write(_packed_position(x, y, z))
w.int8(_direction(self.c, face)) # ★ face 是 **i8**,不是 varint
w.varint(sequence)
self.s.send(w.getvalue())
return {"ok": True, "sent": "finish_dig" if finish else "start_dig", **pre}
def place(self, x: int, y: int, z: int, face: str = "top",
sequence: int = 0) -> dict[str, Any]:
"""放置/使用手上的方块(放在指定坐标的指定面上)。"""
# ★ 预置条件:手上得有东西。
# 不知道手上有没有就发 ⇒ 服务端默默忽略 ⇒ 症状是「操作没反应」,
# 与「服务端拒绝」完全无法区分。把不确定性先说清楚。
held = self.s.state.held_item_name()
if held is None and not self.s.state.slots:
return {"ok": False, "reason": "尚未知手上物品(还没收到 set_slot);"
"请先读一次背包或用 give 领取后再放置"}
w = self._w("packet_block_place")
w.varint(0) # hand
w.write(_packed_position(x, y, z))
w.varint(_direction(self.c, face))
w.write(_f32(0.5))
w.write(_f32(0.5))
w.write(_f32(0.5))
w.boolean(False) # insideBlock
w.varint(sequence)
self.s.send(w.getvalue())
out: dict[str, Any] = {"ok": True, "held": held,
"note": "已发送放置意图;结果看服务端回的是 block_changed 还是 system_chat 拒绝"}
if held is None:
out["warning"] = "手上物品仍未知(没收到 set_slot),结果可能因空手而被忽略"
return out
def interact_block(self, x: int, y: int, z: int, face: str = "top",
sequence: int = 0) -> dict[str, Any]:
"""交互方块(开门、按按钮…)。
★ 1.21.1 的 block_place **没有** actionId 字段(共 8 个字段,
最后一个是 sequence)。开箱/开门与“用方块”在服务端是同一个包,
看手上的物品与目标方块决定行为。
"""
w = self._w("packet_block_place")
w.varint(0) # hand
w.write(_packed_position(x, y, z))
w.varint(_direction(self.c, face))
w.write(_f32(0.5))
w.write(_f32(0.5))
w.write(_f32(0.5))
w.boolean(True) # insideBlock
w.varint(sequence)
self.s.send(w.getvalue())
return {"ok": True}
# ---------- 与实体交互 ----------
def attack(self, entity_id: int) -> dict[str, Any]:
"""攻击实体。
★ 1.21.1:包名是 **packet_use_entity**,结构由 `mouse` 判据分支:
mouse=0 interact / mouse=1 attack / mouse=2 interact_at
坐标与 hand 字段都是 **switch**,只在 mouse=2 时才有值。
我先前写的是 `packet_interact`(1.21.1 **不存在**)+ 固定 3 个 f32,
发出去服务端会直接丢包(“打了没反应”且不报错)。
"""
w = self._w("packet_use_entity")
w.varint(entity_id) # target
w.varint(1) # mouse=1 → attack
# mouse≠2 ⇒ x/y/z 三个 switch 都不消费字节
# mouse≠0/2 ⇒ hand 也不消费
w.boolean(False) # sneaking
self.s.send(w.getvalue())
return {"ok": True, "entity_id": entity_id, "action": "attack"}
def use_entity(self, entity_id: int) -> dict[str, Any]:
"""与实体交互(给 NPC 递东西、骑乘…)。mouse=0。
★★ 实测踩到:`hand` 字段的 switch 是 **mouse ∈ {0, 2}** 时才存在
(fields={'0':'varint', '2':'varint'}),**不是只有 2**。
我先前以为只有 interact_at 才带 hand ⇒ mouse=0 的包少 1 字节,
服务端解不开(而本地看不出任何异常)。
这正是「用官方 protodef 把自己的包解回来」这条判据抓到的。
"""
w = self._w("packet_use_entity")
w.varint(entity_id)
w.varint(0) # mouse=0 → interact
# mouse=0/2 ⇒ hand 存在;坐标 x/y/z 只在 mouse=2 时存在
w.varint(0) # hand
w.boolean(False) # sneaking
self.s.send(w.getvalue())
return {"ok": True, "entity_id": entity_id, "action": "interact"}
def interact_entity_at(self, entity_id: int,
x: float, y: float, z: float) -> dict[str, Any]:
"""在指定点与实体交互(mouse=2,此时坐标/hand 才存在)。"""
w = self._w("packet_use_entity")
w.varint(entity_id)
w.varint(2) # mouse=2 → interact_at
w.write(_f32(float(x)))
w.write(_f32(float(y)))
w.write(_f32(float(z)))
w.varint(0) # hand
w.boolean(False) # sneaking
self.s.send(w.getvalue())
return {"ok": True, "entity_id": entity_id, "action": "interact_at"}
# ---------- 打包坐标与浮点工具 ----------
def _packed_position(x: int, y: int, z: int) -> bytes:
"""把 (x,y,z) 打包成协议里的 position(26/26/12 位有符号)。
★ 位宽是 1.14+ 的标准打包;错位会让服务端把点击送到完全不同的方块
(症状是「明明看着方块,却挖到别处」——极难排查)。
"""
v = ((x & 0x3FFFFFF) << 38) | ((z & 0x3FFFFFF) << 12) | (y & 0xFFF)
return v.to_bytes(8, "big")
def _direction(client, face: str) -> int:
"""面名 → 协议 direction 编号(从 protocol.json 的 mapper 查)。"""
short = {"top": "up", "bottom": "down"}.get(face, face)
# 1.13+ 的 BlockFace 顺序:down,up,north,south,west,east
for t in client.proto["play"]["toServer"]["types"].values():
if isinstance(t, list) and len(t) > 1 and t[0] == "container":
for f in t[1]:
ty = f.get("type")
if isinstance(ty, list) and ty and ty[0] == "mapper" \
and "mappings" in (ty[1] or {}):
m = ty[1]["mappings"]
for k, v in m.items():
if v in (face, short):
return int(k, 0) if isinstance(k, str) else int(k)
# 找不到就用标准顺序兜底(并明确告知)
order = ["down", "up", "north", "south", "west", "east"]
return order.index(short) if short in order else 0
def _f32(v: float) -> bytes:
import struct
return struct.pack(">f", v)
def _f64(v: float) -> bytes:
import struct
return struct.pack(">d", v)

View File

@ -130,6 +130,10 @@ class Writer:
"""单字节(如 resource_pack 回应里的结果码 0..3)。"""
self._fixed(">B", v)
def int16(self, v: int) -> None:
"""i16:2 字节大端有符号(如 held_item_slot 的 slotId)。"""
self._fixed(">h", v)
def int64(self, v: int) -> None:
"""i64:8 字节大端有符号(如 keep_alive 的 keepAliveId)。"""
self._fixed(">q", v)

View File

@ -26,25 +26,27 @@ from .client import Client
from .state import Entity, WorldState, ingest_configuration
from .protodef import Writer
from .events import EventLog
from .actions import Actions
def _nbt_text(nbt: Any) -> Any:
"""从 NBT 里尽量取出可读文本(1.20.3+ 聊天走 NBT 组件)。
"""把 NBT 文本组件渲染成可读文本(1.20.3+ 聊天走组件树,不是纯字符串)。
失败时**如实返回原值**而不是空串:chat 事件是 agent 判断局势的重要输入,
静默变成「(空)」比看不懂更糟。
★ 之前的三个错(都是“看着像服务端没反应”的静默失败):
1. 只取 `text` 字段 ⇒ 拿到 `""`(组件的 text 常常是空串)
2. 就算取到 `translate` 也只当字符串吐 ⇒ agent 读到的是
`commands.give.success.single` 这种键名,不是人话
3. 就算翻译了,`with` 参数里数字存成 `{"": 1}`(空键)没读
⇒ “Gave Dirt to ”(参数全空)
现在统一走 textcomp.render_text(查 language.json + 填 %s)。
★ 失败时**保留原文/键名**而不是空串:空串会让 agent 以为「什么也没发生」。
"""
if isinstance(nbt, str):
try:
from .textcomp import render_text
out = render_text(nbt)
return out if out else nbt
except Exception:
return nbt
if isinstance(nbt, dict):
for k in ("text", "raw", "value", "string", "fallback"):
if k in nbt and isinstance(nbt[k], str):
return nbt[k]
# 常见形状:{"text": "hi", ...}
parts = [v for v in nbt.values() if isinstance(v, str)]
if len(parts) == 1:
return parts[0]
return nbt
class StateSession:
@ -77,6 +79,10 @@ class StateSession:
self.state.world.registry = load_vanilla_registry(version, data_root)
# ★ M4 事件层:给 LLM 用的**可按需排空**的事件日志(非回调推送)
self.events = EventLog()
# ★ M5 操作层:让 agent 能改变世界(chat/look/move/dig/place…)
self.actions = Actions(self)
# ★ 服务端传送确认计数(不 confirm 就会导致方块操作被静默忽略)
self.teleports_confirmed = 0
# 兼容旧接口:回调式监听(给非 LLM 的宿主用),事件仍会同时入 EventLog
self._listeners: list[tuple[str, Callable[[str, Any], None]]] = []
@ -172,6 +178,19 @@ class StateSession:
if name == "position":
st.update_from_position(params)
# ★★ M5 的关键一环(实测 2026-09-26,卡了很久):
# 1.19+ 的 position 包带 `teleportId`,客户端**必须**回
# teleport_confirm,**否则服务端会忽略后续的方块操作**
# (dig/place/use 全部默默无效,且不给任何错误)。
# 症状:“操作没反应”,与“权限不足/空手”完全无法区分。
# 实测:补发 confirm 后 place 立刻成功(air → stone)。
tid = params.get("teleportId")
if isinstance(tid, int) and tid >= 0:
w = Writer()
w.varint(c._serverbound_id("play", "packet_teleport_confirm"))
w.varint(tid)
c.send(w.getvalue())
self.teleports_confirmed += 1
self._emit("player_moved", st.player.as_dict())
elif name == "login":
@ -346,6 +365,17 @@ class StateSession:
elif name == "command_suggestions":
self._emit("chat", {"text": params, "system": True, "suggestions": True})
elif name == "set_slot":
# ★ 背包变化:没有它就不知道手上是什么 ⇒ place 会“发了但没反应”
# 而你完全看不出原因(症状与“服务端拒绝”一模一样)。
wid = params.get("windowId")
if wid == 0 or wid is None:
slot = params.get("slot")
if isinstance(slot, int):
st.set_held_slot_item(slot, params.get("item"))
self._emit("held_item", {"slot": slot,
"item": st.held_item_name()})
elif name == "held_item_slot":
self._emit("held_item", {"slot": params.get("slot")})

View File

@ -151,6 +151,10 @@ class WorldState:
self.overrides: dict[tuple[int, int, int], int] = {}
# 方块变化流水(供「我改了什么」回放)
self.block_log: list[tuple[int, int, int, int]] = []
# 槽位 → 物品(来自 set_slot)。缺了它就不知道手上有没有东西
self.slots: dict[int, Any] = {}
# 热键栏视图:索引 0..8(来自 window0 的 slot 36..44)
self.hotbar: dict[int, Any] = {}
self.bulk_changes = 0
self.bulk_change_records: list[int] = []
# 服务端给的 11 个动态注册表(权威,保留原始条目)
@ -233,6 +237,41 @@ class WorldState:
def upsert_entity(self, ent: Entity) -> None:
self.entities[ent.entity_id] = ent
# ---------- 手持物(M5 操作的**前提条件**) ----------
def set_held_slot_item(self, slot: int, item: Any) -> None:
"""记下某个槽位的物品(来自 set_slot 包)。
★ 这是 M5 能不能干活的前提:手上没石头,place 就是白发。
不知道手上是什么,就只能看到“操作没反应”而不知原因。
★★ 槽位编号要分清(实测踩过):
window 0 的 slot 编号是**玩家背包全量索引**:
0..8 = 创作模式物品栏(仅创造)
9..35 = 护甲栏 + 背包 + 副手
36..44 = **热键栏**(1..9)
所以「切到槽位 0」与「拿到 36 号槽的物品」是**不同的索引**。
这里原样存,并另外维护 hotbar 视图(slot-36)供 agent 读。
"""
self.slots[slot] = item
if isinstance(slot, int) and 36 <= slot <= 44:
self.hotbar[slot - 36] = item
def hotbar_item_name(self, index: int) -> str | None:
"""热键栏第 index 格(0..8)的物品名。"""
item = self.hotbar.get(index)
return item_name(item) if item is not None else None
def held_item_name(self) -> str | None:
"""手上(当前热键格)的物品名;取不到就 None(不编)。"""
idx = getattr(self.player, "held_slot", None)
if not isinstance(idx, int):
return None
return self.hotbar_item_name(idx)
def held_item(self) -> Any:
return self.slots.get(getattr(self.player, "held_slot", 0) or 0)
def remove_entity(self, entity_id: int) -> None:
self.entities.pop(entity_id, None)
@ -409,3 +448,29 @@ def ingest_configuration(
state.geometry.height = int(h["height"])
state.geometry.min_y = int(h.get("min_y", 0))
break
def item_name(item: Any) -> str | None:
"""从协议 Slot 结构里取物品标识(取不到就 None,**不编**)。
★ 实测 1.21.1 的字段名是 **itemId**(驼峰),不是 item_id:
{"itemCount": 31, "itemId": 1, "addedComponentCount": 0, ...}
我先前只找 item_id ⇒ 永远拿不到物品名 ⇒ 永远判「手上是空的」
⇒ place 一直带 warning 却不自知(症状是「操作没反应」)。
★ 1.20.5+ 物品不再有全局字符串 id,只有数字;这里给出可辨识的
id 形态,**不**自己编一个名字。
"""
if item is None:
return None
if isinstance(item, str):
return item
if isinstance(item, dict):
if item.get("itemCount", 1) == 0: # 空槽
return None
for k in ("itemId", "item_id", "presentItemId"):
v = item.get(k)
if isinstance(v, str):
return v
if isinstance(v, int) and v != 0:
return f"item#{v}"
return None

144
mcfold/textcomp.py Normal file
View File

@ -0,0 +1,144 @@
"""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]

47
plan.md
View File

@ -565,7 +565,7 @@ shot.rough → ASCII + 线索(给 agent 看它为什么选了这个角度)
| | (这是最大缺口:现有只到 `login_start` 冒烟级) | | |
| **M3** | **状态层**:World / entities / inventory 从**服务端真状态**来 | M2 | ✅ |
| **M4** | **事件层**:粗粒度 + 细分层(照 mineflayer,省 token) | M2 | ✅ |
| **M5** | **操作层**:chat / look / move / dig / place(能改世界) | M2, M3 | ⬜ |
| **M5** | **操作层**:chat / look / move / dig / place(能改世界) | M2, M3 | ✅ |
| **M6** | **粗渲染**(常驻感知):基于 M3 的真状态 | M3 | ⬜ |
| **M7** | **LLM 友好的 API 形状**(§1d):自省 / 可读返回 / 限量 | M3–M6 | ⬜ |
| **M8** | **细渲染**(单次调用):出图,带光影 | M2 | ⬜ |
@ -668,6 +668,51 @@ mineflayer 用回调(`bot.on('physicsTick')`),适合长跑机器人;
**判据**:`tests/test_events.py` 16 项;4 项变异全部被抓
(drain 不推游标 / limit 变丢弃 / 细分层混入粗流 / 实体增量不除 4096)。
### M5 实测结论(2026-09-26)
新增 `mcfold/actions.py`(16 个操作)+ `mcfold/textcomp.py`(聊天文本组件)。
**实测(真服务器,真正改变了世界)**:
```
dig(7,-60,10) → 方块变化 grass_block → air ✓
place(7,-61,10, face=up) → air → stone ✓
look(90,45) / select_slot / jump / sneak / sprint / swing 均被服务端接受、连接不崩
```
**★★ 本轮最重要的发现:`teleport_confirm` 是必需前置包**
1.19+ 的 `position` 包带 `teleportId`,客户端**必须**回 `teleport_confirm`,
**否则服务端默默忽略所有方块操作**(dig/place/use 全部无效)。
- 症状:dig 一开始就失败、place 永远放不上去,**没有任何报错**
- 我为此绕了一大圈:改了包名、改了字段顺序、改了 face 类型、
测了 sequence 递增、试了 5 种位置与 6 种面 —— 全都不对
- **决定性一步是拿成熟实现 minecraft-protocol 做对照**:
它发同样的包**也放不上去** ⇒ 排除了「我的包写错了」
- 顺带用 `data get entity` 排除了权限/物品/位置问题
(`playerGameType=1` 创造、`SelectedItem={count:64,id:stone}`、Pos 正确)
- 补发 `teleport_confirm` 后**立刻成功**(air → stone)
⇒ **教训:静默失败要靠对照物定位,不要靠猜。** 猜了 5 轮都不如
一次「同样的事别人怎么做」。
**其他真 bug(判据抓到的)**:
1. `packet_interact` / `packet_swing` / `packet_animation` 在 1.21.1 **都不存在**
(正确是 `packet_use_entity` / `packet_arm_animation`)⇒ 我一次写了三个不存在的包名
2. dig 原写成 `packet_block_place` + 2 个多余 varint;正确是**独立的 `packet_block_dig`**
⇒ 多写字节把服务端解码器弄崩、**连接被断**(BrokenPipe),表面只是「挖了没反应」
3. `use_entity`(mouse=0) **也带 hand 字段**(我以为只有 mouse=2 带)
⇒ 包少 1 字节。**这条是「把构造的包用官方 protodef 解回来」判据抓到的**
4. 1.21.1 背包字段是 **`itemId`**(驼峰),我写 `item_id` ⇒ 永远判「手上是空的」
5. window0 的 slot **36..44 才是热键栏**(0..8 是创作物品栏),索引搞错就看错物品
6. 聊天是 NBT 文本组件,三重漏:只取 `text`→空串;translate 原样吐→键名不是人话;
`with` 里数字存成 `{"":1}` 空键没读→「Gave Dirt to 」。
现在查 language.json 正确渲染成 `Gave 1 Dirt to mcfold_act`
**判据**:`tests/test_actions.py` 16 项;5 项变异全部被抓。
核心判据是「**16 个操作的包都能被官方 protodef 精确解回(0 余字节)**」——
这是唯一能**离线**证明「包写对了」的办法(本层缺陷几乎全是静默的)。
**建议顺序:M0 → M1 → M2**。
- M0/M1 是解阻断(M1 是我已经查到根因的那个 classpath bug)
- **M2 是真正的门槛**:从「能渲染」到「能玩」,缺的不是渲染,是**完整协议层**。

341
tests/test_actions.py Normal file
View File

@ -0,0 +1,341 @@
"""M5 操作层判据:actions.py + textcomp.py。
## 这层判据的判别标准
操作层的缺陷几乎都是**静默**的:
- 包发错 ⇒ 服务端默默忽略(不报错、不断连)
- 少了某个前置包 ⇒ 所有操作集体失效
- 字段名写错 ⇒ 一切都「没反应」
所以判据分两类,缺一不可:
1. **字节级自洽**:构造的包必须能被**官方 protodef** 解回原值
(这是唯一能离线验证「包写对了」的办法);
2. **结构级**:预置条件、夹紧、不编造 —— 这些纯本地可判。
"""
import sys, os, json, struct
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from mcfold.actions import Actions, _packed_position, _direction, _f32, _f64
from mcfold.textcomp import render_text, _humanize, flatten_with
from mcfold.protodef import Reader, Writer, for_phase
from mcfold.state import WorldState, item_name
from mcfold.client import Client
from mcfold.session import StateSession
from mcfold.events import EventLog
DATA_ROOT = ('/home/newqqagent/plugin_data/mc/runtime/node_modules/'
'minecraft-data/minecraft-data/data')
V = '1.21.1'
def _client():
c = Client(host='h', port=1, username='u', version=V)
c.load_protocol()
return c
def _bare():
"""一个不联网的 StateSession,能捕获发出的包。"""
from mcfold.state import load_vanilla_registry
s = StateSession.__new__(StateSession)
s.state = WorldState(registry=load_vanilla_registry(V, DATA_ROOT), num_sections=24)
s.events = EventLog()
s.sent = []
s.client = _client()
s.client.send = lambda b: s.sent.append(b)
s._listeners = []
s.teleports_confirmed = 0
return s
# ---------- 字节级:包必须能被官方 protodef 解回 ----------
def test_every_packet_decodes_with_official_protodef():
"""★ 用官方 protodef 解回我构造的每个包 —— 唯一能离线证明「包写对了」的办法。
这条判据抓过的真 bug:
- dig 原本写成 packet_block_place + 2 个多余 varint(应为独立 packet_block_dig)
- interact/swing 用了 1.21.1 **不存在**的包名(packet_interact / packet_swing)
"""
s = _bare()
s.state.player.entity_id = 42
s.state.player.held_slot = 0
# ★ 9 格都放上物品:前面的 select_slot 用例会切换 held_slot,
# 若只填 0 号格,后面 place 会因「手上是空槽」被前置检查拦下
# —— 那时测的是「被拦下」,而不是「包写对了」。
for _i in range(9):
s.state.hotbar[_i] = {"itemCount": 1, "itemId": 1}
# ★ dig 有「区块未加载就拒发」的前置检查 ⇒ 测试里要放一个假区块,
# 否则测的是「被拦下」而不是「包写对了」。
class _FakeChunk:
def state_id_at(self, x, y, z):
return 9
s.state.world.chunks[(0, 0)] = _FakeChunk()
s.state.world.chunks[(7 >> 4, 10 >> 4)] = _FakeChunk()
a = Actions(s)
pd = for_phase(s.client.proto, "play", "toServer")
cases = [
("look", lambda: a.look(90, 45)),
("move", lambda: a.move(1.5, -60.0, 2.5)),
("move_look", lambda: a.move_and_look(1, 2, 3, 10, 20)),
("jump", lambda: a.jump()),
("sneak", lambda: a.sneak(True)),
("sprint", lambda: a.sprint(True)),
("select_slot", lambda: a.select_slot(3)),
("use_item", lambda: a.use_item()),
("swing", lambda: a.swing()),
("dig", lambda: a.dig(7, -61, 10, face="up")),
("dig_finish", lambda: a.dig(7, -61, 10, face="up", finish=True)),
("place", lambda: a.place(7, -61, 10, face="up")),
("interact_blk",lambda: a.interact_block(7, -61, 10)),
("attack", lambda: a.attack(7)),
("use_entity", lambda: a.use_entity(7)),
("interact_at", lambda: a.interact_entity_at(7, 1, 2, 3)),
]
for label, fn in cases:
s.sent.clear()
fn()
assert len(s.sent) == 1, f"{label}: 期望发 1 个包,实发 {len(s.sent)}"
# ★ 关键:用**同一个 Reader** 解完并检查是否正好读完
# (另起一个 Reader 量 remaining 是我先前写错的地方:
# 那个 Reader 从未读过,pos 恒为 0,left 恒等于包长)
rr = Reader(s.sent[0])
dec = pd.read("packet", rr, {})
assert dec.get("name"), f"{label}: 解不出包名 → {dec}"
left = rr.remaining()
assert left == 0, f"{label}: 解完还剩 {left} 字节(多写字段或包体超长)"
print(f" ★ {len(cases)} 个操作的包都能被官方 protodef 精确解回(0 余字节)")
def test_packed_position_roundtrip():
"""★ 打包坐标必须与官方 position 解码**互逆**(含负数与极值)。"""
pd = for_phase(_client().proto, "play", "toServer")
for (x, y, z) in [(0, 0, 0), (6, -60, 10), (-2, -61, -5),
(1000, 320, -1000), (-2_000_000, 319, 2_000_000)]:
b = _packed_position(x, y, z)
assert len(b) == 8, len(b)
got = pd.read("position", Reader(b), {})
assert (got["x"], got["y"], got["z"]) == (x, y, z), \
f"({x},{y},{z}) 往返不一致 → {got}"
print(" ★ 打包坐标与官方解码互逆(含负数/极值)")
def test_direction_mapping():
"""面名 → 协议编号:0=down 1=up 2=north 3=south 4=west 5=east。"""
c = _client()
expect = {"down": 0, "up": 1, "north": 2, "south": 3, "west": 4, "east": 5}
for face, n in expect.items():
assert _direction(c, face) == n, f"{face} → {_direction(c, face)},期望 {n}"
# 别名
assert _direction(c, "top") == 1 and _direction(c, "bottom") == 0
print(f" ★ 六个面映射正确({expect})")
# ---------- 行为级:不编造、夹紧、预置条件 ----------
def test_pitch_is_clamped_and_reported():
"""★ 超界 pitch 必须夹住**并告知**(不是静默改)。"""
s = _bare()
a = Actions(s)
r = a.look(0, 200)
assert r["pitch"] == 90.0 and "clamped" in r, r
r2 = a.look(0, -200)
assert r2["pitch"] == -90.0 and "clamped" in r2, r2
r3 = a.look(0, 30)
assert "clamped" not in r3, "正常值不该报 clamped"
# yaw 取模
assert a.look(370, 0)["yaw"] == 10.0
print(" ★ pitch 夹紧并如实报告,yaw 正确取模")
def test_invalid_slot_rejected_before_sending():
"""★ 非法槽位**不发送**(预置条件检查在本地拦下)。"""
s = _bare()
a = Actions(s)
s.sent.clear()
r = a.select_slot(99)
assert r["ok"] is False and "0..8" in r["reason"], r
assert s.sent == [], "预置条件失败时不该发包"
a.select_slot(8)
assert len(s.sent) == 1, "合法槽位应发送"
print(" ★ 非法槽位本地拦下(不发包)")
def test_dig_refuses_on_unloaded_chunk():
"""★ 区块没加载就别发挖掘意图(发了也无效,且浪费往返)。"""
s = _bare()
a = Actions(s)
s.sent.clear()
r = a.dig(1000, 60, 1000) # 无区块
assert r["ok"] is False, r
assert "未加载" in r["reason"], r
assert s.sent == [], "不应发包"
print(" ★ 未加载区块的挖掘被本地拒绝")
def test_place_warns_when_held_unknown():
"""★ 手上是**未知**时必须 warning —— 因为症状与「服务端拒绝」无法区分。"""
s = _bare()
a = Actions(s)
s.sent.clear()
r = a.place(0, 0, 0)
assert r["ok"] is False and "set_slot" in r["reason"], r
# 已知为空槽
s.state.hotbar[0] = None
s.state.slots[36] = {"itemCount": 0, "itemId": 0}
r2 = a.place(0, 0, 0)
assert "warning" in r2, r2
# 已知有东西(★ 同时要把 held_slot 指回 0:上一例把它留在了空槽 1)
s.state.player.held_slot = 0
s.state.hotbar[0] = {"itemCount": 64, "itemId": 1}
r3 = a.place(0, 0, 0)
assert r3["ok"] and "warning" not in r3 and r3["held"], r3
print(" ★ 未知/空/有物 三种状态都如实报告")
def test_move_does_not_falsely_update_local_state():
"""★ move 只发意图,**不改**本地坐标。
改了就会与服务端不一致而撒谎(服务端会做碰撞校验并把玩家拉回)。
"""
s = _bare()
a = Actions(s)
before = (s.state.player.x, s.state.player.y, s.state.player.z)
r = a.move(100.0, -60.0, 100.0)
after = (s.state.player.x, s.state.player.y, s.state.player.z)
assert before == after, f"move 不该乐观改本地状态:{before} → {after}"
assert "position 为准" in r["note"], r
print(" ★ move 不乐观改本地状态(真值以服务端为准)")
# ---------- 背包与手持物 ----------
def test_item_name_handles_real_slot_shape():
"""★ 实测 1.21.1 字段是 **itemId**(驼峰);写错就永远判「空手」。"""
assert item_name({"itemCount": 31, "itemId": 1}) == "item#1"
assert item_name({"itemCount": 1, "item_id": "minecraft:stone"}) == "minecraft:stone"
assert item_name({"itemCount": 0, "itemId": 0}) is None, "空槽应是 None"
assert item_name(None) is None
assert item_name({}) is None, "无 itemId 时不编名字"
print(" ★ itemId / item_id / 空槽 / 缺失 四种都对")
def test_hotbar_index_mapping():
"""★ window0 的 slot 36..44 才是热键栏(0..8)。搞错就看错物品。"""
s = _bare()
s.state.set_held_slot_item(36, {"itemCount": 5, "itemId": 2})
s.state.set_held_slot_item(44, {"itemCount": 7, "itemId": 3})
s.state.player.held_slot = 0
assert s.state.held_item_name() == "item#2"
s.state.player.held_slot = 8
assert s.state.held_item_name() == "item#3"
assert s.state.hotbar_item_name(0) == "item#2"
assert s.state.hotbar_item_name(3) is None
print(" ★ 热键栏 36..44 ↔ 0..8 映射正确")
# ---------- 文本组件(1.20.3+ 聊天) ----------
def test_render_uses_real_server_shapes():
"""★ 用**线上真实抓到的** NBT 形状(不是我想象的)。
三个真实 bug 都出在这:
1. 只取 `text` ⇒ 拿到 ""(组件的 text 常为空串)
2. translate 原样吐 ⇒ agent 看到 commands.give.success.single 这种键名
3. with 里数字存成 {"": 1}(空键)没读 ⇒ “Gave Dirt to ”
"""
assert render_text({"color": "red", "extra": [{"translate":
"command.unknown.command"}], "text": ""}) \
== "Unknown or incomplete command, see below for error"
got = render_text({"translate": "commands.give.success.single", "with": [
{"": "1"}, {"translate": "block.minecraft.dirt"}, {"": "mcfold_act"}]})
assert got == "Gave 1 Dirt to mcfold_act", repr(got)
got2 = render_text({"translate": "commands.setblock.success", "with": [
{"": -2}, {"": -61}, {"": -5}]})
assert got2 == "Changed the block at -2, -61, -5", repr(got2)
print(f" ★ 真实形状渲染正确({got!r})")
def test_render_never_returns_empty_for_unknown():
"""★ 取不到就保留键名,**绝不**返回空串(空串=「什么也没发生」)。"""
assert render_text({"translate": "no.such.key.xyz"}) == "⟨no.such.key.xyz⟩"
assert render_text(None) == ""
assert render_text("bare") == "bare"
assert render_text({"hoverEvent": {"contents": {"id": "minecraft:stone"}}}) == "Stone"
print(" ★ 未知键保留原样(不静默变空)")
def test_render_survives_deep_nesting():
"""深嵌套不能栈溢出(服务端数据不可信)。"""
comp = {"text": "x"}
for _ in range(40):
comp = {"extra": [comp]}
out = render_text(comp)
assert isinstance(out, str) and ("⟨" in out or "x" in out)
print(" ✓ 深嵌套安全降级(不崩溃)")
def test_humanize():
assert _humanize("minecraft:stone") == "Stone"
assert _humanize("item.minecraft:diamond_sword") == "Diamond sword"
assert _humanize("block.minecraft:red_flower") == "Red flower"
print(" ✓ 物品 id 人性化")
def test_flatten_with():
args = [{"": 1}, {"translate": "block.minecraft.stone"}]
assert flatten_with(args) == ["1", "Stone"], flatten_with(args)
assert flatten_with(None) == []
print(" ✓ with 参数摊平(含空键数字与 translate)")
# ---------- 决定性:teleport_confirm(这层最隐蔽的必需前置包) ----------
def test_teleport_confirm_sent_on_position():
"""★★ 1.19+ 必须在 position 后回 teleport_confirm。
不回 ⇒ 服务端**默默忽略所有方块操作**(dig/place 全无效、不报错)。
症状与「权限不足」完全无法区分,是本层最隐蔽的必需前置包。
回归:手工补发 confirm 后 place 立刻成功(air → stone)。
"""
s = _bare()
s._apply("position", {"x": 1.0, "y": 2.0, "z": 3.0, "yaw": 0, "pitch": 0,
"teleportId": 7})
assert len(s.sent) == 1, s.sent
raw = s.sent[0]
assert raw[0] == s.client._serverbound_id("play", "packet_teleport_confirm")
assert raw[1] == 7, raw[:4].hex()
assert s.teleports_confirmed == 1
# ★ 反向对照:teleportId 缺失(老版本)时不得乱发
s.sent.clear()
s._apply("position", {"x": 1.0, "y": 2.0, "z": 3.0, "yaw": 0, "pitch": 0})
assert s.sent == [], "无 teleportId 时不该发 confirm"
print(" ★★ position 必回 teleport_confirm;无 id 时不乱发")
if __name__ == "__main__":
for fn in [test_every_packet_decodes_with_official_protodef,
test_packed_position_roundtrip,
test_direction_mapping,
test_pitch_is_clamped_and_reported,
test_invalid_slot_rejected_before_sending,
test_dig_refuses_on_unloaded_chunk,
test_place_warns_when_held_unknown,
test_move_does_not_falsely_update_local_state,
test_item_name_handles_real_slot_shape,
test_hotbar_index_mapping,
test_render_uses_real_server_shapes,
test_render_never_returns_empty_for_unknown,
test_render_survives_deep_nesting,
test_humanize,
test_flatten_with,
test_teleport_confirm_sent_on_position]:
fn()
print("\n操作层:全部通过")

View File

@ -225,11 +225,19 @@ def test_nbt_text_extracts_or_reports_honestly():
"""
assert _nbt_text("hello") == "hello"
assert _nbt_text({"text": "hi"}) == "hi"
assert _nbt_text({"raw": "raw!"}) == "raw!"
# ★ 真实形状(线上抓的):translate + with(含空键参数)
got = _nbt_text({"translate": "commands.give.success.single",
"with": [{"": "1"}, {"translate": "block.minecraft.dirt"},
{"": "mcfold_act"}]})
assert got == "Gave 1 Dirt to mcfold_act", repr(got)
# 取不出 ⇒ 原样返回(**不**返回空串:空串会被读成「什么也没发生」)
weird = {"weird": {"deep": 1}}
assert _nbt_text(weird) == weird, "取不出应原样返回"
# 注:曾用 {"raw": "raw!"} 断言过 —— 那是我**凭想象编的**字段,
# 线上服务端发的是 text/translate/with/extra/hoverEvent。
# 判据不能建立在想象的输入上(否则测的是我的臆想,不是协议事实)。
assert _nbt_text(None) is None
print(" ✓ NBT 文本能提取,取不出则如实返回原值")
print(" ✓ NBT 文本按真实形状提取,取不出则如实返回原值")
# ---------- session 接线 ----------