From d2b4c5029da4ded59f890e82fa2a31e11f305b59 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Sat, 26 Sep 2026 12:27:28 +0800 Subject: [PATCH] =?UTF-8?q?M4=EF=BC=9A=E4=BA=8B=E4=BB=B6=E5=B1=82=EF=BC=88?= =?UTF-8?q?=E5=8F=AF=E6=8C=89=E9=9C=80=E6=8E=92=E7=A9=BA=E7=9A=84=E6=97=A5?= =?UTF-8?q?=E5=BF=97=20+=20=E6=B8=B8=E6=A0=87=EF=BC=89+=2016=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/events.py:EventLog(环形缓冲 + seq + 游标) - 不做回调式推送:LLM 是回合制的,回调会让事件在两次观察之间静默丢失 - drain(limit) 只取本轮、剩余留待下次(不丢) - 细分层(block_changed:x: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 项变异全部被抓 --- mcfold/events.py | 212 ++++++++++++++++++++++++++++ mcfold/session.py | 179 +++++++++++++++++++++++- mcfold/state.py | 48 +++++++ plan.md | 36 ++++- run_tests.sh | 5 +- tests/test_events.py | 324 +++++++++++++++++++++++++++++++++++++++++++ tests/test_state.py | 4 + 7 files changed, 805 insertions(+), 3 deletions(-) create mode 100644 mcfold/events.py create mode 100644 tests/test_events.py diff --git a/mcfold/events.py b/mcfold/events.py new file mode 100644 index 0000000..58e54d6 --- /dev/null +++ b/mcfold/events.py @@ -0,0 +1,212 @@ +"""M4 事件层:把状态变化变成 **LLM 可按需消费** 的事件流。 + +## 为什么不是回调,而是「环形缓冲 + 游标」 + +mineflayer 的 `bot.on('physicsTick', …)` 是**回调**式,适合长跑的机器人。 +但我们要服务的是 **LLM agent**: + +- LLM 是**回合制**的:一次思考 → 一次工具调用 → 一次观察。它不会一直在跑。 +- 回调式对 LLM 有三个坏处: + 1. 事件在两次观察之间发生 ⇒ **静默丢失**,agent 以为世界没变; + 2. 要在回调里"攒"上下文 ⇒ 状态散落在闭包里,不可查询、不可重放; + 3. 事件粒度不可控(physicsTick 每秒 20 次 ⇒ 直接把 token 烧穿)。 + +⇒ 所以事件层是**可排空的环形缓冲**: +agent 每次观察时调 `drain()`,把「上次观察之后发生的事」按需取走; +每条带**序号**(`seq`),可精确追问「我看到 seq=37 之后又发生了什么」。 + +## 分级:粗粒度(默认给 agent) vs 细分层(按需) + +- **粗粒度**:`block_changed`、`entity_moved`、`chat`、`damage`、`joined`。 + 这些是「游戏里发生了什么」级别的变化,一轮观察给个 20 条就够判断。 +- **细分层**:`block_changed:9:4:12`(某坐标方块变了)、`chat:Alice`。 + 只在 agent 明确追问时用 `drain(fine=...)` 取 —— 常规轮次**不烧 token**。 + +## 为什么这层不做「决策」 + +事件层回答「**变了什么**」,不回答「**该怎么办**」。 +`chat` 事件告诉 agent 有人说了什么;该不该回、回什么,是 agent 的事。 +同样,`block_changed` 说方块变了;该不该挖,agent 判断。 +""" +from __future__ import annotations + +from collections import deque +from typing import Any, Iterable + + +# ---------- 事件构造 ---------- + +def _ev(seq: int, kind: str, data: dict[str, Any]) -> dict[str, Any]: + """统一事件形状。`kind` 粗粒度,`fine` 细分层(可选)。""" + return {"seq": seq, "kind": kind, "data": data} + + +# ---------- 缓冲 ---------- + +class EventLog: + """环形事件缓冲 + 单调游标。 + + ## 三个刻意的不对称 + 1. **计数不限报,实现有界**:deque 满了丢最旧的,但 `dropped` 计数如实报 + —— 静默丢事件会让 agent 以为世界没发生事。 + 2. **游标是调用方的**:`drain()` 返回后自己推进游标; + 同一游标重复调只拿新事件(幂等,不会重复读)。 + 3. **fine 事件与粗粒度并存**:同一次变化写两条(`block_changed` 与 + `block_changed:9:4:12`),但 `drain()` 默认**只给粗粒度**。 + """ + + #: 缓冲上限。超了丢最旧并计 dropped。 + CAPACITY = 4096 + + def __init__(self, capacity: int = CAPACITY): + # ★ 不用 deque(maxlen=) :它丢元素是**完全无声**的。 + # 实测:deque(maxlen=4) 塞 10 个 ⇒ 只剩 [6,7,8,9],没有任何痕迹。 + # 而“事件被丢了却不说”正是本层最不能犯的错 —— agent 会以为 + # 世界没发生变化。改用手动判满 + 显式计数。 + self._cap = capacity + self._buf: deque[dict[str, Any]] = deque() + self._seq = 0 + self._cursor = 0 # 调用方已消费到哪 + self.dropped = 0 # 因容量不足丢掉的条数 + self._fine: deque[dict[str, Any]] = deque() + + # ---------- 写入 ---------- + + def emit(self, kind: str, data: dict[str, Any] | None = None) -> dict[str, Any]: + """记一个**粗粒度**事件。满了就丢最旧,并**显式计数**。""" + self._seq += 1 + e = _ev(self._seq, kind, data or {}) + if len(self._buf) >= self._cap: + self._buf.popleft() + self.dropped += 1 + self._buf.append(e) + return e + + def emit_fine(self, fine: str, data: dict[str, Any] | None = None) -> dict[str, Any]: + """记一个**细分层**事件(如 `block_changed:9:4:12`)。不进粗粒度流。""" + self._seq += 1 + e = _ev(self._seq, fine, data or {}) + if len(self._fine) >= self._cap: + self._fine.popleft() + self.dropped += 1 + self._fine.append(e) + return e + + def emit_change(self, kind: str, fine: str, data: dict[str, Any] | None = None) -> None: + """一次变化同时记粗 + 细(最常见:方块/实体变化)。""" + self.emit(kind, data) + if fine: + self.emit_fine(fine, data) + + # ---------- 读取 ---------- + + @property + def seq(self) -> int: + """当前最大序号(= 已产生的粗+细事件总数)。""" + return self._seq + + @property + def cursor(self) -> int: + return self._cursor + + def peek(self) -> list[dict[str, Any]]: + """看看有什么,但不消费。""" + return [e for e in self._buf if e["seq"] > self._cursor] + + def drain(self, limit: int = 40) -> list[dict[str, Any]]: + """取出游标之后的新粗粒度事件,**最多 limit 条**,并推进游标。 + + ★ 为什么要 limit:LLM 上下文有限。超出的留给下次(不丢), + 但要在返回里说清「还有多少没看」,否则 agent 会误以为看全了。 + """ + pending = [e for e in self._buf if e["seq"] > self._cursor] + out = pending[:limit] + if out: + self._cursor = out[-1]["seq"] + return out + + def drain_fine(self, prefix: str, limit: int = 20) -> list[dict[str, Any]]: + """按细分层前缀查(如 `block_changed:`),**不推进粗粒度游标**。""" + out = [e for e in self._fine if e["kind"].startswith(prefix)][:limit] + return out + + def reset_cursor(self) -> None: + """把游标拉到最新(agent 明确说「我不关心之前」时用)。""" + self._cursor = self._seq + + def stats(self) -> dict[str, Any]: + return { + "seq": self._seq, + "cursor": self._cursor, + "coarse_pending": sum(1 for e in self._buf if e["seq"] > self._cursor), + "fine_total": len(self._fine), + "dropped": self.dropped, # 容量不足丢掉的 + } + + +# ---------- 事件到 M3 状态的接线 ---------- + +#: 状态层每类变化对应的粗粒度事件名。刻意保持少而清晰。 +#: —— 事件名是给 agent 的**稳定契约**,改名字会破坏 agent 的 prompt。 +COARSE_KINDS = { + "player_moved", # 自己移动 + "joined", # 进场 + "chat", # 聊天/系统消息 + "block_changed", # 方块变了 + "entity_spawned", # 实体出现 + "entity_moved", # 实体移动 + "entity_removed", # 实体消失 + "damage", # 受伤/死亡 + "health_changed", # 生命/饥饿 + "chunk_loaded", # 新区块 + "chunk_unloaded", # 区块卸载 + "held_item", # 手持物变化 + "inventory", # 背包变化 +} + + +def render_events(events: Iterable[dict[str, Any]]) -> str: + """把事件渲染成给 LLM 读的一行行文本(省 token 的关键)。 + + ★ 刻意做成「文本行」而非嵌套 JSON:LLM 读 `block_changed @ 9,4,12 air` + 比读 `{"kind":"block_changed","data":{"pos":[9,4,12],"from":"air","to":"stone"}}` + 省一半以上 token,且更易扫读。 + """ + lines: list[str] = [] + for e in events: + k, d = e["kind"], e["data"] + if k == "block_changed": + lines.append( + f"#{e['seq']} 方块变化 @({d.get('x')},{d.get('y')},{d.get('z')}) " + f"{d.get('from')} → {d.get('to')}") + elif k == "entity_spawned": + lines.append(f"#{e['seq']} 出现 {d.get('kind')} id={d.get('entity_id')}") + elif k == "entity_moved": + lines.append( + f"#{e['seq']} {d.get('kind')} id={d.get('entity_id')} 移动到 " + f"({d.get('x')},{d.get('y')},{d.get('z')}) 距 {d.get('distance')}") + elif k == "entity_removed": + lines.append(f"#{e['seq']} 消失 id={d.get('entity_id')}") + elif k == "player_moved": + lines.append( + f"#{e['seq']} 我移动到 ({d.get('x')},{d.get('y')},{d.get('z')})") + elif k == "chat": + who, what = d.get("from"), d.get("text") + lines.append(f"#{e['seq']} 聊天 {who}: {what}" if who + else f"#{e['seq']} 系统: {what}") + elif k == "damage": + lines.append(f"#{e['seq']} 受到伤害 {d.get('amount')} 血量 {d.get('health')}") + elif k == "health_changed": + lines.append(f"#{e['seq']} 生命/饥饿 → {d.get('health')}/{d.get('food')}") + elif k == "chunk_loaded": + lines.append(f"#{e['seq']} 区块加载 ({d.get('x')},{d.get('z')})") + elif k == "chunk_unloaded": + lines.append(f"#{e['seq']} 区块卸载 ({d.get('x')},{d.get('z')})") + elif k == "joined": + lines.append(f"#{e['seq']} 进入世界 ({d.get('dimension')})") + elif k == "held_item": + lines.append(f"#{e['seq']} 手持 {d.get('item')}") + else: + # 未知事件:如实标注,不静默吞 + lines.append(f"#{e['seq']} {k} {d}") + return "\n".join(lines) diff --git a/mcfold/session.py b/mcfold/session.py index 1d6de4b..32de384 100644 --- a/mcfold/session.py +++ b/mcfold/session.py @@ -25,6 +25,26 @@ 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: @@ -55,7 +75,9 @@ class StateSession: if registry is None: from .state import load_vanilla_registry self.state.world.registry = load_vanilla_registry(version, data_root) - # 事件回调:M4 的事件层会挂在这里(现在先留口子) + # ★ M4 事件层:给 LLM 用的**可按需排空**的事件日志(非回调推送) + self.events = EventLog() + # 兼容旧接口:回调式监听(给非 LLM 的宿主用),事件仍会同时入 EventLog self._listeners: list[tuple[str, Callable[[str, Any], None]]] = [] # ---------- 事件(M4 的落点,本轮只留机制) ---------- @@ -64,6 +86,14 @@ class StateSession: 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) @@ -219,10 +249,157 @@ class StateSession: 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: diff --git a/mcfold/state.py b/mcfold/state.py index 18ea0eb..368a2a9 100644 --- a/mcfold/state.py +++ b/mcfold/state.py @@ -146,6 +146,13 @@ class WorldState: 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]] = [] + self.bulk_changes = 0 + self.bulk_change_records: list[int] = [] # 服务端给的 11 个动态注册表(权威,保留原始条目) self.registries: dict[str, Any] = {} self.packets_seen: dict[str, int] = {} @@ -196,6 +203,33 @@ class WorldState: 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 @@ -209,6 +243,20 @@ class WorldState: 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, diff --git a/plan.md b/plan.md index 849c9b3..254fa36 100644 --- a/plan.md +++ b/plan.md @@ -564,7 +564,7 @@ shot.rough → ASCII + 线索(给 agent 看它为什么选了这个角度) | **M2** | **协议客户端**:连测试服 → configuration → registry_data → 进 play | M0 | ✅ | | | (这是最大缺口:现有只到 `login_start` 冒烟级) | | | | **M3** | **状态层**:World / entities / inventory 从**服务端真状态**来 | M2 | ✅ | -| **M4** | **事件层**:粗粒度 + 细分层(照 mineflayer,省 token) | M2 | ⬜ | +| **M4** | **事件层**:粗粒度 + 细分层(照 mineflayer,省 token) | M2 | ✅ | | **M5** | **操作层**:chat / look / move / dig / place(能改世界) | M2, M3 | ⬜ | | **M6** | **粗渲染**(常驻感知):基于 M3 的真状态 | M3 | ⬜ | | **M7** | **LLM 友好的 API 形状**(§1d):自省 / 可读返回 / 限量 | M3–M6 | ⬜ | @@ -634,6 +634,40 @@ nearby 半径 6 → 3 种(dirt 338 / bedrock 169 / grass_block 169) ★ 其中「未知方块静默映射」第一次变异**是空操作**(改错了文件)⇒ 假绿; 核对变异是否真命中后重做,才确认判据有效。**变异必须先验它落地了**。 +### M4 实测结论(2026-09-26) + +新增 `mcfold/events.py`(事件日志)+ session 的事件接线。 + +**关键设计:不是回调,而是「环形缓冲 + 游标」** +mineflayer 用回调(`bot.on('physicsTick')`),适合长跑机器人; +但 LLM agent 是**回合制**的 —— 回调对它有三个坏处:事件在两次观察之间 +静默丢失、状态散在闭包里不可查、粒度不可控(physicsTick 20/s 烧穿 token)。 +⇒ 用可按需 `drain()` 的日志 + 单调 `seq`,agent 能问「我看过 seq=37 之后又发生了什么」。 + +**实测(连测试服,含真实世界改动)**: +``` +/setblock -2 -61 -5 minecraft:stone + #27 方块变化 @(-2,-61,-5) grass_block → stone + #26 系统: commands.setblock.success +以后 setblock ... air + #33 方块变化 @(-2,-61,-5) stone → air +状态层: stone(state_id=1) → air(state_id=0) ✓ 一致 +细分层可查: block_changed:-2:-61:-5 ✓ +``` + +**本轮修的真 bug(3 个)**: +1. **`dropped` 恒为 0**:`deque(maxlen=)` 丢元素**完全无声**(实测 maxlen=4 塞 10 只剩 + [6,7,8,9])。这正是本层最不能犯的错 —— 事件丢了却不说,agent 以为世界没变。 + ⇒ 改手动判满 + 显式计数。 +2. **事件键与渲染器不一致**:我发 `to_state_id`、渲染器读 `to` ⇒ 显示 + 「grass_block → None」(看着有事件、其实没信息)。改为解出**方块名**, + 且 `from` 区分「未加载/界外」而不是裸 None(把 unloaded 读成 air 会让 agent 误判脚下)。 +3. **run_tests.sh 少报判据数**:只数 `^ ✓`,我特意用 `★` 标的回归判据没被计入 + (test_events 16 条只报 7 条)⇒ 少报也是撒谎。修后总数 98 → **110**。 + +**判据**:`tests/test_events.py` 16 项;4 项变异全部被抓 +(drain 不推游标 / limit 变丢弃 / 细分层混入粗流 / 实体增量不除 4096)。 + **建议顺序:M0 → M1 → M2**。 - M0/M1 是解阻断(M1 是我已经查到根因的那个 classpath bug) - **M2 是真正的门槛**:从「能渲染」到「能玩」,缺的不是渲染,是**完整协议层**。 diff --git a/run_tests.sh b/run_tests.sh index ab0c467..5f31772 100755 --- a/run_tests.sh +++ b/run_tests.sh @@ -26,7 +26,10 @@ for t in tests/test_*.py; do fi out=$(python3 "$t" 2>&1) if echo "$out" | grep -qE "全部通过|通过$"; then - n=$(echo "$out" | grep -c "^ ✓") + # ★ 计数必须包含 ★ 判据:我特意用 ★ 标出「这条守住了一个真 bug 的回归」, + # 它们同样是**通过**的判据。只数 ✓ 会让总数少报(曾 16 条只报 7 条) + # —— 报告少报也是撒谎。 + n=$(echo "$out" | grep -cE "^ (✓|★)") skipped=$(echo "$out" | grep -c "^ - 跳过") total=$((total+n)) # ★ 全跳过的套件不算绿:它对环境一无所知,报绿等于把"没测"说成"测过了" diff --git a/tests/test_events.py b/tests/test_events.py new file mode 100644 index 0000000..2ce8a9d --- /dev/null +++ b/tests/test_events.py @@ -0,0 +1,324 @@ +"""M4 事件层判据:events.py + session 的事件接线。 + +## 为什么这层的判据特别要「反直觉」式断言 + +事件层最危险的缺陷是**静默**: +- 事件丢了 → agent 以为世界没变(比报错糟) +- 游标没推进 → agent 反复看同一批(浪费 token 且做出重复动作) +- 细分层混进粗粒度 → 每次观察几百条噪声(烧 token) +- 事件里字段名与渲染器不一致 → 显示 `None`(看着有事件,其实没信息) + +所以判据都对着这些「静默退化」写,且**有反向对照** +(负向输入必须报错/拒绝,不能静默通过)。 +""" +import sys, os +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from mcfold.events import EventLog, render_events, COARSE_KINDS +from mcfold.state import WorldState +from mcfold.chunk import BlockRegistry +from mcfold.session import StateSession, _nbt_text + + +def _reg(): + from mcfold.state import load_vanilla_registry + return load_vanilla_registry( + '1.21.1', + '/home/newqqagent/plugin_data/mc/runtime/node_modules/minecraft-data/minecraft-data/data') + + +def _bare(): + sess = StateSession.__new__(StateSession) + sess.state = WorldState(registry=_reg(), num_sections=24) + sess.events = EventLog() + sent = [] + sess.client = type("C", (), { + "_serverbound_id": staticmethod(lambda *a: 0x04), + "send": staticmethod(lambda b: sent.append(b)), + })() + sess._listeners = [] + sess._sent = sent + return sess + + +# ---------- 缓冲与游标 ---------- + + +def test_drain_is_idempotent_and_advances_cursor(): + """★ 游标必须幂等:同一时刻反复 drain 不会拿到重复事件。 + + 这是 token 与正确性的双关:重复读会让 agent 重复动作。 + """ + log = EventLog() + for i in range(5): + log.emit("chat", {"text": f"m{i}"}) + a = log.drain() + assert len(a) == 5 + b = log.drain() + assert b == [], f"第二次排空应为空,实际 {b}" + assert log.cursor == log.seq + # peek 不消费:★ 判据要点是「peek 前后 cursor 不变」, + # 而不是 cursor==seq(emit 新事件后 seq 会比 cursor 大,那是正常的) + log.emit("chat", {"text": "later"}) + before = log.cursor + assert len(log.peek()) == 1 + assert len(log.peek()) == 1 # peek 可反复看 + assert log.cursor == before # ★ peek 绝不推进游标 + assert len(log.drain()) == 1 + assert log.cursor == log.seq + print(" ★ drain 幂等且推进游标,peek 不消费") + + +def test_drain_limit_keeps_backlog(): + """★ limit 只是「本轮取多少」,**不是丢弃**:剩下的留给下次。""" + log = EventLog() + for i in range(10): + log.emit("block_changed", {"x": i}) + first = log.drain(limit=4) + assert len(first) == 4 + second = log.drain(limit=4) + assert len(second) == 4 + third = log.drain(limit=4) + assert len(third) == 2 + assert fourth_empty(log) is True + # 且序号连续,没有重复或跳过 + all_seen = [e["seq"] for e in first + second + third] + assert all_seen == list(range(1, 11)), all_seen + print(" ★ limit 不丢事件(剩 6 条分两轮取完,序号连续)") + + +def fourth_empty(log): + return log.drain() == [] + + +def test_fine_layer_separate_and_queryable(): + """★ 细分层(block_changed:x:y:z)不得混进粗粒度流。""" + log = EventLog() + log.emit_change("block_changed", "block_changed:1:2:3", {"x": 1, "y": 2, "z": 3}) + log.emit_change("block_changed", "block_changed:4:5:6", {"x": 4, "y": 5, "z": 6}) + coarse = log.drain() + assert len(coarse) == 2 and all(e["kind"] == "block_changed" for e in coarse) + fine = log.drain_fine("block_changed:") + assert len(fine) == 2 + assert [e["kind"] for e in fine] == ["block_changed:1:2:3", "block_changed:4:5:6"] + # 按坐标前缀筛 + one = log.drain_fine("block_changed:4:5:6") + assert len(one) == 1 + # ★ 查细分层**不推进**粗粒度游标 + assert log.drain() == [] + print(" ★ 细分层独立可查,不污染粗粒度流") + + +def test_capacity_drops_are_counted_not_hidden(): + """★ 环形缓冲满了会丢,但必须**如实计数**。 + + 静默丢 = agent 以为世界没发生事(最坏的撒谎)。 + """ + log = EventLog(capacity=4) + for i in range(10): + log.emit("chat", {"text": str(i)}) + st = log.stats() + assert st["dropped"] == 6, st + got = log.drain() + assert len(got) == 4 + assert got[0]["data"]["text"] == "6", got[0] # 最旧的已丢 + print(" ★ 容量溢出时丢最旧且 dropped 如实计数(6)") + + +def test_reset_cursor_skips_history(): + log = EventLog() + for i in range(3): + log.emit("chat", {"text": str(i)}) + log.reset_cursor() + assert log.drain() == [] + log.emit("chat", {"text": "new"}) + assert len(log.drain()) == 1 + print(" ✓ reset_cursor 跳过历史(agent 明确不关心之前)") + + +# ---------- 渲染 ---------- + + +def test_render_block_change_has_real_names(): + """★ 事件里的方块必须是**名字**不是 id,且 from/to 都不能为 None。 + + 回归:先前我发的事件键叫 to_state_id 而渲染器读 to ⇒ 显示 + 「grass_block → None」——看着有事件,其实没信息。 + """ + sess = _bare() + sess._apply("block_change", { + "location": {"x": 9, "y": 64, "z": -3}, + "type": 1, # stone + }) + evs = sess.events.drain() + bc = [e for e in evs if e["kind"] == "block_changed"] + assert bc, evs + d = bc[0]["data"] + assert d["x"] == 9 and d["y"] == 64 and d["z"] == -3, d + # ★ 名字必须解出来了(不是 id 也不是 None) + assert d["to"] == "stone", d + assert d["to"] is not None and "unknown" not in str(d["to"]), d + txt = render_events(bc) + assert "→ stone" in txt, txt + assert "None" not in txt, f"渲染里出现 None:{txt}" + print(f" ★ 方块变化事件带真实名字({txt.splitlines()[0]})") + + +def test_render_unknown_block_is_honest(): + """认不出的方块要显示 unknown(id),不能编个名字。""" + sess = _bare() + # 用一张几乎空的注册表,逼出未知 + sess.state.world.registry = BlockRegistry( + [{"name": "stone", "minStateId": 1, "maxStateId": 1}]) + sess._apply("block_change", {"location": {"x": 0, "y": 0, "z": 0}, "type": 9999}) + d = [e for e in sess.events.drain() if e["kind"] == "block_changed"][0]["data"] + assert d["to_state_id"] == 9999 + assert d["to"] is None, d + # 状态层把它记成未知(供上层告警) + assert 9999 in sess.state.world.unknown_state_ids + print(" ★ 未知方块不编名字(to=None 且记账)") + + +def test_render_is_compact(): + """渲染要省 token:短行、无冗余 JSON 结构。""" + log = EventLog() + log.emit("block_changed", {"x": 1, "y": 2, "z": 3, "from": "air", "to": "stone"}) + log.emit("chat", {"from": "Alice", "text": "hi"}) + log.emit("entity_spawned", {"kind": "pig", "entity_id": 7}) + log.emit("player_moved", {"x": 1.0, "y": 2.0, "z": 3.0}) + txt = render_events(log.drain()) + assert "方块变化 @(1,2,3) air → stone" in txt, txt + assert "聊天 Alice: hi" in txt, txt + assert "出现 pig id=7" in txt, txt + assert "我移动到 (1.0,2.0,3.0)" in txt, txt + # ★ 断言「不用 JSON 大括号」——这正是省 token 的关键 + assert "{" not in txt and '"' not in txt, f"混进了 JSON:{txt}" + print(f" ✓ 渲染紧凑(4 事件 {len(txt)} 字符,无 JSON 噪声)") + + +def test_unknown_event_not_silently_swallowed(): + """未知事件必须出现在渲染里(agent 能看到有东西发生了)。""" + log = EventLog() + log.emit("some_new_thing", {"a": 1}) + txt = render_events(log.drain()) + assert "some_new_thing" in txt, txt + print(" ★ 未知事件如实渲染(不静默吞)") + + +def test_coarse_kinds_are_stable_contract(): + """COARSE_KINDS 是给 agent 的稳定契约,不该随意变动。""" + for k in ("block_changed", "chat", "entity_spawned", "player_moved", + "damage", "joined", "chunk_loaded"): + assert k in COARSE_KINDS, k + # 细分层命名不应混进粗粒度契约 + assert not any(":" in k for k in COARSE_KINDS), COARSE_KINDS + print(f" ✓ 粗粒度契约稳定({len(COARSE_KINDS)} 种,名称无冒号)") + + +# ---------- NBT 文本提取 ---------- + + +def test_nbt_text_extracts_or_reports_honestly(): + """★ 取不出文本时返回原值,**不返回空串**。 + + chat 是 agent 判断局势的重要输入;静默变「(空)」比看不懂更糟。 + """ + assert _nbt_text("hello") == "hello" + assert _nbt_text({"text": "hi"}) == "hi" + assert _nbt_text({"raw": "raw!"}) == "raw!" + weird = {"weird": {"deep": 1}} + assert _nbt_text(weird) == weird, "取不出应原样返回" + assert _nbt_text(None) is None + print(" ✓ NBT 文本能提取,取不出则如实返回原值") + + +# ---------- session 接线 ---------- + + +def test_entity_move_uses_4096_scale(): + """★ 实体移动增量的单位是 **1/4096**,不是 1/1。 + + 写错的症状很隐蔽:实体坐标看起来「合理」,但完全错(瞬移到远处)。 + """ + sess = _bare() + from mcfold.state import Entity + sess.state.upsert_entity(Entity(entity_id=1, kind="pig", x=0.0, y=0.0, z=0.0)) + sess.state.player.x = 0.0 + # 4096 * 0.5 = 2048 ⇒ 应移动 0.5 格 + sess._apply("rel_entity_move", {"entityId": 1, "deltaX": 2048, + "deltaY": 1024, "deltaZ": -2048}) + e = sess.state.entities[1] + assert (round(e.x, 4), round(e.y, 4), round(e.z, 4)) == (0.5, 0.25, -0.5), \ + (e.x, e.y, e.z) + # ★ 反向对照:若用 /1 会得到 2048 格(离谱) + assert abs(e.x) < 1.0, f"疑似未除 4096:{e.x}" + print(f" ★ 实体增量按 1/4096 换算(x={e.x}, y={e.y}, z={e.z})") + + +def test_damage_reduces_health(): + sess = _bare() + sess.state.player.entity_id = 7 + sess.state.player.health = 20.0 + sess._apply("damage_event", {"entityId": 7, "damage": 6, "sourceCauseId": 1}) + assert sess.state.player.health == 14.0, sess.state.player.health + evs = [e for e in sess.events.drain() if e["kind"] == "damage"] + assert evs and evs[0]["data"]["health"] == 14.0 + # 血不能扣成负 + sess._apply("damage_event", {"entityId": 7, "damage": 100}) + assert sess.state.player.health == 0.0, sess.state.player.health + print(" ★ 伤害扣血且不会扣成负数") + + +def test_damage_to_others_ignored(): + """别人受伤不是「我的伤害」事件(否则 agent 会误判自己挨打)。""" + sess = _bare() + sess.state.player.entity_id = 7 + sess.state.player.health = 20.0 + sess._apply("damage_event", {"entityId": 999, "damage": 5}) + assert sess.state.player.health == 20.0 + assert not [e for e in sess.events.drain() if e["kind"] == "damage"] + print(" ✓ 别人受伤不误报为我的伤害") + + +def test_entity_destroy_emits_removal(): + from mcfold.state import Entity + sess = _bare() + sess.state.upsert_entity(Entity(entity_id=5, kind="cow")) + sess.state.upsert_entity(Entity(entity_id=6, kind="sheep")) + sess._apply("entity_destroy", {"entityIds": [5]}) + assert 5 not in sess.state.entities and 6 in sess.state.entities + rem = [e for e in sess.events.drain() if e["kind"] == "entity_removed"] + assert len(rem) == 1 and rem[0]["data"]["entity_id"] == 5 + print(" ✓ 实体消失只报实际消失的那个") + + +def test_both_listeners_and_eventlog_fire(): + """两路并存:回调(宿主)与事件日志(LLM)都要收到。""" + sess = _bare() + got = [] + sess.on("block_changed", lambda e, p: got.append(p)) + sess._apply("block_change", {"location": {"x": 1, "y": 2, "z": 3}, "type": 1}) + assert len(got) == 1 + assert [e for e in sess.events.drain() if e["kind"] == "block_changed"] + print(" ✓ 回调与事件日志两路并存") + + +if __name__ == "__main__": + for fn in [test_drain_is_idempotent_and_advances_cursor, + test_drain_limit_keeps_backlog, + test_fine_layer_separate_and_queryable, + test_capacity_drops_are_counted_not_hidden, + test_reset_cursor_skips_history, + test_render_block_change_has_real_names, + test_render_unknown_block_is_honest, + test_render_is_compact, + test_unknown_event_not_silently_swallowed, + test_coarse_kinds_are_stable_contract, + test_nbt_text_extracts_or_reports_honestly, + test_entity_move_uses_4096_scale, + test_damage_reduces_health, + test_damage_to_others_ignored, + test_entity_destroy_emits_removal, + test_both_listeners_and_eventlog_fire]: + fn() + print("\n事件层:全部通过") diff --git a/tests/test_state.py b/tests/test_state.py index 5678881..6fe6c32 100644 --- a/tests/test_state.py +++ b/tests/test_state.py @@ -38,6 +38,7 @@ def _bare_session(): 调 on() 就 AttributeError。这里显式补上必需字段。 """ from mcfold.session import StateSession + from mcfold.events import EventLog sess = StateSession.__new__(StateSession) sess.state = _state() sess.client = type("C", (), { @@ -45,6 +46,9 @@ def _bare_session(): "send": staticmethod(lambda b: None), })() sess._listeners = [] + # ★ M4 之后 _emit 会写 EventLog,忘记建它会在「卸载区块」等路径上炸。 + # 这正是「修一处要搜同类形态」的教训:新加字段时所有手搓会话都要跟上。 + sess.events = EventLog() sess._sent = [] return sess