Files
agentmc/mcfold/state.py
JianFeeeee 1a6f197572 M5:操作层(真能改世界)+ 16 项判据
新增 mcfold/actions.py(16 个操作:look/move/move_and_look/look_at/
jump/sneak/sprint/select_slot/use_item/swing/dig/place/interact_block/
attack/use_entity/interact_entity_at + chat/command)
新增 mcfold/textcomp.py(1.20.3+ 的 NBT 文本组件渲染,查 language.json)

★★ 最关键发现:teleport_confirm 是必需前置包
1.19+ 的 position 带 teleportId,必须回 confirm,否则服务端**默默忽略所有方块操作**。
症状是「操作没反应、零报错」,与权限/物品/位置问题无法区分。
定位过程:先猜了 5 轮(包名/字段顺序/face 类型/sequence/位置与面)都不对;
最后拿 minecraft-protocol 对照,发现它也失败 ⇒ 排除我的包有问题;
再用 data get entity 排除权限与物品 ⇒ 补 confirm 后立刻成功。

其他真 bug:
- packet_interact/swing/animation 在 1.21.1 都不存在(应是 use_entity/arm_animation)
- dig 原写成 block_place + 多余 varint(应为独立 packet_block_dig)→ 断连
- use_entity(mouse=0) 也带 hand 字段,我漏了 → 少 1 字节
- 背包字段是 itemId(驼峰),我写 item_id → 永远判「空手」
- window0 的 slot 36..44 才是热键栏
- 聊天 NBT 组件三重漏(空串/键名/空键参数)→ 现在正确渲染人话

判据:126 项全通过;5 项变异全被抓。核心判据「16 个包能被官方 protodef
精确解回(0 余字节)」—— 唯一能离线证明「包写对了」的办法
2026-09-26 12:44:08 +08:00

476 lines
20 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] = {}
# ★ 方块变化覆盖层:(x,y,z) -> state_id
# 不改写区块(区块是压缩调色板,重建代价高且会被 unload 重发覆盖)
self.overrides: dict[tuple[int, int, int], int] = {}
# 方块变化流水(供「我改了什么」回放)
self.block_log: list[tuple[int, int, int, int]] = []
# 槽位 → 物品(来自 set_slot)。缺了它就不知道手上有没有东西
self.slots: dict[int, Any] = {}
# 热键栏视图:索引 0..8(来自 window0 的 slot 36..44)
self.hotbar: dict[int, Any] = {}
self.bulk_changes = 0
self.bulk_change_records: list[int] = []
# 服务端给的 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 on_block_change(self, x: int, y: int, z: int, state_id: int | None) -> None:
"""服务端说某坐标方块变了 ⇒ 更新我们的世界视图。
★ 用**覆盖层**(overlay)而不是改写区块:
区块的 Paletted Container 调色板是压缩的,改一个值可能要重建整段
位数组;而覆盖层天然有界(只存变过的格子)且可回答「原来的值是什么」
—— 事件里的 from → to 就需要这个。
另一个理由:区块随时可能被 unload 重发,覆盖层能正确丢弃。
"""
if state_id is None:
return
self.overrides[(x, y, z)] = int(state_id)
self.block_log.append((x, y, z, int(state_id)))
def on_multi_block_change(self, chunk_x: int, chunk_z: int,
records: list[int]) -> None:
"""一次多方块变化(爆炸/活塞):records 打包为 varint 数组。
记录格式(协议):每条 varint,高位是状态 id,低 12 位是局部坐标。
具体位宽随协议而变,故以**协议给定**为准读入并显式记录条数;
拿不准时不猜坐标,只把条数与方块 id 报给 agent。
"""
self.bulk_changes += 1
self.bulk_change_records = list(records)
def upsert_entity(self, ent: Entity) -> None:
self.entities[ent.entity_id] = ent
# ---------- 手持物(M5 操作的**前提条件**) ----------
def set_held_slot_item(self, slot: int, item: Any) -> None:
"""记下某个槽位的物品(来自 set_slot 包)。
★ 这是 M5 能不能干活的前提:手上没石头,place 就是白发。
不知道手上是什么,就只能看到“操作没反应”而不知原因。
★★ 槽位编号要分清(实测踩过):
window 0 的 slot 编号是**玩家背包全量索引**:
0..8 = 创作模式物品栏(仅创造)
9..35 = 护甲栏 + 背包 + 副手
36..44 = **热键栏**(1..9)
所以「切到槽位 0」与「拿到 36 号槽的物品」是**不同的索引**。
这里原样存,并另外维护 hotbar 视图(slot-36)供 agent 读。
"""
self.slots[slot] = item
if isinstance(slot, int) and 36 <= slot <= 44:
self.hotbar[slot - 36] = item
def hotbar_item_name(self, index: int) -> str | None:
"""热键栏第 index 格(0..8)的物品名。"""
item = self.hotbar.get(index)
return item_name(item) if item is not None else None
def held_item_name(self) -> str | None:
"""手上(当前热键格)的物品名;取不到就 None(不编)。"""
idx = getattr(self.player, "held_slot", None)
if not isinstance(idx, int):
return None
return self.hotbar_item_name(idx)
def held_item(self) -> Any:
return self.slots.get(getattr(self.player, "held_slot", 0) or 0)
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 能分辨「确实没有」与「还没看见」。"""
ov = self.overrides.get((x, y, z))
if ov is not None:
# 覆盖层优先(世界已被服务端改动过)
name = None
if self.world.registry is not None:
name = self.world.registry.name_of(ov)
src = "registry" if name else "unknown-state"
if name is None:
self.world.unknown_state_ids[ov] = \
self.world.unknown_state_ids.get(ov, 0) + 1
return {
"name": name, "state_id": ov, "source": src,
"describe": f"{name}" if name else f"unknown({ov}) [已变更]",
}
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
def item_name(item: Any) -> str | None:
"""从协议 Slot 结构里取物品标识(取不到就 None,**不编**)。
★ 实测 1.21.1 的字段名是 **itemId**(驼峰),不是 item_id:
{"itemCount": 31, "itemId": 1, "addedComponentCount": 0, ...}
我先前只找 item_id ⇒ 永远拿不到物品名 ⇒ 永远判「手上是空的」
⇒ place 一直带 warning 却不自知(症状是「操作没反应」)。
★ 1.20.5+ 物品不再有全局字符串 id,只有数字;这里给出可辨识的
id 形态,**不**自己编一个名字。
"""
if item is None:
return None
if isinstance(item, str):
return item
if isinstance(item, dict):
if item.get("itemCount", 1) == 0: # 空槽
return None
for k in ("itemId", "item_id", "presentItemId"):
v = item.get(k)
if isinstance(v, str):
return v
if isinstance(v, int) and v != 0:
return f"item#{v}"
return None