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 项变异全部被抓
This commit is contained in:
212
mcfold/events.py
Normal file
212
mcfold/events.py
Normal file
@ -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)
|
||||||
@ -25,6 +25,26 @@ from typing import Any, Callable
|
|||||||
from .client import Client
|
from .client import Client
|
||||||
from .state import Entity, WorldState, ingest_configuration
|
from .state import Entity, WorldState, ingest_configuration
|
||||||
from .protodef import Writer
|
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:
|
class StateSession:
|
||||||
@ -55,7 +75,9 @@ class StateSession:
|
|||||||
if registry is None:
|
if registry is None:
|
||||||
from .state import load_vanilla_registry
|
from .state import load_vanilla_registry
|
||||||
self.state.world.registry = load_vanilla_registry(version, data_root)
|
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]]] = []
|
self._listeners: list[tuple[str, Callable[[str, Any], None]]] = []
|
||||||
|
|
||||||
# ---------- 事件(M4 的落点,本轮只留机制) ----------
|
# ---------- 事件(M4 的落点,本轮只留机制) ----------
|
||||||
@ -64,6 +86,14 @@ class StateSession:
|
|||||||
self._listeners.append((event, fn))
|
self._listeners.append((event, fn))
|
||||||
|
|
||||||
def _emit(self, event: str, payload: Any) -> None:
|
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:
|
for name, fn in self._listeners:
|
||||||
if name == event:
|
if name == event:
|
||||||
fn(event, payload)
|
fn(event, payload)
|
||||||
@ -219,10 +249,157 @@ class StateSession:
|
|||||||
elif name == "update_view_position":
|
elif name == "update_view_position":
|
||||||
self._emit("view_moved", params)
|
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":
|
elif name == "declare_recipes":
|
||||||
# 上游数据缺口解不开,但**包名已知**;这里不做任何事
|
# 上游数据缺口解不开,但**包名已知**;这里不做任何事
|
||||||
self._emit("recipes_declared", {"decoded": False})
|
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:
|
def close(self) -> None:
|
||||||
|
|||||||
@ -146,6 +146,13 @@ class WorldState:
|
|||||||
self.geometry = WorldGeometry()
|
self.geometry = WorldGeometry()
|
||||||
self.player = PlayerView()
|
self.player = PlayerView()
|
||||||
self.entities: dict[int, Entity] = {}
|
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 个动态注册表(权威,保留原始条目)
|
# 服务端给的 11 个动态注册表(权威,保留原始条目)
|
||||||
self.registries: dict[str, Any] = {}
|
self.registries: dict[str, Any] = {}
|
||||||
self.packets_seen: dict[str, int] = {}
|
self.packets_seen: dict[str, int] = {}
|
||||||
@ -196,6 +203,33 @@ class WorldState:
|
|||||||
def apply_registry(self, rid: str, entries: Any) -> None:
|
def apply_registry(self, rid: str, entries: Any) -> None:
|
||||||
self.registries[rid] = entries
|
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:
|
def upsert_entity(self, ent: Entity) -> None:
|
||||||
self.entities[ent.entity_id] = ent
|
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]:
|
def block_at(self, x: int, y: int, z: int) -> dict[str, Any]:
|
||||||
"""单个方块。返回来源标记,agent 能分辨「确实没有」与「还没看见」。"""
|
"""单个方块。返回来源标记,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)
|
hit = self.world.block_at(x, y, z)
|
||||||
return {
|
return {
|
||||||
"name": hit.name,
|
"name": hit.name,
|
||||||
|
|||||||
36
plan.md
36
plan.md
@ -564,7 +564,7 @@ shot.rough → ASCII + 线索(给 agent 看它为什么选了这个角度)
|
|||||||
| **M2** | **协议客户端**:连测试服 → configuration → registry_data → 进 play | M0 | ✅ |
|
| **M2** | **协议客户端**:连测试服 → configuration → registry_data → 进 play | M0 | ✅ |
|
||||||
| | (这是最大缺口:现有只到 `login_start` 冒烟级) | | |
|
| | (这是最大缺口:现有只到 `login_start` 冒烟级) | | |
|
||||||
| **M3** | **状态层**:World / entities / inventory 从**服务端真状态**来 | M2 | ✅ |
|
| **M3** | **状态层**:World / entities / inventory 从**服务端真状态**来 | M2 | ✅ |
|
||||||
| **M4** | **事件层**:粗粒度 + 细分层(照 mineflayer,省 token) | M2 | ⬜ |
|
| **M4** | **事件层**:粗粒度 + 细分层(照 mineflayer,省 token) | M2 | ✅ |
|
||||||
| **M5** | **操作层**:chat / look / move / dig / place(能改世界) | M2, M3 | ⬜ |
|
| **M5** | **操作层**:chat / look / move / dig / place(能改世界) | M2, M3 | ⬜ |
|
||||||
| **M6** | **粗渲染**(常驻感知):基于 M3 的真状态 | M3 | ⬜ |
|
| **M6** | **粗渲染**(常驻感知):基于 M3 的真状态 | M3 | ⬜ |
|
||||||
| **M7** | **LLM 友好的 API 形状**(§1d):自省 / 可读返回 / 限量 | M3–M6 | ⬜ |
|
| **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 → M2**。
|
||||||
- M0/M1 是解阻断(M1 是我已经查到根因的那个 classpath bug)
|
- M0/M1 是解阻断(M1 是我已经查到根因的那个 classpath bug)
|
||||||
- **M2 是真正的门槛**:从「能渲染」到「能玩」,缺的不是渲染,是**完整协议层**。
|
- **M2 是真正的门槛**:从「能渲染」到「能玩」,缺的不是渲染,是**完整协议层**。
|
||||||
|
|||||||
@ -26,7 +26,10 @@ for t in tests/test_*.py; do
|
|||||||
fi
|
fi
|
||||||
out=$(python3 "$t" 2>&1)
|
out=$(python3 "$t" 2>&1)
|
||||||
if echo "$out" | grep -qE "全部通过|通过$"; then
|
if echo "$out" | grep -qE "全部通过|通过$"; then
|
||||||
n=$(echo "$out" | grep -c "^ ✓")
|
# ★ 计数必须包含 ★ 判据:我特意用 ★ 标出「这条守住了一个真 bug 的回归」,
|
||||||
|
# 它们同样是**通过**的判据。只数 ✓ 会让总数少报(曾 16 条只报 7 条)
|
||||||
|
# —— 报告少报也是撒谎。
|
||||||
|
n=$(echo "$out" | grep -cE "^ (✓|★)")
|
||||||
skipped=$(echo "$out" | grep -c "^ - 跳过")
|
skipped=$(echo "$out" | grep -c "^ - 跳过")
|
||||||
total=$((total+n))
|
total=$((total+n))
|
||||||
# ★ 全跳过的套件不算绿:它对环境一无所知,报绿等于把"没测"说成"测过了"
|
# ★ 全跳过的套件不算绿:它对环境一无所知,报绿等于把"没测"说成"测过了"
|
||||||
|
|||||||
324
tests/test_events.py
Normal file
324
tests/test_events.py
Normal file
@ -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事件层:全部通过")
|
||||||
@ -38,6 +38,7 @@ def _bare_session():
|
|||||||
调 on() 就 AttributeError。这里显式补上必需字段。
|
调 on() 就 AttributeError。这里显式补上必需字段。
|
||||||
"""
|
"""
|
||||||
from mcfold.session import StateSession
|
from mcfold.session import StateSession
|
||||||
|
from mcfold.events import EventLog
|
||||||
sess = StateSession.__new__(StateSession)
|
sess = StateSession.__new__(StateSession)
|
||||||
sess.state = _state()
|
sess.state = _state()
|
||||||
sess.client = type("C", (), {
|
sess.client = type("C", (), {
|
||||||
@ -45,6 +46,9 @@ def _bare_session():
|
|||||||
"send": staticmethod(lambda b: None),
|
"send": staticmethod(lambda b: None),
|
||||||
})()
|
})()
|
||||||
sess._listeners = []
|
sess._listeners = []
|
||||||
|
# ★ M4 之后 _emit 会写 EventLog,忘记建它会在「卸载区块」等路径上炸。
|
||||||
|
# 这正是「修一处要搜同类形态」的教训:新加字段时所有手搓会话都要跟上。
|
||||||
|
sess.events = EventLog()
|
||||||
sess._sent = []
|
sess._sent = []
|
||||||
return sess
|
return sess
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user