Files
agentmc/tests/test_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

383 lines
16 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 操作层判据:actions.py + textcomp.py。
## 这层判据的判别标准
操作层的缺陷几乎都是**静默**的:
- 包发错 ⇒ 服务端默默忽略(不报错、不断连)
- 少了某个前置包 ⇒ 所有操作集体失效
- 字段名写错 ⇒ 一切都「没反应」
所以判据分两类,缺一不可:
1. **字节级自洽**:构造的包必须能被**官方 protodef** 解回原值
(这是唯一能离线验证「包写对了」的办法);
2. **结构级**:预置条件、夹紧、不编造 —— 这些纯本地可判。
"""
import sys, os, json, struct
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from mcfold.actions import Actions, _packed_position, _direction, _f32, _f64
from mcfold.textcomp import render_text, _humanize, flatten_with
from mcfold.protodef import Reader, Writer, for_phase
from mcfold.state import WorldState, item_name
from mcfold.client import Client
from mcfold.session import StateSession
from mcfold.events import EventLog
DATA_ROOT = ('/home/newqqagent/plugin_data/mc/runtime/node_modules/'
'minecraft-data/minecraft-data/data')
V = '1.21.1'
def _client():
c = Client(host='h', port=1, username='u', version=V)
c.load_protocol()
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
s = StateSession.__new__(StateSession)
s.state = WorldState(registry=load_vanilla_registry(V, DATA_ROOT), num_sections=24)
s.events = EventLog()
s.sent = []
s.client = _client()
s.client.send = lambda b: s.sent.append(b)
s._listeners = []
s.teleports_confirmed = 0
s.pending_corrections = 0
return s
# ---------- 字节级:包必须能被官方 protodef 解回 ----------
def test_every_packet_decodes_with_official_protodef():
"""★ 用官方 protodef 解回我构造的每个包 —— 唯一能离线证明「包写对了」的办法。
这条判据抓过的真 bug:
- dig 原本写成 packet_block_place + 2 个多余 varint(应为独立 packet_block_dig)
- interact/swing 用了 1.21.1 **不存在**的包名(packet_interact / packet_swing)
"""
s = _bare()
s.state.player.entity_id = 42
s.state.player.held_slot = 0
# ★ 9 格都放上物品:前面的 select_slot 用例会切换 held_slot,
# 若只填 0 号格,后面 place 会因「手上是空槽」被前置检查拦下
# —— 那时测的是「被拦下」,而不是「包写对了」。
for _i in range(9):
s.state.hotbar[_i] = {"itemCount": 1, "itemId": 1}
# ★ dig 有「区块未加载就拒发」的前置检查 ⇒ 测试里要放一个假区块,
# 否则测的是「被拦下」而不是「包写对了」。
class _FakeChunk:
def state_id_at(self, x, y, z):
return 9
s.state.world.chunks[(0, 0)] = _FakeChunk()
s.state.world.chunks[(7 >> 4, 10 >> 4)] = _FakeChunk()
a = Actions(s)
pd = for_phase(s.client.proto, "play", "toServer")
cases = [
("look", lambda: a.look(90, 45)),
("move", lambda: a.move(1.5, -60.0, 2.5)),
("move_look", lambda: a.move_and_look(1, 2, 3, 10, 20)),
("jump", lambda: a.jump()),
("sneak", lambda: a.sneak(True)),
("sprint", lambda: a.sprint(True)),
("select_slot", lambda: a.select_slot(3)),
("use_item", lambda: a.use_item()),
("swing", lambda: a.swing()),
("dig", lambda: a.dig(7, -61, 10, face="up")),
("dig_finish", lambda: a.dig(7, -61, 10, face="up", finish=True)),
("place", lambda: a.place(7, -61, 10, face="up")),
("interact_blk",lambda: a.interact_block(7, -61, 10)),
("attack", lambda: a.attack(7)),
("use_entity", lambda: a.use_entity(7)),
("interact_at", lambda: a.interact_entity_at(7, 1, 2, 3)),
]
for label, fn in cases:
s.sent.clear()
fn()
assert len(s.sent) == 1, f"{label}: 期望发 1 个包,实发 {len(s.sent)}"
# ★ 关键:用**同一个 Reader** 解完并检查是否正好读完
# (另起一个 Reader 量 remaining 是我先前写错的地方:
# 那个 Reader 从未读过,pos 恒为 0,left 恒等于包长)
rr = Reader(s.sent[0])
dec = pd.read("packet", rr, {})
assert dec.get("name"), f"{label}: 解不出包名 → {dec}"
left = rr.remaining()
assert left == 0, f"{label}: 解完还剩 {left} 字节(多写字段或包体超长)"
print(f" ★ {len(cases)} 个操作的包都能被官方 protodef 精确解回(0 余字节)")
def test_packed_position_roundtrip():
"""★ 打包坐标必须与官方 position 解码**互逆**(含负数与极值)。"""
pd = for_phase(_client().proto, "play", "toServer")
for (x, y, z) in [(0, 0, 0), (6, -60, 10), (-2, -61, -5),
(1000, 320, -1000), (-2_000_000, 319, 2_000_000)]:
b = _packed_position(x, y, z)
assert len(b) == 8, len(b)
got = pd.read("position", Reader(b), {})
assert (got["x"], got["y"], got["z"]) == (x, y, z), \
f"({x},{y},{z}) 往返不一致 → {got}"
print(" ★ 打包坐标与官方解码互逆(含负数/极值)")
def test_direction_mapping():
"""面名 → 协议编号:0=down 1=up 2=north 3=south 4=west 5=east。"""
c = _client()
expect = {"down": 0, "up": 1, "north": 2, "south": 3, "west": 4, "east": 5}
for face, n in expect.items():
assert _direction(c, face) == n, f"{face} → {_direction(c, face)},期望 {n}"
# 别名
assert _direction(c, "top") == 1 and _direction(c, "bottom") == 0
print(f" ★ 六个面映射正确({expect})")
# ---------- 行为级:不编造、夹紧、预置条件 ----------
def test_pitch_is_clamped_and_reported():
"""★ 超界 pitch 必须夹住**并告知**(不是静默改)。"""
s = _bare()
a = Actions(s)
r = a.look(0, 200)
assert r["pitch"] == 90.0 and "clamped" in r, r
r2 = a.look(0, -200)
assert r2["pitch"] == -90.0 and "clamped" in r2, r2
r3 = a.look(0, 30)
assert "clamped" not in r3, "正常值不该报 clamped"
# yaw 取模
assert a.look(370, 0)["yaw"] == 10.0
print(" ★ pitch 夹紧并如实报告,yaw 正确取模")
def test_invalid_slot_rejected_before_sending():
"""★ 非法槽位**不发送**(预置条件检查在本地拦下)。"""
s = _bare()
a = Actions(s)
s.sent.clear()
r = a.select_slot(99)
assert r["ok"] is False and "0..8" in r["reason"], r
assert s.sent == [], "预置条件失败时不该发包"
a.select_slot(8)
assert len(s.sent) == 1, "合法槽位应发送"
print(" ★ 非法槽位本地拦下(不发包)")
def test_dig_refuses_on_unloaded_chunk():
"""★ 区块没加载就别发挖掘意图(发了也无效,且浪费往返)。"""
s = _bare()
a = Actions(s)
s.sent.clear()
r = a.dig(1000, 60, 1000) # 无区块
assert r["ok"] is False, r
assert "未加载" in r["reason"], r
assert s.sent == [], "不应发包"
print(" ★ 未加载区块的挖掘被本地拒绝")
def test_place_warns_when_held_unknown():
"""★ 手上是**未知**时必须 warning —— 因为症状与「服务端拒绝」无法区分。"""
s = _bare()
a = Actions(s)
s.sent.clear()
r = a.place(0, 0, 0)
assert r["ok"] is False and "set_slot" in r["reason"], r
# 已知为空槽
s.state.hotbar[0] = None
s.state.slots[36] = {"itemCount": 0, "itemId": 0}
r2 = a.place(0, 0, 0)
assert "warning" in r2, r2
# 已知有东西(★ 同时要把 held_slot 指回 0:上一例把它留在了空槽 1)
s.state.player.held_slot = 0
s.state.hotbar[0] = {"itemCount": 64, "itemId": 1}
r3 = a.place(0, 0, 0)
assert r3["ok"] and "warning" not in r3 and r3["held"], r3
print(" ★ 未知/空/有物 三种状态都如实报告")
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)
r = a.move(100.0, -60.0, 100.0)
after = (s.state.player.x, s.state.player.y, s.state.player.z)
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)")
# ---------- 背包与手持物 ----------
def test_item_name_handles_real_slot_shape():
"""★ 实测 1.21.1 字段是 **itemId**(驼峰);写错就永远判「空手」。"""
assert item_name({"itemCount": 31, "itemId": 1}) == "item#1"
assert item_name({"itemCount": 1, "item_id": "minecraft:stone"}) == "minecraft:stone"
assert item_name({"itemCount": 0, "itemId": 0}) is None, "空槽应是 None"
assert item_name(None) is None
assert item_name({}) is None, "无 itemId 时不编名字"
print(" ★ itemId / item_id / 空槽 / 缺失 四种都对")
def test_hotbar_index_mapping():
"""★ window0 的 slot 36..44 才是热键栏(0..8)。搞错就看错物品。"""
s = _bare()
s.state.set_held_slot_item(36, {"itemCount": 5, "itemId": 2})
s.state.set_held_slot_item(44, {"itemCount": 7, "itemId": 3})
s.state.player.held_slot = 0
assert s.state.held_item_name() == "item#2"
s.state.player.held_slot = 8
assert s.state.held_item_name() == "item#3"
assert s.state.hotbar_item_name(0) == "item#2"
assert s.state.hotbar_item_name(3) is None
print(" ★ 热键栏 36..44 ↔ 0..8 映射正确")
# ---------- 文本组件(1.20.3+ 聊天) ----------
def test_render_uses_real_server_shapes():
"""★ 用**线上真实抓到的** NBT 形状(不是我想象的)。
三个真实 bug 都出在这:
1. 只取 `text` ⇒ 拿到 ""(组件的 text 常为空串)
2. translate 原样吐 ⇒ agent 看到 commands.give.success.single 这种键名
3. with 里数字存成 {"": 1}(空键)没读 ⇒ “Gave Dirt to ”
"""
assert render_text({"color": "red", "extra": [{"translate":
"command.unknown.command"}], "text": ""}) \
== "Unknown or incomplete command, see below for error"
got = render_text({"translate": "commands.give.success.single", "with": [
{"": "1"}, {"translate": "block.minecraft.dirt"}, {"": "mcfold_act"}]})
assert got == "Gave 1 Dirt to mcfold_act", repr(got)
got2 = render_text({"translate": "commands.setblock.success", "with": [
{"": -2}, {"": -61}, {"": -5}]})
assert got2 == "Changed the block at -2, -61, -5", repr(got2)
print(f" ★ 真实形状渲染正确({got!r})")
def test_render_never_returns_empty_for_unknown():
"""★ 取不到就保留键名,**绝不**返回空串(空串=「什么也没发生」)。"""
assert render_text({"translate": "no.such.key.xyz"}) == "⟨no.such.key.xyz⟩"
assert render_text(None) == ""
assert render_text("bare") == "bare"
assert render_text({"hoverEvent": {"contents": {"id": "minecraft:stone"}}}) == "Stone"
print(" ★ 未知键保留原样(不静默变空)")
def test_render_survives_deep_nesting():
"""深嵌套不能栈溢出(服务端数据不可信)。"""
comp = {"text": "x"}
for _ in range(40):
comp = {"extra": [comp]}
out = render_text(comp)
assert isinstance(out, str) and ("⟨" in out or "x" in out)
print(" ✓ 深嵌套安全降级(不崩溃)")
def test_humanize():
assert _humanize("minecraft:stone") == "Stone"
assert _humanize("item.minecraft:diamond_sword") == "Diamond sword"
assert _humanize("block.minecraft:red_flower") == "Red flower"
print(" ✓ 物品 id 人性化")
def test_flatten_with():
args = [{"": 1}, {"translate": "block.minecraft.stone"}]
assert flatten_with(args) == ["1", "Stone"], flatten_with(args)
assert flatten_with(None) == []
print(" ✓ with 参数摊平(含空键数字与 translate)")
# ---------- 决定性:teleport_confirm(这层最隐蔽的必需前置包) ----------
def test_teleport_confirm_sent_on_position():
"""★★ 1.19+ 必须在 position 后回 teleport_confirm。
不回 ⇒ 服务端**默默忽略所有方块操作**(dig/place 全无效、不报错)。
症状与「权限不足」完全无法区分,是本层最隐蔽的必需前置包。
回归:手工补发 confirm 后 place 立刻成功(air → stone)。
"""
s = _bare()
s._apply("position", {"x": 1.0, "y": 2.0, "z": 3.0, "yaw": 0, "pitch": 0,
"teleportId": 7})
assert len(s.sent) == 1, s.sent
raw = s.sent[0]
assert raw[0] == s.client._serverbound_id("play", "packet_teleport_confirm")
assert raw[1] == 7, raw[:4].hex()
assert s.teleports_confirmed == 1
# ★ 反向对照:teleportId 缺失(老版本)时不得乱发
s.sent.clear()
s._apply("position", {"x": 1.0, "y": 2.0, "z": 3.0, "yaw": 0, "pitch": 0})
assert s.sent == [], "无 teleportId 时不该发 confirm"
print(" ★★ position 必回 teleport_confirm;无 id 时不乱发")
if __name__ == "__main__":
for fn in [test_every_packet_decodes_with_official_protodef,
test_packed_position_roundtrip,
test_direction_mapping,
test_pitch_is_clamped_and_reported,
test_invalid_slot_rejected_before_sending,
test_dig_refuses_on_unloaded_chunk,
test_place_warns_when_held_unknown,
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,
test_render_never_returns_empty_for_unknown,
test_render_survives_deep_nesting,
test_humanize,
test_flatten_with,
test_teleport_confirm_sent_on_position]:
fn()
print("\n操作层:全部通过")