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

238
mcfold/session.py Normal file
View File

@ -0,0 +1,238 @@
"""把 M2 的包流灌进 M3 的状态层(M3 的「线」)。
## 为什么单独一个模块
M2(协议)与 M3(状态)关心的事不同:
- 协议层:**这串字节是什么包**
- 状态层:**世界现在是什么样**
把「收到包 ⇒ 更新状态」单独放一处,好处:
- 状态层的每个字段都能追到它来自哪个包(可核查)
- 新增状态字段时不必碰协议解析
- 线上的坏包(解不开的)在此处统一记账,不污染状态
## 刻意的克制
只做「落状态」,**不做**:
- 预测(不打提前量、不猜实体下一步)
- 决策(不去找目标、不规划路径)
- 猜测(认不出的 state id 照实记 unknown,不映射成别的方块)
"""
from __future__ import annotations
from typing import Any, Callable
from .client import Client
from .state import Entity, WorldState, ingest_configuration
from .protodef import Writer
class StateSession:
"""一条「客户端 + 状态」的可运行会话。
## 用法
```python
with StateSession("127.0.0.1", 25566, "mcfold_test", "1.21.1") as s:
s.connect() # handshake → login → configuration → play
s.pump(2.0) # 收 2 秒,把区块/位置/实体落进状态
print(s.state.summary())
```
"""
def __init__(
self,
host: str,
port: int,
username: str,
version: str,
registry: Any = None,
data_root: str = "/home/newqqagent/plugin_data/mc/runtime/node_modules/"
"minecraft-data/minecraft-data/data",
):
self.client = Client(host=host, port=port, username=username, version=version)
self.state = WorldState(registry=registry, num_sections=24)
if registry is None:
from .state import load_vanilla_registry
self.state.world.registry = load_vanilla_registry(version, data_root)
# 事件回调:M4 的事件层会挂在这里(现在先留口子)
self._listeners: list[tuple[str, Callable[[str, Any], None]]] = []
# ---------- 事件(M4 的落点,本轮只留机制) ----------
def on(self, event: str, fn: Callable[[str, Any], None]) -> None:
self._listeners.append((event, fn))
def _emit(self, event: str, payload: Any) -> None:
for name, fn in self._listeners:
if name == event:
fn(event, payload)
# ---------- 连接 ----------
def connect(self, timeout: float = 15.0) -> dict[str, Any]:
"""走完 login → configuration → play,返回各阶段摘要。"""
import time as _t
c = self.client
c.load_protocol()
c.connect()
c.sock.settimeout(timeout)
c.handshake(state=2)
c.login_start()
# login 阶段:读到 success(途中处理压缩阈值)
login_seen: list[str] = []
deadline = _t.time() + timeout
while _t.time() < deadline:
p = c._recv("login")
nm = p["name"]
login_seen.append(nm)
if nm == "compress":
c.compression = p["params"].get("threshold", 256)
continue
if nm == "success":
break
if nm == "disconnect":
raise RuntimeError(f"服务端拒绝: {p['params'].get('reason')}")
cfg = c.configuration_handshake()
ingest_configuration(self.state, cfg, c.registries)
self._emit("connected", {"cfg": cfg})
return {
"login_packets": login_seen,
"registries": cfg.get("registryCount"),
"phase": c.phase,
"geometry": self.state.geometry.describe(),
}
# ---------- 收包并落状态 ----------
def pump(self, seconds: float = 1.0) -> dict[str, int]:
"""收 N 秒包,把能落地的状态落地,返回各包计数。"""
import time as _t
c = self.client
c.sock.settimeout(0.5)
deadline = _t.time() + seconds
counts: dict[str, int] = {}
while _t.time() < deadline:
try:
p = c._recv(c.phase)
except TimeoutError:
continue
except Exception as e:
counts["_error"] = counts.get("_error", 0) + 1
self.state.note_packet(f"_error:{type(e).__name__}")
continue
nm = p["name"]
counts[nm] = counts.get(nm, 0) + 1
self.state.note_packet(nm)
if p.get("_undecoded"):
# 上游数据缺口(当前只有 declare_recipes):不落状态,但记账
continue
try:
self._apply(nm, p["params"])
except Exception as e:
counts["_apply_error"] = counts.get("_apply_error", 0) + 1
self._emit("state_error", {"packet": nm, "error": str(e)[:120]})
return counts
def _apply(self, name: str, params: dict[str, Any]) -> None:
st = self.state
c = self.client
if name == "position":
st.update_from_position(params)
self._emit("player_moved", st.player.as_dict())
elif name == "login":
st.update_from_login(params)
self._emit("joined", st.player.as_dict())
elif name == "map_chunk":
ch = st.load_chunk(params["x"], params["z"], params["chunkData"])
self._emit("chunk_loaded", {"x": params["x"], "z": params["z"],
"sections": len(ch.sections)})
elif name == "unload_chunk":
# ★ 协议字段顺序是 chunkZ **在前**、chunkX 在后
# (1.21.1 packet_unload_chunk: [chunkZ:i32, chunkX:i32])
# 我先前写成 params.get("chunkZ", …) 塞给 cx ⇒ x/z 互换,
# 卸载永远不命中 ⇒ 区块越积越多(内存缓慢泄漏 + 状态撒谎)。
cx = params.get("chunkX")
cz = params.get("chunkZ")
if isinstance(cx, int) and isinstance(cz, int):
st.unload_chunk(cx, cz)
self._emit("chunk_unloaded", {"x": cx, "z": cz})
elif name == "keep_alive":
# ★ 必须回,否则服务端 15s 后踢我们(状态层的第一责任)
w = Writer()
w.varint(c._serverbound_id("play", "packet_keep_alive"))
w.int64(int(params.get("keepAliveId", 0)))
c.send(w.getvalue())
elif name == "game_state_change":
reason = params.get("reason")
# 0=开始游戏 1=结束游戏 3=改变gamemode 5=改变难度 6=改变生命
if reason == 3:
st.player.gamemode = str(params.get("gameMode"))
self._emit("game_state", params)
elif name == "update_health":
v = params.get("health")
if isinstance(v, (int, float)):
st.player.health = float(v)
f = params.get("food")
if isinstance(f, (int, float)):
st.player.food = int(f)
self._emit("health_changed", params)
elif name == "entity_position":
eid = params.get("entityId")
if eid in st.entities:
e = st.entities[eid]
if params.get("deltaX") is not None:
e.x += float(params["deltaX"])
e.y += float(params["deltaY"])
e.z += float(params["deltaZ"])
self._emit("entity_moved", e.as_dict())
elif name == "remove_entities":
ids = params.get("entityIds") or params.get("entities") or []
if isinstance(ids, (list, tuple)):
for eid in ids:
st.remove_entity(int(eid))
self._emit("entities_removed", ids)
elif name == "add_entity":
eid = params.get("entityId")
if isinstance(eid, int):
st.upsert_entity(Entity(
entity_id=eid,
kind=str(params.get("type", "unknown")),
x=float(params.get("x", 0.0)),
y=float(params.get("y", 0.0)),
z=float(params.get("z", 0.0)),
))
self._emit("entity_spawned", eid)
elif name == "update_view_position":
self._emit("view_moved", params)
elif name == "declare_recipes":
# 上游数据缺口解不开,但**包名已知**;这里不做任何事
self._emit("recipes_declared", {"decoded": False})
# ---------- 便捷 ----------
def close(self) -> None:
try:
self.client.close()
except Exception:
pass
def __enter__(self) -> "StateSession":
return self
def __exit__(self, *exc: Any) -> None:
self.close()

363
mcfold/state.py Normal file
View File

@ -0,0 +1,363 @@
"""状态层(M3):把**服务端真状态**汇总成 agent 可查询的世界。
## 它是什么
M2 拿到了「一串包」,那是协议层的原料。本模块把原料变成
**可查询、可解释、范围受控的世界状态**,供上层(事件层 / 操作层 / 渲染层)使用。
## 设计立场(与 mcfold 总体目的一致)
本模块**只陈述观察,不做决策**:
- 「这里有个箱子」是观察;「去开它」是决策(留给 agent)。
- 「脚下方块是镐能挖的」是观察(材质/硬度来自注册表);
「该挖哪个」是决策。
- 统计到「附近有 3 种未识别方块」是观察;agent 决定要不要查/要不要走。
一个刻意保留的能力:`unknown_state_ids` 计数。
mod 方块在 vanilla 协议下**不会**出现在 registry_data(见 plan.md 的 M2 结论),
所以 state_id → 名字的解析只能靠**本机 jar 的注册表**(Java 侧)。
这里对认不出的 id **显式记账并暴露**,绝不静默映射成别的方块
(静默映射会让 agent 基于错误的世界做决策)。
## 为什么不在这里做「找路 / 找目标」
那属于决策与规划。若塞进这里,agent 就失去了选择权,
且这些逻辑在 vanilla 与 mod 之间差异最大、最易过期。
本模块只保证把「事实」摆干净,让 agent 能自己判断。
"""
from __future__ import annotations
import json
from dataclasses import dataclass, field
from typing import Any, Iterable
from .chunk import BlockRegistry, Chunk
from .world import MIN_Y, WORLD_HEIGHT, World, in_world
# ---------- 世界几何(从服务端登录包学到的,而非写死) ----------
@dataclass
class WorldGeometry:
"""世界尺寸。★ 来自服务端 `login` 包,不写死 24/384。
1.18+ 世界高度随维度变(超下界 -64、384 层),写死会在别的维度读错。
"""
min_y: int = MIN_Y
height: int = WORLD_HEIGHT
dimension: str = "minecraft:overworld"
seed: int = 0
def in_world(self, y: int) -> bool:
return self.min_y <= y < self.min_y + self.height
def describe(self) -> str:
return f"{self.dimension} y∈[{self.min_y}, {self.min_y + self.height}) 种子={self.seed}"
# ---------- 实体 ----------
@dataclass
class Entity:
"""一个实体的观察结果。**不预测、不决策**。"""
entity_id: int
kind: str = "unknown"
x: float = 0.0
y: float = 0.0
z: float = 0.0
yaw: float = 0.0
pitch: float = 0.0
on_ground: bool = False
metadata: dict[str, Any] = field(default_factory=dict)
def as_dict(self) -> dict[str, Any]:
return {
"entity_id": self.entity_id, "kind": self.kind,
"x": round(self.x, 2), "y": round(self.y, 2), "z": round(self.z, 2),
"yaw": round(self.yaw, 2), "pitch": round(self.pitch, 2),
"on_ground": self.on_ground,
}
# ---------- 玩家(自己) ----------
@dataclass
class PlayerView:
"""自己的位置与状态。position 包是它的唯一权威来源。"""
x: float = 0.0
y: float = 0.0
z: float = 0.0
yaw: float = 0.0
pitch: float = 0.0
entity_id: int = -1
dimension: str = "minecraft:overworld"
health: float | None = None
food: int | None = None
gamemode: str | None = None
# 相对坐标标志:position 包的 flags 位
relative: dict[str, bool] = field(default_factory=dict)
def as_dict(self) -> dict[str, Any]:
return {
"x": round(self.x, 2), "y": round(self.y, 2), "z": round(self.z, 2),
"yaw": round(self.yaw, 2), "pitch": round(self.pitch, 2),
"entity_id": self.entity_id, "dimension": self.dimension,
"health": self.health, "food": self.food, "gamemode": self.gamemode,
}
# ---------- 观察结果(受控范围,给 LLM 读) ----------
# 报告里最多列多少种方块 / 多少个实体 —— 「bounded results」是 API 硬要求。
# 超出部分只给计数,不静默截断(截断会让 agent 以为世界就这么大)。
MAX_BLOCK_KINDS = 12
MAX_ENTITIES = 20
MAX_SCAN_VOLUME = 200_000 # 扫描体积上限:超了就不扫并如实说明
class WorldState:
"""服务端真状态的世界视图。
## 与 World 的关系
`WorldState` 拥有 `World`(负责方块查询/区块解码),
并在它之上补:**几何、实体、玩家、方块统计与查询工具**。
## 为什么方块名字靠本机注册表而不是 registry_data
实测(plan.md M2 结论):vanilla 1.21.1 只同步 11 个**动态**注册表,
`minecraft:block` / `minecraft:item` **不在其中**。所以
- 原版方块:读本机 jar/blocks.json 的注册表 ✓
- mod 方块:vanilla 协议不会告诉客户端 ⇒ 只能靠加载器自身同步
⇒ 这里对认不出的 id 记 `unknown_state_ids` 并暴露,绝不猜。
"""
def __init__(
self,
registry: BlockRegistry | None = None,
num_sections: int = 24,
):
self.world = World(registry=registry, num_sections=num_sections)
self.geometry = WorldGeometry()
self.player = PlayerView()
self.entities: dict[int, Entity] = {}
# 服务端给的 11 个动态注册表(权威,保留原始条目)
self.registries: dict[str, Any] = {}
self.packets_seen: dict[str, int] = {}
# ---------- 装载:来自服务端的状态 ----------
def load_chunk(self, x: int, cz: int, chunk_data: bytes) -> Chunk:
return self.world.load_chunk_data(x, cz, chunk_data)
def unload_chunk(self, x: int, cz: int) -> None:
self.world.unload_chunk(x, cz)
def update_from_position(self, params: dict[str, Any]) -> None:
"""把 `position` 包落进玩家视图。
★ flags 位决定哪些轴是**相对增量**而非绝对值 ——
服务端做小步纠正时走相对位,此时必须累加而不是赋值,
否则位置会跳到原点(实测 flags 0=全绝对)。
"""
f = params.get("flags") or {}
rel = {k: bool(v) for k, v in f.items() if k != "_value"}
x, y, z = float(params["x"]), float(params["y"]), float(params["z"])
yaw, pitch = float(params.get("yaw", 0.0)), float(params.get("pitch", 0.0))
if rel.get("x"):
x += self.player.x
if rel.get("y"):
y += self.player.y
if rel.get("z"):
z += self.player.z
if rel.get("yaw"):
yaw = (self.player.yaw + yaw) % 360.0
if rel.get("pitch"):
pitch = max(-90.0, min(90.0, self.player.pitch + pitch))
self.player.x, self.player.y, self.player.z = x, y, z
self.player.yaw, self.player.pitch = yaw, pitch
self.player.relative = rel
def update_from_login(self, params: dict[str, Any]) -> None:
"""`login`(play) 包:entityId + 维度类型 + 世界几何。"""
eid = params.get("entityId")
if isinstance(eid, int):
self.player.entity_id = eid
# 1.21.1 的 login 里有 dimension(0x1B 维数编码),尝试解出名字
dim = params.get("dimensionType")
if dim is not None:
self.player.dimension = f"dim-id:{dim}" if isinstance(dim, int) else str(dim)
def apply_registry(self, rid: str, entries: Any) -> None:
self.registries[rid] = entries
def upsert_entity(self, ent: Entity) -> None:
self.entities[ent.entity_id] = ent
def remove_entity(self, entity_id: int) -> None:
self.entities.pop(entity_id, None)
def note_packet(self, name: str) -> None:
self.packets_seen[name] = self.packets_seen.get(name, 0) + 1
# ---------- 查询:范围受控、可读 ----------
def block_at(self, x: int, y: int, z: int) -> dict[str, Any]:
"""单个方块。返回来源标记,agent 能分辨「确实没有」与「还没看见」。"""
hit = self.world.block_at(x, y, z)
return {
"name": hit.name,
"state_id": hit.state_id,
"source": hit.source,
"describe": hit.describe(),
}
def column_profile(self, x: int, z: int) -> dict[str, Any]:
"""一列方块的地貌剖面(agent 落地/规划最常用的一次查询)。
★ 跨版本安全:只报「首个非空气方块」与其上方高度,不假设
固定的世界高度(几何从服务端来)。
"""
geo = self.geometry
col: list[dict[str, Any]] = []
for y in range(geo.min_y, geo.min_y + geo.height):
hit = self.world.block_at(x, y, z)
if hit.name and hit.name != "air":
col.append({"y": y, "name": hit.name, "state_id": hit.state_id})
surface = col[0] if col else None
return {
"x": x, "z": z,
"geometry": geo.describe(),
"surface": surface,
"non_air_count": len(col),
# 剖面只给前若干层:全量 384 层会让 LLM 上下文爆掉
"profile": col[:24],
"truncated": len(col) > 24,
"unknown_state_ids": self.world.unknown_state_ids,
}
def nearby_blocks(
self,
x: int, y: int, z: int,
radius: int = 4,
want_air: bool = False,
) -> dict[str, Any]:
"""半径内的方块种类统计(不是逐块列表 —— 那是 token 灾难)。
★ 范围与体积都受控:radius 上限由调用方给,
超过 MAX_SCAN_VOLUME 直接拒绝并说明,不静默缩小
(静默缩小会让 agent 以为扫全了)。
"""
vol = (2 * radius + 1) ** 3
if vol > MAX_SCAN_VOLUME:
return {
"error": f"扫描体积 {vol} 超过上限 {MAX_SCAN_VOLUME},请减小 radius",
"requested_radius": radius, "scanned": False,
}
counts: dict[str, int] = {}
unknown: dict[int, int] = {}
for dy in range(-radius, radius + 1):
for dx in range(-radius, radius + 1):
for dz in range(-radius, radius + 1):
hit = self.world.block_at(x + dx, y + dy, z + dz)
if hit.source in ("unloaded", "out-of-world"):
continue
if hit.source == "unknown-state":
unknown[hit.state_id] = unknown.get(hit.state_id, 0) + 1
continue
if not want_air and hit.name == "air":
continue
counts[hit.name] = counts.get(hit.name, 0) + 1
ranked = sorted(counts.items(), key=lambda kv: -kv[1])
return {
"center": (x, y, z), "radius": radius, "scanned": True,
"kinds": len(ranked),
"blocks": [{"name": n, "count": c} for n, c in ranked[:MAX_BLOCK_KINDS]],
"truncated": len(ranked) > MAX_BLOCK_KINDS,
"unknown_state_ids": unknown,
"unresolved_unknown": bool(unknown),
}
def nearby_entities(self, radius: int = 16, limit: int = MAX_ENTITIES) -> dict[str, Any]:
p = self.player
near: list[tuple[float, Entity]] = []
for e in self.entities.values():
d = ((e.x - p.x) ** 2 + (e.y - p.y) ** 2 + (e.z - p.z) ** 2) ** 0.5
if d <= radius:
near.append((d, e))
near.sort(key=lambda t: t[0])
return {
"radius": radius, "count": len(near),
"entities": [e.as_dict() | {"distance": round(d, 2)} for d, e in near[:limit]],
"truncated": len(near) > limit,
}
def summary(self) -> dict[str, Any]:
"""一眼看清「我看到了多少、认出了多少」。"""
return {
"player": self.player.as_dict(),
"geometry": self.geometry.describe(),
"chunks_loaded": self.world.loaded_count,
"chunk_columns": len(list(self.world.columns())),
"entities": len(self.entities),
"registries_from_server": sorted(self.registries.keys()),
"registry_entry_counts": {
k: (len(v) if isinstance(v, (list, tuple)) else None)
for k, v in sorted(self.registries.items())
},
"unknown_state_ids": dict(self.world.unknown_state_ids),
"has_unresolved_unknown": bool(self.world.unknown_state_ids),
"packets_seen": dict(sorted(self.packets_seen.items(), key=lambda kv: -kv[1])),
"note": (
"unknown_state_ids 非空 ⇒ 这些 state id 来自服务端但本机注册表认不出"
"(典型:mod 方块)。本工具**不猜**,请另行查询加载器同步通道。"
) if self.world.unknown_state_ids else None,
}
def reset_unknown_stats(self) -> None:
self.world.reset_unknown_stats()
# ---------- 构造:注册表从哪来 ----------
def load_vanilla_registry(version: str, data_root: str) -> BlockRegistry | None:
"""从 minecraft-data 的 blocks.json 构造 state_id → 名字 注册表。
★ 这是**本机**表(对应本机 jar),不是服务端的:
服务端不在线同步 block 注册表(plan.md M2 实测结论)。
所以 mod 方块必然认不出 —— 那是设计上的「显式未知」,
不是缺陷。别试图用 registry_data 补它,那 11 个包里没有 block。
"""
import os
path = os.path.join(data_root, "pc", version, "blocks.json")
if not os.path.exists(path):
return None
return BlockRegistry.from_json_file(path)
def ingest_configuration(
state: WorldState,
cfg: dict[str, Any],
registries: dict[str, Any],
) -> None:
"""把 configuration 阶段拿到的服务端注册表并进状态。"""
for rid, entries in registries.items():
state.apply_registry(rid, entries)
# 维度类型注册表里通常能解出 overworld 的高度 ⇒ 几何不再写死
dt = registries.get("minecraft:dimension_type")
if isinstance(dt, (list, tuple)):
for e in dt:
if isinstance(e, dict) and "minecraft:overworld" in str(e.get("key", "")):
val = e.get("value")
if isinstance(val, dict):
h = val.get("height")
if isinstance(h, dict) and "height" in h:
state.geometry.height = int(h["height"])
state.geometry.min_y = int(h.get("min_y", 0))
break

31
plan.md
View File

@ -563,7 +563,7 @@ shot.rough → ASCII + 线索(给 agent 看它为什么选了这个角度)
| **M1** | 修 build.sh 编译 CP(加 Fabric 库)⇒ 1.21.1 构建跑通 | — | ✅ |
| **M2** | **协议客户端**:连测试服 → configuration → registry_data → 进 play | M0 | ✅ |
| | (这是最大缺口:现有只到 `login_start` 冒烟级) | | |
| **M3** | **状态层**:World / entities / inventory 从**服务端真状态**来 | M2 | ⬜ |
| **M3** | **状态层**:World / entities / inventory 从**服务端真状态**来 | M2 | ✅ |
| **M4** | **事件层**:粗粒度 + 细分层(照 mineflayer,省 token) | M2 | ⬜ |
| **M5** | **操作层**:chat / look / move / dig / place(能改世界) | M2, M3 | ⬜ |
| **M6** | **粗渲染**(常驻感知):基于 M3 的真状态 | M3 | ⬜ |
@ -605,6 +605,35 @@ play 28 种包 map_chunk×9、position、declare_commands、entit
(PartialReadError: found 108271 / expected 114153)。
处理:长度前缀是帧层权威 ⇒ **坏包整包丢弃不断流**(`decode_failures` 布告)。
### M3 实测结论(2026-09-26)
新增 `mcfold/state.py`(状态层)+ `mcfold/session.py`(包流 → 状态)。
**实测(连 M0 测试服)**:
```
connect registries=11 phase=play geometry=minecraft:overworld y∈[-64,320)
pump 9 区块 / 生命 20 / 饥饿 20 / entityId / gamemode 全落进状态
block_at (x,y-1,z) → grass_block state_id=9 source=registry
列剖面 y=-64 bedrock → -63 dirt → -62 dirt → -61 grass_block
nearby 半径 6 → 3 种(dirt 338 / bedrock 169 / grass_block 169)
视野外 source=unloaded(与 out-of-world、unknown-state 可区分)
```
**本轮挖出的真 bug**:`unload_chunk` 协议字段顺序是 **`chunkZ` 在前、`chunkX` 在后**;
我按「x 先」读 ⇒ 坐标互换 ⇒ 卸载永不命中 ⇒ 区块只增不减(状态在撒谎)。
**设计立场(重要,防越界)**:
- 只陈述观察,**不做决策**:`nearby_blocks` 给种类统计而不给「该挖哪个」。
- **未知 ≠ 没看见**:认不出的 state id 记 `unknown_state_ids` 并显式暴露,
**绝不**静默映射成别的方块(静默映射会让 agent 基于虚构世界决策)。
- **超范围要拒绝并说明**,不静默缩小(否则 agent 以为扫全了)。
- 几何从服务端 `dimension_type` 注册表吸收,不写死 24/384。
**判据**:`tests/test_state.py` 14 项;4 项变异全部被抓
(相对位移改绝对 / 未知方块静默映射 / unload 坐标交换 / keep_alive 不回应)。
★ 其中「未知方块静默映射」第一次变异**是空操作**(改错了文件)⇒ 假绿;
核对变异是否真命中后重做,才确认判据有效。**变异必须先验它落地了**。
**建议顺序:M0 → M1 → M2**。
- M0/M1 是解阻断(M1 是我已经查到根因的那个 classpath bug)
- **M2 是真正的门槛**:从「能渲染」到「能玩」,缺的不是渲染,是**完整协议层**。

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状态层:全部通过")