"""实例创建与列举 —— Python 层给 agent 的「建实例」接口。 ## 归属(用户 2026-09-26 定向,重要) > 这个文件夹内容也是 python 层创建的,而不是标准游戏目录。 > 即 python 提供创建实例的接口(含 mod/插件的实例,内部有对应目录, > 插件 mod 放在目录中就会被加载)。 所以: - **实例是我们定义的目录**,不是「一份 MC 客户端安装」。 MC 需要的 `versions/` `libraries/` `assets/` 都在**内容根**(只读共享), 实例里只放**这个实例独占的东西**。 - **谁来建**:Python(`create_instance`)。agent 调 Python 拿实例, 把 mod 丢进 `mods/` 就会被加载 —— 不需要懂 MC 的目录规范。 - **同一版本可有多个实例**(装不同 mod)⇒ 实例与内容根**同级**,绝不嵌套。 ## 三层(权威口径见 mcview/lib/versions.cjs:59/61) BASE/ store/-/ 内容根 共享只读:jar / libraries / assets / .mcfold 产物 instances// 实例 独占:mods / shaderpacks / config / saves runtime// 运行期 独占:X display / screenshots / logs ## 实例内部(我们定义的约定) <实例>/ instance.json 本实例自述(版本 / 世代 / 加载器 / mod 清单 / 创建时间) mods/ ★ 放进去就会被加载(.jar) shaderpacks/ ★ 光影包(Iris 等) resourcepacks/ ★ 资源包 datapacks/ ★ 数据包 config/ 加载器/mod 的配置产出 saves/ 世界存档 options.txt 游戏选项 logs/ 运行日志 ★ 前四项是**我们给 agent 的接口面**(放进去即生效); 其余是 MC/加载器自己会写的运行时产物。 """ from __future__ import annotations import json import os import re import shutil import time from pathlib import Path from typing import Any # 实例名白名单:它要进路径,不能含分隔符或空白 NAME_RE = re.compile(r"^[A-Za-z0-9._-]{1,64}$") # 实例内部布局(★ 前四项是 agent 的接口面) INSTANCE_DIRS = ( "mods", # ★ "shaderpacks", # ★ "resourcepacks", # ★ "datapacks", # ★ "config", "saves", "logs", ) # 内容根候选(与 instance.py 的 DEFAULT_CONTENT_ROOTS 一致) _CONTENT_ROOTS = ( "/home/newqqagent/mcview/store", "/home/newqqagent/plugindev/mcfold/store", ) def instance_root(root: str | os.PathLike[str] | None = None) -> Path: """实例层的根。缺省与内容根**同级**(绝不嵌套进内容根)。""" if root: return Path(root) base = os.environ.get("MCVIEW_BASE") if base: return Path(base) / "instances" return Path(_CONTENT_ROOTS[0]).parent / "instances" def find_content_root( version: str | None = None, loader: str = "vanilla", roots: tuple[str, ...] = _CONTENT_ROOTS, ) -> Path | None: """按 (loader, version) 定位内容根(共享只读的那一层)。""" for r in roots: rp = Path(r) if not rp.is_dir(): continue if version: for cand in (rp / f"{loader}-{version}", rp / f"vanilla-{version}"): if cand.is_dir(): return cand subs = sorted(p for p in rp.iterdir() if p.is_dir() and (p / "libraries").is_dir()) if subs: return subs[0] return None def create_instance( name: str, *, version: str | None = None, loader: str = "vanilla", content_root: str | os.PathLike[str] | None = None, root: str | os.PathLike[str] | None = None, mods: list[str] | None = None, force: bool = False, ) -> Path: """创建一个实例,返回实例目录。**这是 Python 层给 agent 的接口。** 参数 ---- name 实例名(`^[A-Za-z0-9._-]{1,64}$`) version MC 版本,如 "1.20.1";缺省从内容根名推断 loader vanilla / fabric / forge / neoforge content_root 内容根;缺省按 (loader, version) 在 store 下找 root 实例层根;缺省 `instance_root()` mods 要装进实例的 mod jar 路径(**放进去就会被加载**) force 已存在时是否覆盖(缺省报错,不默默复用) ★ 这里**不**在实例里创建 versions/libraries/assets: 那些属于内容根(只读共享)。放进实例会让「同一版本多实例」 变成多份副本,也把「实例私有 / 版本公有」的边界糊掉。 """ if not NAME_RE.match(name): raise ValueError( f"实例名不合法: {name!r}(只允许字母数字 . _ -,长度 1..64;" f"它要进路径,不能含分隔符或空白)") inst_root = instance_root(root) path = inst_root / name if path.exists() and not force: raise FileExistsError( f"实例已存在: {path}\n" f" · 要覆盖请传 force=True\n" f" · 或换个名字 —— 同一个版本可以有多个实例," f" 这正是「装不同 mod」的方式") cr = Path(content_root) if content_root else find_content_root(version, loader) if cr is not None and not cr.is_dir(): raise FileNotFoundError(f"内容根不存在: {cr}") ver = version or _version_of(cr) path.mkdir(parents=True, exist_ok=True) for d in INSTANCE_DIRS: (path / d).mkdir(exist_ok=True) copied: list[str] = [] if mods: for m in mods: src = Path(m) if not src.is_file(): raise FileNotFoundError(f"mod 不存在: {src}") dst = path / "mods" / src.name if src.resolve() != dst.resolve(): shutil.copy2(src, dst) copied.append(src.name) meta = { "name": name, "version": ver, "loader": loader, "content_root": str(cr) if cr else None, "created_at": time.strftime("%Y-%m-%d %H:%M:%S"), "mods": copied, "layout": "mcfold-instance/1", "note": "mods/ 与 shaderpacks/ 等目录放进去就会被加载;" "versions/libraries/assets 在内容根(只读共享)", } (path / "instance.json").write_text( json.dumps(meta, indent=2, ensure_ascii=False) + "\n") (path / "options.txt").touch(exist_ok=True) return path def _version_of(content_root: Path | None) -> str | None: """store/vanilla-1.20.1 → "1.20.1" """ if not content_root: return None n = content_root.name return n.split("-", 1)[1] if "-" in n else None def list_instances(root: str | os.PathLike[str] | None = None) -> list[dict[str, Any]]: """列出实例(给 agent 看:每个实例什么版本、装了哪些 mod)。""" r = instance_root(root) out: list[dict[str, Any]] = [] if not r.is_dir(): return out for p in sorted(r.iterdir()): if not p.is_dir(): continue meta: dict[str, Any] = {} mf = p / "instance.json" if mf.is_file(): try: meta = json.loads(mf.read_text()) except Exception: meta = {} mods_dir = p / "mods" out.append({ "name": p.name, "path": str(p), "version": meta.get("version"), "loader": meta.get("loader"), "mods": sorted(f.name for f in mods_dir.glob("*.jar")) if mods_dir.is_dir() else [], "has_meta": bool(meta), }) return out