Files
agentmc/mcfold/state.py
JianFeeeee 40b2e1d60f 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 项变异全部被抓(含「变异空操作 ⇒ 假绿」的核对)
2026-09-26 12:21:18 +08:00

364 lines
14 KiB
Python
Raw 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):把**服务端真状态**汇总成 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