Files
agentmc/mcfold/session.py
JianFeeeee 40b2e1d60f M3:状态层(服务端真状态)+ 包流灌入 + 14 项判据
新增:
- mcfold/state.py   WorldState / WorldGeometry / PlayerView / Entity
- mcfold/session.py StateSession:connect() 走完 login→configuration→play,
                    pump() 把包落进状态,on() 留 M4 事件口

修的真 bug:
- unload_chunk 协议顺序是 chunkZ 在前、chunkX 在后;按 x 先读会坐标互换,
  卸载永不命中 ⇒ 区块只增不减(状态撒谎)

设计立场(防越界):
- 只陈述观察、不做决策(nearby_blocks 给统计不给「该挖哪个」)
- 未知 ≠ 没看见:认不出的 state id 显式记账,绝不静默映射成别的方块
- 超范围拒绝并说明,不静默缩小
- 几何从服务端 dimension_type 吸收,不写死 24/384

实测:11 注册表 / 9 区块 / 生命 20 饥饿 20 / 列剖面 bedrock→dirt→grass_block
判据:91 项全通过;4 项变异全部被抓(含「变异空操作 ⇒ 假绿」的核对)
2026-09-26 12:21:18 +08:00

239 lines
8.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""把 M2 的包流灌进 M3 的状态层(M3 的「线」)。
## 为什么单独一个模块
M2(协议)与 M3(状态)关心的事不同:
- 协议层:**这串字节是什么包**
- 状态层:**世界现在是什么样**
把「收到包 ⇒ 更新状态」单独放一处,好处:
- 状态层的每个字段都能追到它来自哪个包(可核查)
- 新增状态字段时不必碰协议解析
- 线上的坏包(解不开的)在此处统一记账,不污染状态
## 刻意的克制
只做「落状态」,**不做**:
- 预测(不打提前量、不猜实体下一步)
- 决策(不去找目标、不规划路径)
- 猜测(认不出的 state id 照实记 unknown,不映射成别的方块)
"""
from __future__ import annotations
from typing import Any, Callable
from .client import Client
from .state import Entity, WorldState, ingest_configuration
from .protodef import Writer
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 的事件层会挂在这里(现在先留口子)
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:
for name, fn in self._listeners:
if name == event:
fn(event, payload)
# ---------- 连接 ----------
def connect(self, timeout: float = 15.0) -> dict[str, Any]:
"""走完 login → configuration → play,返回各阶段摘要。"""
import time as _t
c = self.client
c.load_protocol()
c.connect()
c.sock.settimeout(timeout)
c.handshake(state=2)
c.login_start()
# login 阶段:读到 success(途中处理压缩阈值)
login_seen: list[str] = []
deadline = _t.time() + timeout
while _t.time() < deadline:
p = c._recv("login")
nm = p["name"]
login_seen.append(nm)
if nm == "compress":
c.compression = p["params"].get("threshold", 256)
continue
if nm == "success":
break
if nm == "disconnect":
raise RuntimeError(f"服务端拒绝: {p['params'].get('reason')}")
cfg = c.configuration_handshake()
ingest_configuration(self.state, cfg, c.registries)
self._emit("connected", {"cfg": cfg})
return {
"login_packets": login_seen,
"registries": cfg.get("registryCount"),
"phase": c.phase,
"geometry": self.state.geometry.describe(),
}
# ---------- 收包并落状态 ----------
def pump(self, seconds: float = 1.0) -> dict[str, int]:
"""收 N 秒包,把能落地的状态落地,返回各包计数。"""
import time as _t
c = self.client
c.sock.settimeout(0.5)
deadline = _t.time() + seconds
counts: dict[str, int] = {}
while _t.time() < deadline:
try:
p = c._recv(c.phase)
except TimeoutError:
continue
except Exception as e:
counts["_error"] = counts.get("_error", 0) + 1
self.state.note_packet(f"_error:{type(e).__name__}")
continue
nm = p["name"]
counts[nm] = counts.get(nm, 0) + 1
self.state.note_packet(nm)
if p.get("_undecoded"):
# 上游数据缺口(当前只有 declare_recipes):不落状态,但记账
continue
try:
self._apply(nm, p["params"])
except Exception as e:
counts["_apply_error"] = counts.get("_apply_error", 0) + 1
self._emit("state_error", {"packet": nm, "error": str(e)[:120]})
return counts
def _apply(self, name: str, params: dict[str, Any]) -> None:
st = self.state
c = self.client
if name == "position":
st.update_from_position(params)
self._emit("player_moved", st.player.as_dict())
elif name == "login":
st.update_from_login(params)
self._emit("joined", st.player.as_dict())
elif name == "map_chunk":
ch = st.load_chunk(params["x"], params["z"], params["chunkData"])
self._emit("chunk_loaded", {"x": params["x"], "z": params["z"],
"sections": len(ch.sections)})
elif name == "unload_chunk":
# ★ 协议字段顺序是 chunkZ **在前**、chunkX 在后
# (1.21.1 packet_unload_chunk: [chunkZ:i32, chunkX:i32])
# 我先前写成 params.get("chunkZ", …) 塞给 cx ⇒ x/z 互换,
# 卸载永远不命中 ⇒ 区块越积越多(内存缓慢泄漏 + 状态撒谎)。
cx = params.get("chunkX")
cz = params.get("chunkZ")
if isinstance(cx, int) and isinstance(cz, int):
st.unload_chunk(cx, cz)
self._emit("chunk_unloaded", {"x": cx, "z": cz})
elif name == "keep_alive":
# ★ 必须回,否则服务端 15s 后踢我们(状态层的第一责任)
w = Writer()
w.varint(c._serverbound_id("play", "packet_keep_alive"))
w.int64(int(params.get("keepAliveId", 0)))
c.send(w.getvalue())
elif name == "game_state_change":
reason = params.get("reason")
# 0=开始游戏 1=结束游戏 3=改变gamemode 5=改变难度 6=改变生命
if reason == 3:
st.player.gamemode = str(params.get("gameMode"))
self._emit("game_state", params)
elif name == "update_health":
v = params.get("health")
if isinstance(v, (int, float)):
st.player.health = float(v)
f = params.get("food")
if isinstance(f, (int, float)):
st.player.food = int(f)
self._emit("health_changed", params)
elif name == "entity_position":
eid = params.get("entityId")
if eid in st.entities:
e = st.entities[eid]
if params.get("deltaX") is not None:
e.x += float(params["deltaX"])
e.y += float(params["deltaY"])
e.z += float(params["deltaZ"])
self._emit("entity_moved", e.as_dict())
elif name == "remove_entities":
ids = params.get("entityIds") or params.get("entities") or []
if isinstance(ids, (list, tuple)):
for eid in ids:
st.remove_entity(int(eid))
self._emit("entities_removed", ids)
elif name == "add_entity":
eid = params.get("entityId")
if isinstance(eid, int):
st.upsert_entity(Entity(
entity_id=eid,
kind=str(params.get("type", "unknown")),
x=float(params.get("x", 0.0)),
y=float(params.get("y", 0.0)),
z=float(params.get("z", 0.0)),
))
self._emit("entity_spawned", eid)
elif name == "update_view_position":
self._emit("view_moved", params)
elif name == "declare_recipes":
# 上游数据缺口解不开,但**包名已知**;这里不做任何事
self._emit("recipes_declared", {"decoded": False})
# ---------- 便捷 ----------
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()