★★ 真 bug(用户问「寻路呢」时暴露出来):move() 之后本地坐标不变,
而服务端认为玩家真的移动了。
实测证据:
连发 5 次 move 后
本地以为: (0.5, -60.0, -3.5) ← 没动
服务端认为: (5.5, -60.0, -3.5) ← 真的动了
修后:本地 (10.5,-60,-3.5) == 服务端 [10.5d,-60.0d,-3.5d] ✓
根因:MC 的自身位置是**客户端权威** —— 客户端跑自己那侧物理并上报,
服务端只做校验(穿墙/超速会发 position 拉回),普通移动**不回包**。
我把这条规则错用成「等服务端权威」⇒ 本地状态永久陈旧。
与另一条的对比(别搞成一套):
自身移动(move/look) 客户端权威 ⇒ 本地必须立刻更新,标 pending 待确认
改世界(dig/place) 服务端权威 ⇒ 本地不动,等 block_changed 事件
★ 同时纠正一条**我自己写错的判据**:
test_move_does_not_falsely_update_local_state(断言「不该改本地」)是错的,
已替换为 test_move_updates_local_state_because_client_is_authoritative,
并补 test_dig_place_do_not_touch_local_world_state 守住相反的那条。
这个 bug 影响很大:寻路/碰撞/可达性判断全都建立在玩家位置上,
状态陈旧会让整个工具箱在错误前提下工作。
452 lines
19 KiB
Python
452 lines
19 KiB
Python
"""把 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
|
||
from .actions import Actions
|
||
|
||
|
||
def _nbt_text(nbt: Any) -> Any:
|
||
"""把 NBT 文本组件渲染成可读文本(1.20.3+ 聊天走组件树,不是纯字符串)。
|
||
|
||
★ 之前的三个错(都是“看着像服务端没反应”的静默失败):
|
||
1. 只取 `text` 字段 ⇒ 拿到 `""`(组件的 text 常常是空串)
|
||
2. 就算取到 `translate` 也只当字符串吐 ⇒ agent 读到的是
|
||
`commands.give.success.single` 这种键名,不是人话
|
||
3. 就算翻译了,`with` 参数里数字存成 `{"": 1}`(空键)没读
|
||
⇒ “Gave Dirt to ”(参数全空)
|
||
现在统一走 textcomp.render_text(查 language.json + 填 %s)。
|
||
★ 失败时**保留原文/键名**而不是空串:空串会让 agent 以为「什么也没发生」。
|
||
"""
|
||
try:
|
||
from .textcomp import render_text
|
||
out = render_text(nbt)
|
||
return out if out else nbt
|
||
except Exception:
|
||
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()
|
||
# ★ M5 操作层:让 agent 能改变世界(chat/look/move/dig/place…)
|
||
self.actions = Actions(self)
|
||
# ★ 服务端传送确认计数(不 confirm 就会导致方块操作被静默忽略)
|
||
self.teleports_confirmed = 0
|
||
# ★ 自身移动后等不到「纠正」的次数 —— 用于判断本地状态是否可信。
|
||
# 普通移动服务端**不会**回 position(那不是它的职责),
|
||
# 所以本地位置始终是「预期值」;只有收到 position 才算被确认。
|
||
self.pending_corrections = 0
|
||
# 兼容旧接口:回调式监听(给非 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":
|
||
# ★ 收到服务端的权威位置 ⇒ 本地「预期值」得到确认/纠正
|
||
self.pending_corrections = 0
|
||
st.update_from_position(params)
|
||
# ★★ M5 的关键一环(实测 2026-09-26,卡了很久):
|
||
# 1.19+ 的 position 包带 `teleportId`,客户端**必须**回
|
||
# teleport_confirm,**否则服务端会忽略后续的方块操作**
|
||
# (dig/place/use 全部默默无效,且不给任何错误)。
|
||
# 症状:“操作没反应”,与“权限不足/空手”完全无法区分。
|
||
# 实测:补发 confirm 后 place 立刻成功(air → stone)。
|
||
tid = params.get("teleportId")
|
||
if isinstance(tid, int) and tid >= 0:
|
||
w = Writer()
|
||
w.varint(c._serverbound_id("play", "packet_teleport_confirm"))
|
||
w.varint(tid)
|
||
c.send(w.getvalue())
|
||
self.teleports_confirmed += 1
|
||
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 == "set_slot":
|
||
# ★ 背包变化:没有它就不知道手上是什么 ⇒ place 会“发了但没反应”
|
||
# 而你完全看不出原因(症状与“服务端拒绝”一模一样)。
|
||
wid = params.get("windowId")
|
||
if wid == 0 or wid is None:
|
||
slot = params.get("slot")
|
||
if isinstance(slot, int):
|
||
st.set_held_slot_item(slot, params.get("item"))
|
||
self._emit("held_item", {"slot": slot,
|
||
"item": st.held_item_name()})
|
||
|
||
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()
|