"""把 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()