From 1a6f197572aaef866dc793335b1c67ab3d8e1c99 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Sat, 26 Sep 2026 12:44:08 +0800 Subject: [PATCH] =?UTF-8?q?M5=EF=BC=9A=E6=93=8D=E4=BD=9C=E5=B1=82=EF=BC=88?= =?UTF-8?q?=E7=9C=9F=E8=83=BD=E6=94=B9=E4=B8=96=E7=95=8C=EF=BC=89+=2016=20?= =?UTF-8?q?=E9=A1=B9=E5=88=A4=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 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 余字节)」—— 唯一能离线证明「包写对了」的办法 --- mcfold/actions.py | 368 ++++++++++++++++++++++++++++++++++++++++++ mcfold/protodef.py | 4 + mcfold/session.py | 56 +++++-- mcfold/state.py | 65 ++++++++ mcfold/textcomp.py | 144 +++++++++++++++++ plan.md | 47 +++++- tests/test_actions.py | 341 ++++++++++++++++++++++++++++++++++++++ tests/test_events.py | 12 +- 8 files changed, 1021 insertions(+), 16 deletions(-) create mode 100644 mcfold/actions.py create mode 100644 mcfold/textcomp.py create mode 100644 tests/test_actions.py diff --git a/mcfold/actions.py b/mcfold/actions.py new file mode 100644 index 0000000..b1de8b9 --- /dev/null +++ b/mcfold/actions.py @@ -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) diff --git a/mcfold/protodef.py b/mcfold/protodef.py index 9646460..a6760e7 100644 --- a/mcfold/protodef.py +++ b/mcfold/protodef.py @@ -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) diff --git a/mcfold/session.py b/mcfold/session.py index 32de384..500ea36 100644 --- a/mcfold/session.py +++ b/mcfold/session.py @@ -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")}) diff --git a/mcfold/state.py b/mcfold/state.py index 368a2a9..2d4e7d4 100644 --- a/mcfold/state.py +++ b/mcfold/state.py @@ -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 \ No newline at end of file diff --git a/mcfold/textcomp.py b/mcfold/textcomp.py new file mode 100644 index 0000000..fb2cf11 --- /dev/null +++ b/mcfold/textcomp.py @@ -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] diff --git a/plan.md b/plan.md index 254fa36..37f1c14 100644 --- a/plan.md +++ b/plan.md @@ -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 是真正的门槛**:从「能渲染」到「能玩」,缺的不是渲染,是**完整协议层**。 diff --git a/tests/test_actions.py b/tests/test_actions.py new file mode 100644 index 0000000..469c6b6 --- /dev/null +++ b/tests/test_actions.py @@ -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操作层:全部通过") diff --git a/tests/test_events.py b/tests/test_events.py index 2ce8a9d..a14b112 100644 --- a/tests/test_events.py +++ b/tests/test_events.py @@ -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 接线 ----------