Files
agentmc/tests/test_events.py
JianFeeeee 1a6f197572 M5:操作层(真能改世界)+ 16 项判据
新增 mcfold/actions.py(16 个操作:look/move/move_and_look/look_at/
jump/sneak/sprint/select_slot/use_item/swing/dig/place/interact_block/
attack/use_entity/interact_entity_at + chat/command)
新增 mcfold/textcomp.py(1.20.3+ 的 NBT 文本组件渲染,查 language.json)

★★ 最关键发现:teleport_confirm 是必需前置包
1.19+ 的 position 带 teleportId,必须回 confirm,否则服务端**默默忽略所有方块操作**。
症状是「操作没反应、零报错」,与权限/物品/位置问题无法区分。
定位过程:先猜了 5 轮(包名/字段顺序/face 类型/sequence/位置与面)都不对;
最后拿 minecraft-protocol 对照,发现它也失败 ⇒ 排除我的包有问题;
再用 data get entity 排除权限与物品 ⇒ 补 confirm 后立刻成功。

其他真 bug:
- packet_interact/swing/animation 在 1.21.1 都不存在(应是 use_entity/arm_animation)
- dig 原写成 block_place + 多余 varint(应为独立 packet_block_dig)→ 断连
- use_entity(mouse=0) 也带 hand 字段,我漏了 → 少 1 字节
- 背包字段是 itemId(驼峰),我写 item_id → 永远判「空手」
- window0 的 slot 36..44 才是热键栏
- 聊天 NBT 组件三重漏(空串/键名/空键参数)→ 现在正确渲染人话

判据:126 项全通过;5 项变异全被抓。核心判据「16 个包能被官方 protodef
精确解回(0 余字节)」—— 唯一能离线证明「包写对了」的办法
2026-09-26 12:44:08 +08:00

333 lines
13 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.

"""M4 事件层判据:events.py + session 的事件接线。
## 为什么这层的判据特别要「反直觉」式断言
事件层最危险的缺陷是**静默**:
- 事件丢了 → agent 以为世界没变(比报错糟)
- 游标没推进 → agent 反复看同一批(浪费 token 且做出重复动作)
- 细分层混进粗粒度 → 每次观察几百条噪声(烧 token)
- 事件里字段名与渲染器不一致 → 显示 `None`(看着有事件,其实没信息)
所以判据都对着这些「静默退化」写,且**有反向对照**
(负向输入必须报错/拒绝,不能静默通过)。
"""
import sys, os
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from mcfold.events import EventLog, render_events, COARSE_KINDS
from mcfold.state import WorldState
from mcfold.chunk import BlockRegistry
from mcfold.session import StateSession, _nbt_text
def _reg():
from mcfold.state import load_vanilla_registry
return load_vanilla_registry(
'1.21.1',
'/home/newqqagent/plugin_data/mc/runtime/node_modules/minecraft-data/minecraft-data/data')
def _bare():
sess = StateSession.__new__(StateSession)
sess.state = WorldState(registry=_reg(), num_sections=24)
sess.events = EventLog()
sent = []
sess.client = type("C", (), {
"_serverbound_id": staticmethod(lambda *a: 0x04),
"send": staticmethod(lambda b: sent.append(b)),
})()
sess._listeners = []
sess._sent = sent
return sess
# ---------- 缓冲与游标 ----------
def test_drain_is_idempotent_and_advances_cursor():
"""★ 游标必须幂等:同一时刻反复 drain 不会拿到重复事件。
这是 token 与正确性的双关:重复读会让 agent 重复动作。
"""
log = EventLog()
for i in range(5):
log.emit("chat", {"text": f"m{i}"})
a = log.drain()
assert len(a) == 5
b = log.drain()
assert b == [], f"第二次排空应为空,实际 {b}"
assert log.cursor == log.seq
# peek 不消费:★ 判据要点是「peek 前后 cursor 不变」,
# 而不是 cursor==seq(emit 新事件后 seq 会比 cursor 大,那是正常的)
log.emit("chat", {"text": "later"})
before = log.cursor
assert len(log.peek()) == 1
assert len(log.peek()) == 1 # peek 可反复看
assert log.cursor == before # ★ peek 绝不推进游标
assert len(log.drain()) == 1
assert log.cursor == log.seq
print(" ★ drain 幂等且推进游标,peek 不消费")
def test_drain_limit_keeps_backlog():
"""★ limit 只是「本轮取多少」,**不是丢弃**:剩下的留给下次。"""
log = EventLog()
for i in range(10):
log.emit("block_changed", {"x": i})
first = log.drain(limit=4)
assert len(first) == 4
second = log.drain(limit=4)
assert len(second) == 4
third = log.drain(limit=4)
assert len(third) == 2
assert fourth_empty(log) is True
# 且序号连续,没有重复或跳过
all_seen = [e["seq"] for e in first + second + third]
assert all_seen == list(range(1, 11)), all_seen
print(" ★ limit 不丢事件(剩 6 条分两轮取完,序号连续)")
def fourth_empty(log):
return log.drain() == []
def test_fine_layer_separate_and_queryable():
"""★ 细分层(block_changed:x:y:z)不得混进粗粒度流。"""
log = EventLog()
log.emit_change("block_changed", "block_changed:1:2:3", {"x": 1, "y": 2, "z": 3})
log.emit_change("block_changed", "block_changed:4:5:6", {"x": 4, "y": 5, "z": 6})
coarse = log.drain()
assert len(coarse) == 2 and all(e["kind"] == "block_changed" for e in coarse)
fine = log.drain_fine("block_changed:")
assert len(fine) == 2
assert [e["kind"] for e in fine] == ["block_changed:1:2:3", "block_changed:4:5:6"]
# 按坐标前缀筛
one = log.drain_fine("block_changed:4:5:6")
assert len(one) == 1
# ★ 查细分层**不推进**粗粒度游标
assert log.drain() == []
print(" ★ 细分层独立可查,不污染粗粒度流")
def test_capacity_drops_are_counted_not_hidden():
"""★ 环形缓冲满了会丢,但必须**如实计数**。
静默丢 = agent 以为世界没发生事(最坏的撒谎)。
"""
log = EventLog(capacity=4)
for i in range(10):
log.emit("chat", {"text": str(i)})
st = log.stats()
assert st["dropped"] == 6, st
got = log.drain()
assert len(got) == 4
assert got[0]["data"]["text"] == "6", got[0] # 最旧的已丢
print(" ★ 容量溢出时丢最旧且 dropped 如实计数(6)")
def test_reset_cursor_skips_history():
log = EventLog()
for i in range(3):
log.emit("chat", {"text": str(i)})
log.reset_cursor()
assert log.drain() == []
log.emit("chat", {"text": "new"})
assert len(log.drain()) == 1
print(" ✓ reset_cursor 跳过历史(agent 明确不关心之前)")
# ---------- 渲染 ----------
def test_render_block_change_has_real_names():
"""★ 事件里的方块必须是**名字**不是 id,且 from/to 都不能为 None。
回归:先前我发的事件键叫 to_state_id 而渲染器读 to ⇒ 显示
「grass_block → None」——看着有事件,其实没信息。
"""
sess = _bare()
sess._apply("block_change", {
"location": {"x": 9, "y": 64, "z": -3},
"type": 1, # stone
})
evs = sess.events.drain()
bc = [e for e in evs if e["kind"] == "block_changed"]
assert bc, evs
d = bc[0]["data"]
assert d["x"] == 9 and d["y"] == 64 and d["z"] == -3, d
# ★ 名字必须解出来了(不是 id 也不是 None)
assert d["to"] == "stone", d
assert d["to"] is not None and "unknown" not in str(d["to"]), d
txt = render_events(bc)
assert "→ stone" in txt, txt
assert "None" not in txt, f"渲染里出现 None:{txt}"
print(f" ★ 方块变化事件带真实名字({txt.splitlines()[0]})")
def test_render_unknown_block_is_honest():
"""认不出的方块要显示 unknown(id),不能编个名字。"""
sess = _bare()
# 用一张几乎空的注册表,逼出未知
sess.state.world.registry = BlockRegistry(
[{"name": "stone", "minStateId": 1, "maxStateId": 1}])
sess._apply("block_change", {"location": {"x": 0, "y": 0, "z": 0}, "type": 9999})
d = [e for e in sess.events.drain() if e["kind"] == "block_changed"][0]["data"]
assert d["to_state_id"] == 9999
assert d["to"] is None, d
# 状态层把它记成未知(供上层告警)
assert 9999 in sess.state.world.unknown_state_ids
print(" ★ 未知方块不编名字(to=None 且记账)")
def test_render_is_compact():
"""渲染要省 token:短行、无冗余 JSON 结构。"""
log = EventLog()
log.emit("block_changed", {"x": 1, "y": 2, "z": 3, "from": "air", "to": "stone"})
log.emit("chat", {"from": "Alice", "text": "hi"})
log.emit("entity_spawned", {"kind": "pig", "entity_id": 7})
log.emit("player_moved", {"x": 1.0, "y": 2.0, "z": 3.0})
txt = render_events(log.drain())
assert "方块变化 @(1,2,3) air → stone" in txt, txt
assert "聊天 Alice: hi" in txt, txt
assert "出现 pig id=7" in txt, txt
assert "我移动到 (1.0,2.0,3.0)" in txt, txt
# ★ 断言「不用 JSON 大括号」——这正是省 token 的关键
assert "{" not in txt and '"' not in txt, f"混进了 JSON:{txt}"
print(f" ✓ 渲染紧凑(4 事件 {len(txt)} 字符,无 JSON 噪声)")
def test_unknown_event_not_silently_swallowed():
"""未知事件必须出现在渲染里(agent 能看到有东西发生了)。"""
log = EventLog()
log.emit("some_new_thing", {"a": 1})
txt = render_events(log.drain())
assert "some_new_thing" in txt, txt
print(" ★ 未知事件如实渲染(不静默吞)")
def test_coarse_kinds_are_stable_contract():
"""COARSE_KINDS 是给 agent 的稳定契约,不该随意变动。"""
for k in ("block_changed", "chat", "entity_spawned", "player_moved",
"damage", "joined", "chunk_loaded"):
assert k in COARSE_KINDS, k
# 细分层命名不应混进粗粒度契约
assert not any(":" in k for k in COARSE_KINDS), COARSE_KINDS
print(f" ✓ 粗粒度契约稳定({len(COARSE_KINDS)} 种,名称无冒号)")
# ---------- NBT 文本提取 ----------
def test_nbt_text_extracts_or_reports_honestly():
"""★ 取不出文本时返回原值,**不返回空串**。
chat 是 agent 判断局势的重要输入;静默变「(空)」比看不懂更糟。
"""
assert _nbt_text("hello") == "hello"
assert _nbt_text({"text": "hi"}) == "hi"
# ★ 真实形状(线上抓的):translate + with(含空键参数)
got = _nbt_text({"translate": "commands.give.success.single",
"with": [{"": "1"}, {"translate": "block.minecraft.dirt"},
{"": "mcfold_act"}]})
assert got == "Gave 1 Dirt to mcfold_act", repr(got)
# 取不出 ⇒ 原样返回(**不**返回空串:空串会被读成「什么也没发生」)
weird = {"weird": {"deep": 1}}
assert _nbt_text(weird) == weird, "取不出应原样返回"
# 注:曾用 {"raw": "raw!"} 断言过 —— 那是我**凭想象编的**字段,
# 线上服务端发的是 text/translate/with/extra/hoverEvent。
# 判据不能建立在想象的输入上(否则测的是我的臆想,不是协议事实)。
assert _nbt_text(None) is None
print(" ✓ NBT 文本按真实形状提取,取不出则如实返回原值")
# ---------- session 接线 ----------
def test_entity_move_uses_4096_scale():
"""★ 实体移动增量的单位是 **1/4096**,不是 1/1。
写错的症状很隐蔽:实体坐标看起来「合理」,但完全错(瞬移到远处)。
"""
sess = _bare()
from mcfold.state import Entity
sess.state.upsert_entity(Entity(entity_id=1, kind="pig", x=0.0, y=0.0, z=0.0))
sess.state.player.x = 0.0
# 4096 * 0.5 = 2048 ⇒ 应移动 0.5 格
sess._apply("rel_entity_move", {"entityId": 1, "deltaX": 2048,
"deltaY": 1024, "deltaZ": -2048})
e = sess.state.entities[1]
assert (round(e.x, 4), round(e.y, 4), round(e.z, 4)) == (0.5, 0.25, -0.5), \
(e.x, e.y, e.z)
# ★ 反向对照:若用 /1 会得到 2048 格(离谱)
assert abs(e.x) < 1.0, f"疑似未除 4096:{e.x}"
print(f" ★ 实体增量按 1/4096 换算(x={e.x}, y={e.y}, z={e.z})")
def test_damage_reduces_health():
sess = _bare()
sess.state.player.entity_id = 7
sess.state.player.health = 20.0
sess._apply("damage_event", {"entityId": 7, "damage": 6, "sourceCauseId": 1})
assert sess.state.player.health == 14.0, sess.state.player.health
evs = [e for e in sess.events.drain() if e["kind"] == "damage"]
assert evs and evs[0]["data"]["health"] == 14.0
# 血不能扣成负
sess._apply("damage_event", {"entityId": 7, "damage": 100})
assert sess.state.player.health == 0.0, sess.state.player.health
print(" ★ 伤害扣血且不会扣成负数")
def test_damage_to_others_ignored():
"""别人受伤不是「我的伤害」事件(否则 agent 会误判自己挨打)。"""
sess = _bare()
sess.state.player.entity_id = 7
sess.state.player.health = 20.0
sess._apply("damage_event", {"entityId": 999, "damage": 5})
assert sess.state.player.health == 20.0
assert not [e for e in sess.events.drain() if e["kind"] == "damage"]
print(" ✓ 别人受伤不误报为我的伤害")
def test_entity_destroy_emits_removal():
from mcfold.state import Entity
sess = _bare()
sess.state.upsert_entity(Entity(entity_id=5, kind="cow"))
sess.state.upsert_entity(Entity(entity_id=6, kind="sheep"))
sess._apply("entity_destroy", {"entityIds": [5]})
assert 5 not in sess.state.entities and 6 in sess.state.entities
rem = [e for e in sess.events.drain() if e["kind"] == "entity_removed"]
assert len(rem) == 1 and rem[0]["data"]["entity_id"] == 5
print(" ✓ 实体消失只报实际消失的那个")
def test_both_listeners_and_eventlog_fire():
"""两路并存:回调(宿主)与事件日志(LLM)都要收到。"""
sess = _bare()
got = []
sess.on("block_changed", lambda e, p: got.append(p))
sess._apply("block_change", {"location": {"x": 1, "y": 2, "z": 3}, "type": 1})
assert len(got) == 1
assert [e for e in sess.events.drain() if e["kind"] == "block_changed"]
print(" ✓ 回调与事件日志两路并存")
if __name__ == "__main__":
for fn in [test_drain_is_idempotent_and_advances_cursor,
test_drain_limit_keeps_backlog,
test_fine_layer_separate_and_queryable,
test_capacity_drops_are_counted_not_hidden,
test_reset_cursor_skips_history,
test_render_block_change_has_real_names,
test_render_unknown_block_is_honest,
test_render_is_compact,
test_unknown_event_not_silently_swallowed,
test_coarse_kinds_are_stable_contract,
test_nbt_text_extracts_or_reports_honestly,
test_entity_move_uses_4096_scale,
test_damage_reduces_health,
test_damage_to_others_ignored,
test_entity_destroy_emits_removal,
test_both_listeners_and_eventlog_fire]:
fn()
print("\n事件层:全部通过")