Files
agentmc/mcfold/instance.py

292 lines
11 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.

"""实例层:传入文件夹路径 → 拉起一个完整实例。
这是用户口径的落点(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()