★★ 真 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 影响很大:寻路/碰撞/可达性判断全都建立在玩家位置上,
状态陈旧会让整个工具箱在错误前提下工作。
398 lines
17 KiB
Python
398 lines
17 KiB
Python
"""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)
|