292 lines
11 KiB
Python
292 lines
11 KiB
Python
"""实例层:传入文件夹路径 → 拉起一个完整实例。
|
||
|
||
这是用户口径的落点(2026-09-25):
|
||
"支持传入文件夹路径,按照版本文件夹内容,拉起实例,
|
||
并读取 mod jar 注册方块表。然后链接服务端,获取到数据。"
|
||
|
||
实例文件夹 == 一个完整的 MC 客户端安装(与官方启动器一致):
|
||
|
||
instances/<name>/ ← 传进来的就是它(== .minecraft)
|
||
versions/<verId>/<verId>.json ← 决定协议版本(含加载器继承)
|
||
versions/<verId>/<verId>.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/<id>/<id>.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()
|