Files
agentmc/mcfold/session.py
JianFeeeee d2b4c5029d M4:事件层(可按需排空的日志 + 游标)+ 16 项判据
新增 mcfold/events.py:EventLog(环形缓冲 + seq + 游标)
- 不做回调式推送:LLM 是回合制的,回调会让事件在两次观察之间静默丢失
- drain(limit) 只取本轮、剩余留待下次(不丢)
- 细分层(block_changed❌y:z)独立可查,不污染粗粒度流
- render_events() 输出紧凑文本行,省 token

session 接线:block_change / multi_block_change / chat / damage /
entity spawn·move·destroy / held_item_slot,并加 chat()/command() 发送

修的真 bug:
- dropped 恒 0:deque(maxlen=) 丢元素无声 ⇒ 改手动计数(事件丢了必须说)
- 事件键 to_state_id 与渲染器的 to 不一致 ⇒ 显示 None,改为解出方块名;
  from 区分「未加载/界外」而非裸 None
- run_tests.sh 只数 ✓ 不数 ★ ⇒ 判据少报(98 修正为 110)

实测:/setblock 后事件正确报 grass_block→stone→air,与状态层一致
判据:110 项全通过;4 项变异全部被抓
2026-09-26 12:27:28 +08:00

416 lines
17 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.

"""把 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
from .events import EventLog
def _nbt_text(nbt: Any) -> Any:
"""从 NBT 里尽量取出可读文本(1.20.3+ 聊天走 NBT 组件)。
失败时**如实返回原值**而不是空串:chat 事件是 agent 判断局势的重要输入,
静默变成「(空)」比看不懂更糟。
"""
if isinstance(nbt, str):
return nbt
if isinstance(nbt, dict):
for k in ("text", "raw", "value", "string", "fallback"):
if k in nbt and isinstance(nbt[k], str):
return nbt[k]
# 常见形状:{"text": "hi", ...}
parts = [v for v in nbt.values() if isinstance(v, str)]
if len(parts) == 1:
return parts[0]
return nbt
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 事件层:给 LLM 用的**可按需排空**的事件日志(非回调推送)
self.events = EventLog()
# 兼容旧接口:回调式监听(给非 LLM 的宿主用),事件仍会同时入 EventLog
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:
"""同时进事件日志(LLM 路径)与回调(宿主路径)。
★ 两路并存的原因:LLM 是回合制的,需要 `drain()` 式拉取;
而某些宿主(如测试/工具)想要即时回调。
只做其中一路都会让另一类使用者难用。
"""
if isinstance(payload, dict):
self.events.emit(event, payload)
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)
# ---------- M4:世界变化类事件 ----------
elif name == "block_change":
# 1.21.1 的 position 是 **bitfield 打包坐标**(x 26 / z 26 / y 12,有符号)
loc = params.get("location") or {}
bx, by, bz = int(loc.get("x", 0)), int(loc.get("y", 0)), int(loc.get("z", 0))
before = st.block_at(bx, by, bz)
# ★ from 要能区分「之前是空气」与「之前我没见过」:
# unloaded → 写「未加载」,而不是 None(None 看着像“没有值”)。
# agent 若把 unloaded 读成 air,会以为脚下是空气而做出错误动作。
from_name = before.get("name")
if from_name is None:
from_name = {"unloaded": "未加载(无原值)",
"out-of-world": "界外"}.get(
before.get("source"), f"未知({before.get('source')})")
st.on_block_change(bx, by, bz, params.get("type"))
# ★ 把 state_id 解成**方块名**再进事件:
# agent 读「grass_block → stone」比读「9 → 1」有用得多;
# 而且我先前写的事件键是 to_state_id、渲染器读 to ⇒ 显示 None
# (「变了个不知道是什么的东西」,比不报还糟)。
now = st.block_at(bx, by, bz)
self._emit("block_changed", {
"x": bx, "y": by, "z": bz,
"from": from_name,
"to": now.get("name"),
"to_state_id": params.get("type"),
})
self.events.emit_fine(f"block_changed:{bx}:{by}:{bz}",
{"to": now.get("name"),
"to_state_id": params.get("type")})
elif name == "multi_block_change":
sec = params.get("chunkCoordinates") or {}
recs = params.get("records") or []
st.on_multi_block_change(int(sec.get("x", 0)), int(sec.get("z", 0)), recs)
self._emit("block_changed", {
"chunk": [int(sec.get("x", 0)), int(sec.get("z", 0))],
"count": len(recs), "bulk": True,
})
elif name in ("system_chat", "profileless_chat"):
self._emit("chat", {
"text": _nbt_text(params.get("content")),
"action_bar": bool(params.get("isActionBar")),
"system": True,
})
elif name == "player_chat":
self._emit("chat", {
"from": params.get("senderName") or str(params.get("senderUuid")),
"text": params.get("plainMessage"),
"system": False,
})
elif name == "damage_event":
eid = params.get("entityId")
if eid == st.player.entity_id:
amt = params.get("damage")
if amt is not None and st.player.health is not None:
st.player.health = max(0.0, st.player.health - float(amt))
self._emit("damage", {"amount": amt, "health": st.player.health})
elif name == "entity_destroy":
for eid in (params.get("entityIds") or []):
e = st.entities.pop(int(eid), None)
if e is not None:
self._emit("entity_removed", e.as_dict())
elif name == "spawn_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", st.entities[eid].as_dict())
elif name in ("rel_entity_move", "entity_move_look"):
eid = params.get("entityId")
e = st.entities.get(eid) if isinstance(eid, int) else None
if e is not None:
# ★ 增量单位是 1/4096 方块(协议定义),不是 1/1 —— 写错会得到
# 看似合理但完全错误的坐标(实体“瞬移”到远处)。
e.x += float(params.get("deltaX", 0)) / 4096.0
e.y += float(params.get("deltaY", 0)) / 4096.0
e.z += float(params.get("deltaZ", 0)) / 4096.0
p = st.player
dist = ((e.x - p.x) ** 2 + (e.y - p.y) ** 2 + (e.z - p.z) ** 2) ** 0.5
self._emit("entity_moved",
e.as_dict() | {"distance": round(dist, 2)})
elif name == "command_suggestions":
self._emit("chat", {"text": params, "system": True, "suggestions": True})
elif name == "held_item_slot":
self._emit("held_item", {"slot": params.get("slot")})
elif name == "declare_recipes":
# 上游数据缺口解不开,但**包名已知**;这里不做任何事
self._emit("recipes_declared", {"decoded": False})
# ---------- 发(M5 的第一块,先把 chat/command 铺上) ----------
def send(self, payload: bytes) -> None:
self.client.send(payload)
def chat(self, text: str) -> None:
"""发聊天(M5)。结构随版本变化,**从协议数据取**而非写死。"""
fields = self._serverbound_fields("play", "packet_chat_message")
w = Writer()
w.varint(self.client._serverbound_id("play", "packet_chat_message"))
w.pstring(text)
# 1.19.1+ 有 timestamp / salt / signature / etc
for name, ftype in fields:
if name == "message":
continue
if name in ("timestamp", "salt"):
w.int64(0)
elif isinstance(ftype, list) and ftype and ftype[0] == "option":
w.boolean(False) # signature / unsignedChatContent:无
elif isinstance(ftype, list) and ftype and ftype[0] in ("varint", "i8", "u8", "bool"):
w.varint(0)
else:
w.pstring("") # 未知可选字段:保守发空
self.send(w.getvalue())
def command(self, cmd: str) -> None:
"""发命令(**不带**前导 /)。1.19+ 走 chat_command 独立包。"""
cmd = cmd.lstrip("/")
pid = self.client._serverbound_id("play", "packet_chat_command")
w = Writer()
w.varint(pid)
w.pstring(cmd)
self.send(w.getvalue())
@staticmethod
def _serverbound_fields(phase: str, pname: str) -> list[tuple[str, Any]]:
"""从 protocol.json 读 serverbound 包的字段(顺序敏感)。"""
import json as _json
try:
types = self.client.proto[phase]["toServer"]["types"]
c = types.get(pname)
if c and isinstance(c, list) and len(c) > 1:
return [(f["name"], f["type"]) for f in c[1]]
except Exception:
pass
return []
# ---------- 便捷 ----------
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()