新增 mcfold/events.py:EventLog(环形缓冲 + seq + 游标)
- 不做回调式推送:LLM 是回合制的,回调会让事件在两次观察之间静默丢失
- drain(limit) 只取本轮、剩余留待下次(不丢)
- 细分层(block_changed❌y:z)独立可查,不污染粗粒度流
- render_events() 输出紧凑文本行,省 token
session 接线:block_change / multi_block_change / chat / damage /
entity spawn·move·destroy / held_item_slot,并加 chat()/command() 发送
修的真 bug:
- dropped 恒 0:deque(maxlen=) 丢元素无声 ⇒ 改手动计数(事件丢了必须说)
- 事件键 to_state_id 与渲染器的 to 不一致 ⇒ 显示 None,改为解出方块名;
from 区分「未加载/界外」而非裸 None
- run_tests.sh 只数 ✓ 不数 ★ ⇒ 判据少报(98 修正为 110)
实测:/setblock 后事件正确报 grass_block→stone→air,与状态层一致
判据:110 项全通过;4 项变异全部被抓
325 lines
13 KiB
Python
325 lines
13 KiB
Python
"""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"
|
||
assert _nbt_text({"raw": "raw!"}) == "raw!"
|
||
weird = {"weird": {"deep": 1}}
|
||
assert _nbt_text(weird) == weird, "取不出应原样返回"
|
||
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事件层:全部通过")
|