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 .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:
|
||||
|
||||
@ -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,
|
||||
|
||||
Reference in New Issue
Block a user