Files
agentmc/tests/test_state.py
JianFeeeee d2b4c5029d M4:事件层(可按需排空的日志 + 游标)+ 16 项判据
新增 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 项变异全部被抓
2026-09-26 12:27:28 +08:00

312 lines
12 KiB
Python
Raw Permalink 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.

"""M3 状态层判据:state.py + session.py。
## 判据原则(沿用本仓纪律)
1. **能失败**:不靠「观察当前输出就算对」,而是**构造**输入并断言语义
(相对位移、总和、范围上限、x/z 顺序这类都必须有反向对照)。
2. **能区分**:「认不出的方块」与「没看见的方块」必须报不同的 source
—— 否则 agent 会把「未知」当「空地」而做出错误决策。
3. **解码器的判据用线上真实字节**(见 test_protodef 的
entity_metadata 回归)—— 单元构造的字节可能偏离真实格式。
"""
import sys, os, json
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from mcfold.state import (
WorldState, WorldGeometry, PlayerView, Entity,
load_vanilla_registry, MAX_SCAN_VOLUME, MAX_BLOCK_KINDS,
)
from mcfold.chunk import BlockRegistry
DATA_ROOT = '/home/newqqagent/plugin_data/mc/runtime/node_modules/minecraft-data/minecraft-data/data'
def _reg():
return load_vanilla_registry('1.21.1', DATA_ROOT)
def _state(reg=None):
return WorldState(registry=reg if reg is not None else _reg(), num_sections=24)
def _bare_session():
"""造一个不连网的 StateSession,只测「包 → 状态」的落状态逻辑。
★ 为什么不用 __new__:它跳过 __init__ ⇒ _listeners 不存在,
调 on() 就 AttributeError。这里显式补上必需字段。
"""
from mcfold.session import StateSession
from mcfold.events import EventLog
sess = StateSession.__new__(StateSession)
sess.state = _state()
sess.client = type("C", (), {
"_serverbound_id": staticmethod(lambda *a: 0x26),
"send": staticmethod(lambda b: None),
})()
sess._listeners = []
# ★ M4 之后 _emit 会写 EventLog,忘记建它会在「卸载区块」等路径上炸。
# 这正是「修一处要搜同类形态」的教训:新加字段时所有手搓会话都要跟上。
sess.events = EventLog()
sess._sent = []
return sess
# ---------- 几何 ----------
def test_geometry_not_hardcoded():
"""几何来自服务端,不是写死 24/384。"""
ws = _state()
assert ws.geometry.height == 384 and ws.geometry.min_y == -64
# 改成别的维度(如 y=-128 起、512 高)后应如实反映
ws.geometry.min_y, ws.geometry.height = -128, 512
assert ws.geometry.in_world(-128) and not ws.geometry.in_world(-129)
assert ws.geometry.in_world(383) and not ws.geometry.in_world(384)
assert "y∈[-128, 384)" in ws.geometry.describe()
print(" ✓ 几何不写死(可随服务端维度变)")
# ---------- position:绝对 vs 相对 ----------
def test_position_absolute_and_relative():
"""★ position 包的 flags 位决定绝对/相对 —— 必须分别处理。
这是最容易出的错:服务端做小步纠正时发**相对增量**,
若照绝对赋值,位置会瞬间跳回原点。
"""
ws = _state()
ws.update_from_position({"x": 100.0, "y": 64.0, "z": -50.0, "yaw": 90.0, "pitch": 10.0})
assert (ws.player.x, ws.player.y, ws.player.z) == (100.0, 64.0, -50.0)
# 全相对:应累加
ws.update_from_position({"x": 1.0, "y": -1.0, "z": 2.0, "yaw": 15.0, "pitch": 5.0,
"flags": {"_value": 0x1F, "x": True, "y": True, "z": True,
"yaw": True, "pitch": True}})
assert (ws.player.x, ws.player.y, ws.player.z) == (101.0, 63.0, -48.0), ws.player.as_dict()
assert ws.player.yaw == 105.0, ws.player.yaw
assert ws.player.pitch == 15.0, ws.player.pitch
# ★ 混合:只有 x 相对 ⇒ 另两个轴必须是绝对值
ws.update_from_position({"x": 5.0, "y": 70.0, "z": 999.0,
"flags": {"_value": 0x01, "x": True, "y": False,
"z": False, "yaw": False, "pitch": False}})
assert ws.player.x == 106.0, ws.player.x
assert ws.player.y == 70.0, ws.player.y # 绝对覆盖,不是累加
assert ws.player.z == 999.0, ws.player.z
print(" ✓ position 绝对/相对/混合三种都正确")
def test_pitch_clamped():
"""相对 pitch 必须夹在 ±90(否则视野会翻转/穿模)。"""
ws = _state()
ws.update_from_position({"x": 0, "y": 0, "z": 0, "yaw": 0, "pitch": 0})
ws.update_from_position({"x": 0, "y": 0, "z": 0, "yaw": 0, "pitch": 200,
"flags": {"_value": 0x10, "x": False, "y": False,
"z": False, "yaw": False, "pitch": True}})
assert ws.player.pitch == 90.0, ws.player.pitch
print(" ✓ 相对 pitch 夹紧到 ±90")
# ---------- 方块查询:未知 ≠ 没看见 ----------
def test_unknown_state_is_not_silently_mapped():
"""★ 认不出的 state id 必须报 unknown-state,**不能**映射成别的方块。
静默映射是这类工具最危险的缺陷:agent 会基于「虚构的世界」做决策。
"""
# 故意用一张缺 id=9 的注册表,模拟「本机没有这个方块」
partial = BlockRegistry([{"name": "stone", "minStateId": 1, "maxStateId": 1}])
ws = _state(partial)
# 手工塞一个 state_id=9 的区块
class FakeChunk:
def state_id_at(self, x, y, z):
return 9
ws.world.chunks[(0, 0)] = FakeChunk()
hit = ws.block_at(0, 0, 0)
assert hit["name"] is None, hit
assert hit["source"] == "unknown-state", hit["source"]
assert hit["state_id"] == 9
# 且必须被记账(供上层告警)
assert 9 in ws.world.unknown_state_ids
assert ws.summary()["has_unresolved_unknown"] is True
print(" ✓ 未知方块显式记账,不静默映射")
def test_unloaded_vs_out_of_world_distinguished():
"""「区块没加载」与「在世界外」必须报不同 source。"""
ws = _state()
assert ws.block_at(0, 0, 0)["source"] == "unloaded"
assert ws.block_at(0, 9999, 0)["source"] == "out-of-world"
# 负坐标也要成立(Python 的 >> 对负数是 floor 除,正是 MC 的语义)
assert ws.block_at(-1, 0, -1)["source"] == "unloaded"
print(" ✓ unloaded / out-of-world 可区分(负坐标亦然)")
# ---------- 受控范围 ----------
def test_nearby_blocks_refuses_oversized_scan():
"""★ 超范围要**拒绝并说明**,不能静默缩小。
静默缩小会让 agent 以为扫全了,进而做出错误规划。
"""
ws = _state()
big = ws.nearby_blocks(0, 0, 0, radius=100)
assert big["scanned"] is False
assert "超过上限" in big["error"], big
assert big["requested_radius"] == 100
# 边界内则正常(即便空)
ok = ws.nearby_blocks(0, 0, 0, radius=4)
assert ok["scanned"] is True
assert (2 * 4 + 1) ** 3 <= MAX_SCAN_VOLUME
print(" ★ 超大扫描被拒(不静默缩小)")
print(f" ✓ 正常范围扫描可用({ok['kinds']} 种方块,0 个区块时全空)")
def test_summary_is_bounded_and_honest():
"""summary 要有界、且不谎报。"""
ws = _state()
s = ws.summary()
assert s["chunks_loaded"] == 0
assert s["entities"] == 0
# 没识别出东西时 note 应为 None(不啰嗦)
assert s["note"] is None
# 块数上限生效
assert MAX_BLOCK_KINDS <= 20
print(" ✓ summary 有界且不谎报")
# ---------- 实体 ----------
def test_nearby_entities_sorted_by_distance():
ws = _state()
ws.player.x, ws.player.y, ws.player.z = 0.0, 0.0, 0.0
for eid, d in ((1, 10.0), (2, 3.0), (3, 6.0)):
ws.upsert_entity(Entity(entity_id=eid, kind="pig", x=d, y=0.0, z=0.0))
got = ws.nearby_entities(radius=16)
assert got["count"] == 3
dists = [e["distance"] for e in got["entities"]]
assert dists == sorted(dists), dists
assert got["entities"][0]["entity_id"] == 2
# 半径外的不算
assert ws.nearby_entities(radius=5)["count"] == 1
# 移除
ws.remove_entity(2)
assert ws.nearby_entities(radius=16)["count"] == 2
print(" ✓ 实体按距离排序、半径过滤、可移除")
# ---------- 注册表(从服务端来) ----------
def test_server_registries_kept_verbatim():
"""服务端注册表原样保留(权威来源,不加工)。"""
ws = _state()
dt = [{"key": "minecraft:overworld", "value": {"height": {"height": 384, "min_y": -64}}}]
ws.apply_registry("minecraft:dimension_type", dt)
assert ws.registries["minecraft:dimension_type"] is dt
s = ws.summary()
assert "minecraft:dimension_type" in s["registries_from_server"]
assert s["registry_entry_counts"]["minecraft:dimension_type"] == 1
print(" ✓ 服务端注册表原样保留并计数")
def test_geometry_absorbed_from_dimension_registry():
"""从 dimension_type 注册表吸收几何(不再写死)。"""
from mcfold.state import ingest_configuration
ws = _state()
ws.geometry = WorldGeometry(height=384, min_y=-64)
# 造一个不同高度的 overworld 条目
fake = {"minecraft:dimension_type": [
{"key": "minecraft:overworld",
"value": {"height": {"height": 512, "min_y": -128}}}]}
ingest_configuration(ws, {}, fake)
assert ws.geometry.height == 512 and ws.geometry.min_y == -128
print(" ✓ 几何可从服务端维度注册表覆盖")
# ---------- session:包 → 状态(x/z 顺序这个真 bug 的回归) ----------
def test_unload_chunk_xz_order():
"""★ unload_chunk 协议字段顺序是 **chunkZ 在前**、chunkX 在后。
回归判据:我先前把 chunkZ 塞给 cx ⇒ x/z 互换 ⇒ 卸载永不命中,
表现为区块越积越多(状态在撒谎)。
"""
sess = _bare_session()
ws = sess.state
ws.world.chunks[(5, -9)] = "sentinel"
# 协议给的是 {"chunkZ": -9, "chunkX": 5}
sess._apply("unload_chunk", {"chunkZ": -9, "chunkX": 5})
assert (5, -9) not in ws.world.chunks, f"卸载未命中:{list(ws.world.chunks)}"
# 反向对照:坐标顺序传错时**不能**误删
ws.world.chunks[(5, -9)] = "sentinel"
ws.world.chunks[(-9, 5)] = "other"
sess._apply("unload_chunk", {"chunkZ": 5, "chunkX": -9}) # 故意交换
assert (-9, 5) not in ws.world.chunks
assert (5, -9) in ws.world.chunks
print(" ★ unload_chunk 按协议顺序卸载命中(不交换)")
print(" ✓ 反向对照:坐标错位时不误删")
def test_keepalive_is_answered():
"""★ keep_alive 必须回,否则 15s 后被踢 —— 状态层的头号责任。"""
sent = []
sess = _bare_session()
sess.client.send = staticmethod(lambda b: sent.append(b))
sess._apply("keep_alive", {"keepAliveId": 12345678901234})
assert len(sent) == 1, sent
# 载荷:varint(0x26) + int64
assert sent[0][0] == 0x26, sent[0][:3].hex()
import struct
assert struct.unpack(">q", sent[0][1:9])[0] == 12345678901234
print(" ★ keep_alive 必被回应(包 id + int64 载荷)")
print(" ✓ keepAliveId 原样回填")
def test_health_and_gamemode_landed():
sess = _bare_session()
sess._apply("update_health", {"health": 7.5, "food": 3})
assert sess.state.player.health == 7.5
assert sess.state.player.food == 3
sess._apply("game_state_change", {"reason": 3, "gameMode": 1})
assert sess.state.player.gamemode == "1", sess.state.player.gamemode
print(" ✓ 生命/饥饿/gamemode 落入状态")
def test_events_emitted():
"""M4 的机制留口:事件要真发出去。"""
got = []
sess = _bare_session()
sess.on("player_moved", lambda e, p: got.append(e))
sess.on("joined", lambda e, p: got.append(e))
sess._apply("position", {"x": 1, "y": 2, "z": 3, "yaw": 0, "pitch": 0})
sess._apply("login", {"entityId": 7})
assert got == ["player_moved", "joined"], got
print(" ✓ 事件回调真的被调用(M4 机制就位)")
if __name__ == "__main__":
for fn in [test_geometry_not_hardcoded,
test_position_absolute_and_relative,
test_pitch_clamped,
test_unknown_state_is_not_silently_mapped,
test_unloaded_vs_out_of_world_distinguished,
test_nearby_blocks_refuses_oversized_scan,
test_summary_is_bounded_and_honest,
test_nearby_entities_sorted_by_distance,
test_server_registries_kept_verbatim,
test_geometry_absorbed_from_dimension_registry,
test_unload_chunk_xz_order,
test_keepalive_is_answered,
test_health_and_gamemode_landed,
test_events_emitted]:
fn()
print("\n状态层:全部通过")