"""实例层:传入文件夹路径 → 拉起一个完整实例。 这是用户口径的落点(2026-09-25): "支持传入文件夹路径,按照版本文件夹内容,拉起实例, 并读取 mod jar 注册方块表。然后链接服务端,获取到数据。" 实例文件夹 == 一个完整的 MC 客户端安装(与官方启动器一致): instances// ← 传进来的就是它(== .minecraft) versions//.json ← 决定协议版本(含加载器继承) versions//.jar ← 客户端本体(渲染器) mods/*.jar ← mod(决定方块名清单) config/ options.txt saves/ ← 实例私有 本模块把这些组合起来,并对**每一项能力标注可信度**: * 协议版本 → 可信(来自 version.json + inheritsFrom) * 方块名 → 原版可信;mod 方块名只有名字、没有 state id(附录 D) * 世界数据 → 连接后才有;未加载区域显式可辨 """ from __future__ import annotations import os from dataclasses import dataclass, field from pathlib import Path from typing import Any from .chunk import BlockRegistry, MIN_Y, WORLD_HEIGHT, NUM_SECTIONS from .client import Client from .modjar import ModInfo, scan_mods, summarize from .version import VersionInfo, list_versions, resolve_version from .world import World # 默认的内容根(libraries/assets 可共享,可只读) DEFAULT_CONTENT_ROOTS = ( "/home/newqqagent/mcview/store", "/home/newqqagent/plugindev/mcfold/store", ) # 内置的方块数据(minecraft-data 的 blocks.json,按 majorVersion 组织) DEFAULT_DATA_ROOT = ( "/home/newqqagent/plugin_data/mc/runtime/node_modules/" "minecraft-data/minecraft-data/data/pc" ) @dataclass class InstanceStatus: """给 agent 看的实例现状(含每一项的可信度)。""" path: str version_id: str = "" mc_version: str = "" loader: str = "" protocol: int = 0 mods_loaded: int = 0 mods_failed: int = 0 mod_blocks: int = 0 connected: bool = False loaded_chunks: int = 0 unknown_states: int = 0 notes: list[str] = field(default_factory=list) def to_dict(self) -> dict[str, Any]: return { "path": self.path, "version": { "id": self.version_id, "mc": self.mc_version, "loader": self.loader, "protocol": self.protocol, "trusted": "来自 version.json(含 inheritsFrom 归一化)", }, "mods": { "loaded": self.mods_loaded, "failed": self.mods_failed, "block_names": self.mod_blocks, "state_ids": None, "note": "只有方块名,无 state id(附录 D)", }, "world": { "connected": self.connected, "loaded_chunks": self.loaded_chunks, "unknown_states": self.unknown_states, }, "notes": self.notes, } class Instance: """一个 MC 实例。""" def __init__( self, path: str | os.PathLike[str], prefer_version: str | None = None, content_root: str | os.PathLike[str] | None = None, data_root: str | os.PathLike[str] | None = None, ): self.path = Path(path) self.prefer_version = prefer_version self.content_root = Path(content_root) if content_root else self._find_content_root() self.data_root = Path(data_root) if data_root else Path(DEFAULT_DATA_ROOT) self.version: VersionInfo | None = None self.mods: list[ModInfo] = [] self.registry: BlockRegistry | None = None self.world = World() self.client: Client | None = None self.notes: list[str] = [] # ---------- 内容根 ---------- def _find_content_root(self) -> Path | None: """找可共享的内容根(含 libraries/ 的那个)。 实例文件夹自己带 libraries/ 时优先用它(自包含安装)。 """ if (self.path / "libraries").is_dir(): return self.path for c in DEFAULT_CONTENT_ROOTS: cp = Path(c) if cp.is_dir(): # 内容根下按 loader-version 组织 for sub in cp.iterdir(): if sub.is_dir() and (sub / "libraries").is_dir(): return sub return None # ---------- 装载 ---------- def load(self) -> "Instance": """读实例文件夹:版本 + mod + 方块注册表。""" self.version = resolve_version(self.path, self.prefer_version) if self.version is None: raise FileNotFoundError( f"{self.path} 下没有可用版本(需要 versions//.json)") mods_dir = self.path / "mods" self.mods = scan_mods(mods_dir) self._load_registry() self._build_world() if self.mods: bad = [m for m in self.mods if not m.ok] self.notes.append( f"本实例含 {len(self.mods)} 个 mod" + (f"({len(bad)} 个解析失败)" if bad else "") + ";mod 方块仅有名字、无 state id," "世界查询遇到未注册 state id 会显示 unknown(id)。" ) return self def _load_registry(self) -> None: """载入方块注册表。 注意 minecraft-data 的组织方式:1.20.1 目录里只有 version.json (majorVersion=1.20),真正的数据在 1.20/ 下 —— 所以要按 majorVersion 找。 """ if self.version is None: return mc = self.version.mc_version cands = [self.data_root / mc / "blocks.json"] # 按 majorVersion 回退(1.20.1 -> 1.20) parts = mc.split(".") for take in range(len(parts) - 1, 0, -1): cands.append(self.data_root / ".".join(parts[:take]) / "blocks.json") for c in cands: if c.is_file(): self.registry = BlockRegistry.from_json_file(str(c)) return self.notes.append(f"未找到 {mc} 的方块数据 ⇒ 世界查询将只返回 state id") def _build_world(self) -> None: self.world = World(self.registry) if self.version is not None: base = self.version.raw or {} # 1.18+ 的世界范围可由 version.json 提供;缺省用 1.20 的实测值 self.world.min_y = base.get("minY", MIN_Y) # worldHeight 常见写法:height 或 直接用默认 384 self.world.num_sections = base.get("worldHeight", WORLD_HEIGHT) >> 4 # ---------- 连接 ---------- def connect(self, host: str, port: int, username: str) -> Client: """连服务端(协议版本由实例文件夹决定,不硬编码)。""" if self.version is None: self.load() assert self.version is not None self.client = Client(host, port, username, self.version.mc_version, protocol_path=self.data_root) self.client.load_protocol() return self.client # ---------- 能力查询 ---------- def mod_block_names(self) -> list[str]: """本实例 mod 提供的方块名(带命名空间)。""" out: list[str] = [] for m in self.mods: if m.ok: out.extend(m.qualified_blocks()) return sorted(set(out)) def knows_block_name(self, name: str) -> bool: """该方块名是否可用(原版注册表里有,或来自已装 mod)。 用途:在 agent 下指令前校验,避免"你说的这个方块这服没有"。 命名空间处理(实测需求):agent 常常只说 `cask` 而不是 `expandeddelight:cask`。所以无命名空间时也去 mod 清单里找: * 唯一命中 ⇒ 认识 * 多个 mod 都有同名方块 ⇒ **不**当成认识(歧义,应当让 agent 说全名) """ if self.registry: bare = name.split(":", 1)[1] if ":" in name else name if bare in self.registry.by_name: return True if ":" in name: return name in set(self.mod_block_names()) # 无命名空间:在 mod 方块名的「裸名」里查,但要歧义敏感 return len(self._mod_hits_bare(name)) == 1 def _mod_hits_bare(self, bare: str) -> list[str]: """裸名 → 命中的完整限定名列表(用于歧义判定)。""" hits: list[str] = [] for m in self.mods: if m.ok and bare in m.block_names: hits.extend(m.qualified_blocks_for(bare)) return hits def expand_block_name(self, name: str) -> str | None: """把裸名展开为完整限定名;歧义或无命中返回 None。""" if ":" in name: return name if name in set(self.mod_block_names()) else None hits = self._mod_hits_bare(name) return hits[0] if len(hits) == 1 else None def resolve_block_name(self, state_id: int) -> str: """state id → 名字,带 mod 归因(拿不到名字时明说是可能来自 mod)。""" if self.registry is None: return f"unknown({state_id})" n = self.registry.name_of(state_id) if n is not None: return n if self.mods: return (f"unknown({state_id}) —— 本机注册表无此 state id;" f"本实例有 {len([m for m in self.mods if m.ok])} 个 mod," f"极可能是 mod 方块") return f"unknown({state_id})" def _protocol_of(self, mc_version: str) -> int: """从 minecraft-data 的 version.json 读协议号(无需连接)。 注意:传入的必须是**归一化后的** mc 版本(version.py 已保证)。 加载器版本的 id(如 1.20.1-forge-47.4.0)在这里查不到, 所以调用方先用 VersionInfo.mc_version。 """ import json as _json parts = mc_version.split(".") cands = [self.data_root / mc_version / "version.json"] # 按 majorVersion 回退(1.20.1 -> 1.20) for take in range(len(parts) - 1, 0, -1): cands.append(self.data_root / ".".join(parts[:take]) / "version.json") for cand in cands: if cand.is_file(): try: return int(_json.loads(cand.read_text()).get("version", 0)) except Exception: return 0 return 0 # ---------- 摘要 ---------- def status(self) -> InstanceStatus: st = InstanceStatus(path=str(self.path)) if self.version: st.version_id = self.version.version_id st.mc_version = self.version.mc_version st.loader = self.version.loader if self.client: st.protocol = self.client.proto_num elif self.version: st.protocol = self._protocol_of(self.version.mc_version) st.mods_loaded = len([m for m in self.mods if m.ok]) st.mods_failed = len([m for m in self.mods if not m.ok]) st.mod_blocks = len(self.mod_block_names()) st.loaded_chunks = self.world.loaded_count st.unknown_states = len(self.world.unknown_state_ids) st.notes = list(self.notes) return st def mods_summary(self) -> dict[str, Any]: return summarize(self.mods) def world_summary(self) -> dict[str, Any]: return self.world.summary()