From 40b2e1d60fe1e072b5fe1ba1205581d4afd6b627 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Sat, 26 Sep 2026 12:21:18 +0800 Subject: [PATCH] =?UTF-8?q?M3=EF=BC=9A=E7=8A=B6=E6=80=81=E5=B1=82=EF=BC=88?= =?UTF-8?q?=E6=9C=8D=E5=8A=A1=E7=AB=AF=E7=9C=9F=E7=8A=B6=E6=80=81=EF=BC=89?= =?UTF-8?q?+=20=E5=8C=85=E6=B5=81=E7=81=8C=E5=85=A5=20+=2014=20=E9=A1=B9?= =?UTF-8?q?=E5=88=A4=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增: - 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 项变异全部被抓(含「变异空操作 ⇒ 假绿」的核对) --- mcfold/session.py | 238 +++++++++++++++++++++++++++++ mcfold/state.py | 363 ++++++++++++++++++++++++++++++++++++++++++++ plan.md | 31 +++- tests/test_state.py | 307 +++++++++++++++++++++++++++++++++++++ 4 files changed, 938 insertions(+), 1 deletion(-) create mode 100644 mcfold/session.py create mode 100644 mcfold/state.py create mode 100644 tests/test_state.py diff --git a/mcfold/session.py b/mcfold/session.py new file mode 100644 index 0000000..1d6de4b --- /dev/null +++ b/mcfold/session.py @@ -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() diff --git a/mcfold/state.py b/mcfold/state.py new file mode 100644 index 0000000..18ea0eb --- /dev/null +++ b/mcfold/state.py @@ -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 diff --git a/plan.md b/plan.md index 9407170..849c9b3 100644 --- a/plan.md +++ b/plan.md @@ -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 是真正的门槛**:从「能渲染」到「能玩」,缺的不是渲染,是**完整协议层**。 diff --git a/tests/test_state.py b/tests/test_state.py new file mode 100644 index 0000000..5678881 --- /dev/null +++ b/tests/test_state.py @@ -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状态层:全部通过")