Files
agentmc/mcfold/instances.py
JianFeeeee 51ee57e556 实例创建接口(Python 层)+ 后端透传新参数
## ① mcfold/instances.py —— Python 提供「建实例」接口

用户定向(2026-09-26):
> 这个文件夹内容也是 python 层创建的,而不是标准游戏目录。
> 即 python 提供创建实例的接口(含 mod/插件的实例,内部有对应目录,
> 插件 mod 放在目录中就会被加载)。

所以实例是**我们定义的目录**,不是一份 MC 客户端安装:

    create_instance(name, version=, loader=, mods=[], force=False)  → Path
    list_instances()                                    → [实例摘要]
    find_content_root(version, loader)                  → 共享只读那层

- 实例内部:`instance.json`(自述)+ **mods/ shaderpacks/ resourcepacks/
  datapacks/**(★ agent 的接口面,放进去即生效)+ config/ saves/ logs/
  options.txt(MC/加载器自己写的运行时产物)
- **不在实例里建 versions/libraries/assets** —— 那些属于内容根(只读共享)。
  放进实例会让「同一版本多实例」变成多份副本,也把「实例私有/版本公有」
  的边界糊掉。
- 实例名白名单 `^[A-Za-z0-9._-]{1,64}$`(它要进路径)
- 已存在时**报错而非默默复用**,并提示「换名字 = 装不同 mod 的方式」

★ 这与已有的三层口径一致(mcview/lib/versions.cjs:59/61):
  store/<loader>-<version>/ · instances/<name>/ · runtime/<name>/

## ② base.py 三个修正

**(a) cwd 缺省改为 game_dir** —— 版本隔离漏洞:
    渲染进程的 cwd 原是仓库根,而 Sodium 等会往 cwd 写 config/
    ⇒ 实测 config/sodium-mixins.properties 落进了 mcfold 仓库根。
    这些产物属于**实例**。已改为 `cwd=self.cwd or self.game_dir`。

**(b) 命名空间按 client jar 形态自动判** —— 原先硬编码 `named`:
    官方 obf jar + 加载器 ⇒ intermediary(mod 的 mixin 能解析)
    client-named.jar      ⇒ named(仅裸启动可用)
    实测:named 下 42 个 mod 的 464 处 mixin 全部找不到目标。

**(c) 新增 names_file 参数** —— 把 names.json 路径透给后端
    (B 方案的 extern 参数在 Python 侧的可入口)。
2026-09-26 10:18:26 +08:00

215 lines
7.6 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.

"""实例创建与列举 —— 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/<loader>-<version>/ 内容根 共享只读:jar / libraries / assets / .mcfold 产物
instances/<name>/ 实例 独占:mods / shaderpacks / config / saves
runtime/<name>/ 运行期 独占: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