"""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]: """移动到自己报的坐标。 ## ★★ 这里有一个我先前搞错、并实测纠正过的关键区别 MC 的**自身位置是客户端权威**的:客户端跑自己那侧的物理, 把位置报给服务端;服务端只做**校验**(穿墙/超速会拉回)。 服务端**不会**在每次普通移动后回发 `position` —— 那个包只在 传送或纠正时发。 ⇒ 所以发完 move **必须立刻更新本地坐标**,否则本地状态会 永久停在旧值(实测:我发了 5 次移动,本地一直显示 (0.5,…), 而服务端认为我在 (5.5,…) —— **状态在撞谎**, 而寻路完全依赖它)。 ## 与 dig/place 的区别(别把两者搞成一套) - **自身移动**:客户端权威 ⇒ 本地先改,服务端要否会发 position 纠正 (所以只能叫“预期值”,不是“已确认”)。 - **改世界**(dig/place)**:服务端权威 ⇒ **不**本地改, 等服务端 `block_changed` 事件。 ★ 我先前给 move 写了“不乐观改本地状态”的判据 —— 那条是**错的**, 它把“服务端权威”的规则错用到“客户端权威”的移动上。 """ 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()) # ★ 客户端权威:立刻把报到值记下(服务端会用 position 包纠正错误) p = self.s.state.player p.x, p.y, p.z = float(x), float(y), float(z) self.s.pending_corrections += 1 return {"ok": True, "sent": [x, y, z], "note": "已发送并更新本地位置(自身位置客户端权威);" "若服务端不认同会发 position 纠正", "pending_server_correction": True} 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()) # ★ 同上:自身位置/视角都是客户端权威 ⇒ 立刻记下 p = self.s.state.player p.x, p.y, p.z = float(x), float(y), float(z) p.yaw, p.pitch = yaw, pitch self.s.pending_corrections += 1 out: dict[str, Any] = {"ok": True, "sent": [x, y, z, yaw, pitch], "pending_server_correction": True} 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)