diff --git a/mcfold/actions.py b/mcfold/actions.py index b1de8b9..16c9219 100644 --- a/mcfold/actions.py +++ b/mcfold/actions.py @@ -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 diff --git a/mcfold/session.py b/mcfold/session.py index 500ea36..5ddb861 100644 --- a/mcfold/session.py +++ b/mcfold/session.py @@ -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`,客户端**必须**回 diff --git a/plan.md b/plan.md index 38167a6..1106206 100644 --- a/plan.md +++ b/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 是真正的门槛**:从「能渲染」到「能玩」,缺的不是渲染,是**完整协议层**。 diff --git a/tests/test_actions.py b/tests/test_actions.py index 469c6b6..84c022e 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -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,