新增:
- 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 项变异全部被抓(含「变异空操作 ⇒ 假绿」的核对)
239 lines
8.7 KiB
Python
239 lines
8.7 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
|
||
|
||
|
||
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()
|