M3:状态层(服务端真状态)+ 包流灌入 + 14 项判据

新增:
- mcfold/state.py   WorldState / WorldGeometry / PlayerView / Entity
- mcfold/session.py StateSession:connect() 走完 login→configuration→play,
                    pump() 把包落进状态,on() 留 M4 事件口

修的真 bug:
- unload_chunk 协议顺序是 chunkZ 在前、chunkX 在后;按 x 先读会坐标互换,
  卸载永不命中 ⇒ 区块只增不减(状态撒谎)

设计立场(防越界):
- 只陈述观察、不做决策(nearby_blocks 给统计不给「该挖哪个」)
- 未知 ≠ 没看见:认不出的 state id 显式记账,绝不静默映射成别的方块
- 超范围拒绝并说明,不静默缩小
- 几何从服务端 dimension_type 吸收,不写死 24/384

实测:11 注册表 / 9 区块 / 生命 20 饥饿 20 / 列剖面 bedrock→dirt→grass_block
判据:91 项全通过;4 项变异全部被抓(含「变异空操作 ⇒ 假绿」的核对)
This commit is contained in:
JianFeeeee
2026-09-26 12:21:18 +08:00
parent 547f4beb7e
commit 40b2e1d60f
4 changed files with 938 additions and 1 deletions

307
tests/test_state.py Normal file
View File

@ -0,0 +1,307 @@
"""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
sess = StateSession.__new__(StateSession)
sess.state = _state()
sess.client = type("C", (), {
"_serverbound_id": staticmethod(lambda *a: 0x26),
"send": staticmethod(lambda b: None),
})()
sess._listeners = []
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状态层:全部通过")