修复状态层撒谎:自身位置是客户端权威,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 影响很大:寻路/碰撞/可达性判断全都建立在玩家位置上,
状态陈旧会让整个工具箱在错误前提下工作。
This commit is contained in:
@ -89,11 +89,29 @@ class Actions:
|
||||
return out
|
||||
|
||||
def move(self, x: float, y: float, z: float, on_ground: bool = True) -> dict[str, Any]:
|
||||
"""**只发位置**(不转视角)。
|
||||
"""移动到自己报的坐标。
|
||||
|
||||
★ 重要:这不是「传送」。服务端会做碰撞与权限校验,
|
||||
非法位置会被纠正/拉回,随后用 position 包告诉我们真值。
|
||||
所以本方法**不改**本地坐标 —— 改了就会与服务端不一致而撒谎。
|
||||
## ★★ 这里有一个我先前搞错、并实测纠正过的关键区别
|
||||
|
||||
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)))
|
||||
@ -101,8 +119,14 @@ class Actions:
|
||||
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 为准"}
|
||||
"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]:
|
||||
@ -118,8 +142,13 @@ class Actions:
|
||||
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]}
|
||||
# ★ 同上:自身位置/视角都是客户端权威 ⇒ 立刻记下
|
||||
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
|
||||
|
||||
@ -83,6 +83,10 @@ class StateSession:
|
||||
self.actions = Actions(self)
|
||||
# ★ 服务端传送确认计数(不 confirm 就会导致方块操作被静默忽略)
|
||||
self.teleports_confirmed = 0
|
||||
# ★ 自身移动后等不到「纠正」的次数 —— 用于判断本地状态是否可信。
|
||||
# 普通移动服务端**不会**回 position(那不是它的职责),
|
||||
# 所以本地位置始终是「预期值」;只有收到 position 才算被确认。
|
||||
self.pending_corrections = 0
|
||||
# 兼容旧接口:回调式监听(给非 LLM 的宿主用),事件仍会同时入 EventLog
|
||||
self._listeners: list[tuple[str, Callable[[str, Any], None]]] = []
|
||||
|
||||
@ -177,6 +181,8 @@ class StateSession:
|
||||
c = self.client
|
||||
|
||||
if name == "position":
|
||||
# ★ 收到服务端的权威位置 ⇒ 本地「预期值」得到确认/纠正
|
||||
self.pending_corrections = 0
|
||||
st.update_from_position(params)
|
||||
# ★★ M5 的关键一环(实测 2026-09-26,卡了很久):
|
||||
# 1.19+ 的 position 包带 `teleportId`,客户端**必须**回
|
||||
|
||||
38
plan.md
38
plan.md
@ -567,7 +567,7 @@ shot.rough → ASCII + 线索(给 agent 看它为什么选了这个角度)
|
||||
| **M4** | **事件层**:粗粒度 + 细分层(照 mineflayer,省 token) | M2 | ✅ |
|
||||
| **M5** | **操作层**:chat / look / move / dig / place(能改世界) | M2, M3 | ✅ |
|
||||
| **M6** | **粗渲染**(常驻感知):基于 M3 的真状态 | M3 | ✅ |
|
||||
| **M7** | **LLM 友好的 API 形状**(§1d):自省 / 可读返回 / 限量 | M3–M6 | ⬜ |
|
||||
| **M7** | **LLM 友好的 API 形状**(§1d):自省 / 可读返回 / 限量 | M3–M6 | ✅ |
|
||||
| **M8** | **细渲染**(单次调用):出图,带光影 | M2 | ⬜ |
|
||||
| **M9** | 第一个用例:`capture_photo()`(粗搜机位 → 细出图) | M6, M8 | ⬜ |
|
||||
|
||||
@ -750,6 +750,42 @@ look(90,45) / select_slot / jump / sneak / sprint / swing 均被服务端接受
|
||||
「未知格」判据只构造了**未加载**路径,覆盖面不足 ⇒ 补上「已加载但缺残注册表」
|
||||
的场景后才抓住。**变异抓不到时要先怀疑判据覆盖面,而不是以为实现没问题。**
|
||||
|
||||
### M7 实测结论(2026-09-26)
|
||||
|
||||
新增 `mcfold/api.py`:**23 个工具**(观察 14 / 事件 1 / 操作 7 / 会话 2),
|
||||
外加 `call(name, **kwargs)` 派发入口。
|
||||
|
||||
| 维度 | 做法 | 判据 |
|
||||
|---|---|---|
|
||||
| 参数 | **全部 keyword-only**(坐标必须按名字传) | 反射检查参数种类 |
|
||||
| 返回 | 每条结果带 `describe`(人类可读摘要) | 12 个观察工具逐个查 |
|
||||
| 失败 | **永不抛异常**:`ok:false` + `why` + `how_to` | 10 组垃圾输入全要结构化答复 |
|
||||
| 规模 | 限量 + `total`/`remaining`/`truncated` | 事件限量报剩余、超限拒绝并说明 |
|
||||
| 发现 | `describe()` 一次列全工具 + 约定 + 4 条提示 | 目录与实现**双向一致** |
|
||||
|
||||
**实测(真服务器)**:
|
||||
```
|
||||
status() phase=play protocol=767 chunks=9 entities=4 decode_failures=1
|
||||
where_am_i() 在 (-2.5,-60.0,5.5) 朝向南(+Z),踩着 grass_block
|
||||
block_at(9999,60,9999) 未加载(该区块还没收到) ← 不谎称空气
|
||||
inventory() 手上(第 0 格): item#1(1.20.5+ 不传物品名)
|
||||
dig(x=..,y=..,z=..) ok=True → events 收到 block_changed
|
||||
```
|
||||
|
||||
**判据抓到的真问题**:
|
||||
1. **坐标工具允许位置传参**(`block_at(0,0,0)`)⇒ LLM 看到 `0,0,0`
|
||||
不知道哪个是 x/z。已把**全部工具改成 keyword-only**。
|
||||
2. 我第一版判据写成「不能有必填参数」,把「坐标必需」误判成违规
|
||||
⇒ 判据改为检查**参数种类**(关键字专用)—— 这两件事不是一回事。
|
||||
3. 5 项变异全部被抓(异常外泄 / 未知工具抛 KeyError / 物品编名字 /
|
||||
不报剩余 / 自省少列工具)。
|
||||
|
||||
**★ 一条操作纪律(我自己的失误)**:变异测试时用 `/tmp/state.bak` 恢复文件,
|
||||
那份备份是**过期**的(少了 M6 新增的 113 行)⇒ 把好代码覆盖坏了,
|
||||
且让其中一个变异「看起来被抓了」——其实是坏代码造成的。
|
||||
⇒ **恢复文件必须用 git(已知正确版本)**,且**每个变异单独确认基线是绿的**。
|
||||
修好基线后重做该变异,才确认判据真的有效。
|
||||
|
||||
**建议顺序:M0 → M1 → M2**。
|
||||
- M0/M1 是解阻断(M1 是我已经查到根因的那个 classpath bug)
|
||||
- **M2 是真正的门槛**:从「能渲染」到「能玩」,缺的不是渲染,是**完整协议层**。
|
||||
|
||||
@ -35,6 +35,12 @@ def _client():
|
||||
return c
|
||||
|
||||
|
||||
class _FakeChunk:
|
||||
"""占位区块:让 dig 的「已加载」前置检查通过。"""
|
||||
def state_id_at(self, x, y, z):
|
||||
return 9
|
||||
|
||||
|
||||
def _bare():
|
||||
"""一个不联网的 StateSession,能捕获发出的包。"""
|
||||
from mcfold.state import load_vanilla_registry
|
||||
@ -46,6 +52,7 @@ def _bare():
|
||||
s.client.send = lambda b: s.sent.append(b)
|
||||
s._listeners = []
|
||||
s.teleports_confirmed = 0
|
||||
s.pending_corrections = 0
|
||||
return s
|
||||
|
||||
|
||||
@ -197,19 +204,52 @@ def test_place_warns_when_held_unknown():
|
||||
print(" ★ 未知/空/有物 三种状态都如实报告")
|
||||
|
||||
|
||||
def test_move_does_not_falsely_update_local_state():
|
||||
"""★ move 只发意图,**不改**本地坐标。
|
||||
def test_move_updates_local_state_because_client_is_authoritative():
|
||||
"""★★ move **必须**更新本地坐标 —— 自身位置是**客户端权威**。
|
||||
|
||||
改了就会与服务端不一致而撒谎(服务端会做碰撞校验并把玩家拉回)。
|
||||
这条判据我第一版写反了(写成「不乐观改」),是**错的**。
|
||||
实测证据(2026-09-26,M5 之后才发现):
|
||||
|
||||
本地以为: (0.5, -60.0, -3.5) ← 连发 5 次 move 后仍是旧值
|
||||
服务端认为: (5.5, -60.0, -3.5) ← 真的动了 5 格
|
||||
|
||||
⇒ MC 里自身位置由客户端自己算、自己报;服务端只做校验,
|
||||
**不会**在每次普通移动后回发 position(那包只在传送/纠正时发)。
|
||||
不更新本地状态 ⇒ 状态层永久陈旧 ⇒ 寻路、碰撞判断全建立在错位置上。
|
||||
|
||||
★ 但**改世界**(dig/place)恰好相反:服务端权威,那里**不能**本地改。
|
||||
"""
|
||||
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 不乐观改本地状态(真值以服务端为准)")
|
||||
assert after == (100.0, -60.0, 100.0), \
|
||||
f"move 后本地坐标未更新:{after}(服务端会发 position 纠正)"
|
||||
assert r["pending_server_correction"] is True, r
|
||||
assert "客户端权威" in r["note"], r
|
||||
# 待确认计数增加(尚未收到服务端 position)
|
||||
assert s.pending_corrections == 1, s.pending_corrections
|
||||
print(" ★★ move 更新本地状态(客户端权威)且标记待确认")
|
||||
|
||||
|
||||
def test_dig_place_do_not_touch_local_world_state():
|
||||
"""★ 与 move 相反:**改世界**等服务端权威,本地不动。
|
||||
|
||||
本地擅自改方块 = 撒谎:服务端可能拒绝(没权限/放不下),
|
||||
而 agent 会以为已经成功。
|
||||
"""
|
||||
s = _bare()
|
||||
s.state.player.entity_id = 42
|
||||
s.state.player.held_slot = 0
|
||||
for _i in range(9):
|
||||
s.state.hotbar[_i] = {"itemCount": 1, "itemId": 1}
|
||||
s.state.world.chunks[(0, 0)] = _FakeChunk()
|
||||
a = Actions(s)
|
||||
before = dict(s.state.overrides)
|
||||
a.dig(0, 0, 0)
|
||||
a.place(0, 0, 0)
|
||||
assert s.state.overrides == before, "dig/place 不该改本地方块状态"
|
||||
print(" ★ dig/place 不改本地方块(等服务端 block_changed)")
|
||||
|
||||
|
||||
# ---------- 背包与手持物 ----------
|
||||
@ -328,7 +368,8 @@ if __name__ == "__main__":
|
||||
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_move_updates_local_state_because_client_is_authoritative,
|
||||
test_dig_place_do_not_touch_local_world_state,
|
||||
test_item_name_handles_real_slot_shape,
|
||||
test_hotbar_index_mapping,
|
||||
test_render_uses_real_server_shapes,
|
||||
|
||||
Reference in New Issue
Block a user