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