Files
agentmc/mcfold/actions.py
JianFeeeee 69f54dc5a6 修复状态层撒谎:自身位置是客户端权威,move 必须更新本地状态
★★ 真 bug(用户问「寻路呢」时暴露出来):move() 之后本地坐标不变,
   而服务端认为玩家真的移动了。

实测证据:
  连发 5 次 move 后
    本地以为:  (0.5, -60.0, -3.5)    ← 没动
    服务端认为: (5.5, -60.0, -3.5)    ← 真的动了
  修后:本地 (10.5,-60,-3.5) == 服务端 [10.5d,-60.0d,-3.5d] ✓

根因:MC 的自身位置是**客户端权威** —— 客户端跑自己那侧物理并上报,
服务端只做校验(穿墙/超速会发 position 拉回),普通移动**不回包**。
我把这条规则错用成「等服务端权威」⇒ 本地状态永久陈旧。

与另一条的对比(别搞成一套):
  自身移动(move/look)  客户端权威 ⇒ 本地必须立刻更新,标 pending 待确认
  改世界(dig/place)    服务端权威 ⇒ 本地不动,等 block_changed 事件

★ 同时纠正一条**我自己写错的判据**:
  test_move_does_not_falsely_update_local_state(断言「不该改本地」)是错的,
  已替换为 test_move_updates_local_state_because_client_is_authoritative,
  并补 test_dig_place_do_not_touch_local_world_state 守住相反的那条。

这个 bug 影响很大:寻路/碰撞/可达性判断全都建立在玩家位置上,
状态陈旧会让整个工具箱在错误前提下工作。
2026-09-26 13:21:44 +08:00

398 lines
17 KiB
Python
Raw 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.

"""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)