新增 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 余字节)」—— 唯一能离线证明「包写对了」的办法
476 lines
20 KiB
Python
476 lines
20 KiB
Python
"""状态层(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 |