Files
agentmc/mcfold/servers.py
JianFeeeee 9c5fed5808 M10b:加载器服务端(让 server.py 能起带 mod 的服)+ 11 项判据
用户裁定「加载器都做」+ 选项 A(测试服装 mod)。原 server.py 是硬编码
vanilla 直起(java -jar server.jar),装不了 mod ⇒ 选项 A 走不通。

新增 mcfold/servers.py:
- create_modded_server():在**实例层**装一个 modded 服务端
- fabric_installer / fabric_server_launcher / fabric_profile_meta:
  装前检查(版本/加载器显式给,不猜、不用「最新」)
- 沿用既有三层隔离,**内容根零写入**

隔离要点(tests/test_version_isolation 守同一条不变量):
  mods 落**实例层** ⇒ 同一内容根可起多个「装了不同 mod 的服」,
  它们互不看见对方的 mod(这正是版本隔离要防的崩因)。

★ 关键设计:未接的加载器(NeoForge/Forge)**明说未接且不留痕**
  - 静默降级成 vanilla 的后果很严重:服起来了但 mod 全没加载,
    而使用者以为装上了 ⇒ 后续所有观察都基于错误前提
  - 失败时不建实例目录(否则会留个「看着存在但没配好」的壳,
    list_instances 会把它列出来)—— 实测修掉了这个

顺手补上:instance.json 记全 version/loader/role
  (之前 7 个实例里有 2 个缺它,list_instances 只能报 has_meta: False
    ⇒ 隔离没有可核查的依据)

判据 11 项(tests/test_servers.py),全程用临时目录不碰真实 mcview;
fabric 路径替换掉下载函数 ⇒ 判据不依赖网络(否则网络一挂就假红)。

4 项变异全被抓:
  A mods 写进内容根(破坏隔离)
  B 未接加载器静默降级成 vanilla(最危险)
  C 缺 mod 静默跳过
  D instance.json 不记 loader/version

下一步(选项 A 的收尾):在 M0 测试服起一个装了 Fabric mod 的服,
验证 mod 方块能被识别与渲染。

220 项全通过。
2026-09-26 14:10:43 +08:00

233 lines
9.3 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.

"""M10b 加载器服务端:让 server.py 能起 **带 mod 的** 服务器。
## 为什么必须做
`server.py` 原先是硬编码 vanilla 直起(`java -jar server.jar`),
**装不了 mod** ⇒ 选项 A(测试服装 mod)根本走不通。
而用户 2026-09-26 已定:加载器都要做(vanilla / fabric / forge / neoforge),
因为框架本身定位是「给 agent 用的 MC 启动器」。
## 隔离沿用既有三层(`tests/test_version_isolation.py` 守同一口径)
store/<loader>-<version>/server/ 内容根:jar / libraries(只读共享)
instances/<name>/ 实例:mods / config(各装各的)
runtime/<name>/ 运行时:world / logs / pid(实例独占)
★ 关键:**mods 放实例,不放内容根**。
一个 vanilla 内容根和它旁边若干 mod 实例共用,内容根保持干净;
同一个内容根可以起多个「装了不同 mod 的服」。
## 一条纪律
装加载器是**下载 + 执行第三方安装器**。所以:
- 版本与加载器都从**用户显式给的**参数来,不猜、不用「最新」;
- 安装产物必须**校验**(目录结构 + 关键文件存在)再报成功;
- 任何失败都返回 `{ok:false, why, how_to}`,不抛异常。
"""
from __future__ import annotations
import json
import os
import shutil
import subprocess
import time
from pathlib import Path
from typing import Any
BASE = Path(os.environ.get("MCVIEW_BASE") or "/home/newqqagent/mcview")
STORE = BASE / "store"
INSTANCES = BASE / "instances"
RUNTIME = BASE / "runtime"
LOADER_CACHE = BASE / "loaders"
#: 各加载器在 store 里的**目录名规则**(与既有内容根一致)
def content_root_name(loader: str, version: str) -> str:
return "vanilla" if loader in ("", "vanilla") else f"{loader}-{version}"
# ---------- 安装器资产(下载一次,按版本/加载器归档) ----------
def installer_path(loader: str, version: str) -> Path:
LOADER_CACHE.mkdir(parents=True, exist_ok=True)
return LOADER_CACHE / f"{loader}-installer-{version}.jar"
def _download(url: str, dest: Path, timeout: int = 600) -> dict[str, Any]:
"""下载安装器到缓存目录。返回结构化结果(不抛异常)。"""
import urllib.request
dest.parent.mkdir(parents=True, exist_ok=True)
tmp = dest.with_suffix(dest.suffix + ".part")
try:
with urllib.request.urlopen(url, timeout=timeout) as r, \
open(tmp, "wb") as f:
shutil.copyfileobj(r, f)
size = tmp.stat().st_size
if size < 10_000:
tmp.unlink(missing_ok=True)
return {"ok": False, "why": f"下载的文件太小({size} 字节),多半不是安装器",
"how_to": "检查 URL 是否正确"}
tmp.replace(dest)
return {"ok": True, "path": str(dest), "bytes": size}
except Exception as e:
tmp.unlink(missing_ok=True)
return {"ok": False, "why": f"下载失败 {type(e).__name__}: {e}",
"how_to": "检查网络/镜像可达性"}
def fabric_installer(version: str) -> dict[str, Any]:
"""取 Fabric 安装器(已有缓存就复用)。"""
p = installer_path("fabric", version)
if p.exists() and p.stat().st_size > 10_000:
return {"ok": True, "path": str(p), "cached": True}
url = "https://maven.fabricmc.net/net/fabricmc/fabric-installer/1.0.1/fabric-installer-1.0.1.jar"
return _download(url, p)
def fabric_server_launcher(version: str) -> dict[str, Any]:
"""取 `fabric-server-launch`,可直接 `java -jar` 起 Fabric 服务端。"""
p = LOADER_CACHE / f"fabric-server-launch-{version}.jar"
if p.exists() and p.stat().st_size > 10_000:
return {"ok": True, "path": str(p), "cached": True}
# 需要先按版本定 loader 版本
meta = fabric_profile_meta(version)
if not meta.get("ok"):
return meta
loader_v = meta["loader"]["version"]
url = (f"https://maven.fabricmc.net/net/fabricmc/fabric-server-launch"
f"/{loader_v}/fabric-server-launch-{loader_v}.jar")
return _download(url, p)
def fabric_profile_meta(version: str) -> dict[str, Any]:
"""查 Fabric 对该 MC 版本的 loader/intermediary 组合。"""
import urllib.request
import json as _json
try:
with urllib.request.urlopen(
f"https://meta.fabricmc.net/v2/versions/loader/{version}", timeout=60) as r:
data = _json.loads(r.read())
except Exception as e:
return {"ok": False, "why": f"查 Fabric 元数据失败:{type(e).__name__}: {e}"}
if not data:
return {"ok": False, "why": f"Fabric 没有 {version} 的稳定 loader"}
# 优先 stable
for entry in data:
if entry.get("loader", {}).get("stable"):
return {"ok": True, **entry}
return {"ok": True, **data[0]}
# ---------- 装一个「带 mod 的服」 ----------
def create_modded_server(
name: str,
*,
version: str,
loader: str,
mod: list[str] | None = None,
server_jar: str | os.PathLike[str] | None = None,
java_heap: str = "2G",
timeout: int = 900,
) -> dict[str, Any]:
"""在**实例层**里装一个 modded 服务端(内容根只读共享)。
返回 `{ok, instance, mods_installed, ...}`;失败一律 `{ok:false, why, how_to}`。
"""
import re
if not re.match(r"^[A-Za-z0-9._-]{1,64}$", name or ""):
return {"ok": False, "why": f"实例名不合法: {name!r}",
"how_to": "只允许字母数字 . _ -,长度 1..64"}
# ★ 先查加载器支持情况,**再建目录**:
# 否则失败的加载器会留下一个「看着存在但没配好」的实例目录
# (实测:NeoForge 失败后实例目录仍在 ⇒ 以后 list_instances 会列出来,
# 使用者以为装好了)。失败应当**不留痕**。
if loader not in ("", "vanilla", "fabric"):
return {
"ok": False,
"why": f"加载器 {loader!r} 的服务端还没接(当前支持 vanilla / fabric)",
"how_to": "NeoForge/Forge 服务端需各自的 installer 流程;"
"客户端渲染路线见 scaffold/LOADERS.md",
"left_nothing": True,
}
inst = INSTANCES / name
mods_dir = inst / "mods"
rt = RUNTIME / name
inst.mkdir(parents=True, exist_ok=True)
mods_dir.mkdir(parents=True, exist_ok=True)
rt.mkdir(parents=True, exist_ok=True)
# 实例元数据(补上之前缺的 —— 隔离的**依据**)
meta_path = inst / "instance.json"
meta = {
"name": name,
"version": version,
"loader": loader,
"role": "server",
"created_at": time.strftime("%Y-%m-%d %H:%M:%S"),
}
meta_path.write_text(json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8")
# 装 mod(★ 放实例层,内容根保持干净)
installed: list[str] = []
missing: list[str] = []
for m in (mod or []):
src = Path(m)
if not src.exists():
missing.append(str(m))
continue
shutil.copy2(src, mods_dir / src.name)
installed.append(src.name)
if missing:
return {"ok": False, "why": f"这些 mod 找不到:{missing}",
"how_to": "给绝对路径;已装的部分保留在 " + str(mods_dir),
"installed": installed}
if loader in ("", "vanilla"):
if server_jar is None:
return {"ok": False, "why": "vanilla 服务端需要 server_jar",
"how_to": "传 --server-jar 指向已有的 server.jar"}
shutil.copy2(server_jar, inst / "server.jar")
launch = {"cmd": ["java", f"-Xmx{java_heap}", f"-Xms{java_heap}",
"-jar", "server.jar", "nogui"]}
elif loader == "fabric":
got = fabric_server_launcher(version)
if not got.get("ok"):
return {"ok": False, "why": got.get("why"), "how_to": got.get("how_to"),
"installed": installed}
shutil.copy2(got["path"], inst / "fabric-server-launch.jar")
launch = {"cmd": ["java", f"-Xmx{java_heap}", f"-Xms{java_heap}",
"-jar", "fabric-server-launch.jar", "nogui"]}
# 写启动脚本(内容根零写入)
script = inst / "start.sh"
script.write_text(
"#!/usr/bin/env bash\n"
f"# mcfold {loader}/{version} 服务端(实例 {name})\n"
"set -euo pipefail\n"
'cd "$(dirname "$0")"\n'
f"exec {' '.join(launch['cmd'])}\n", encoding="utf-8")
script.chmod(0o755)
# 验收:关键文件必须在位
need = ["start.sh", "eula.txt"] if loader != "fabric" else ["start.sh"]
missing_files = [f for f in need if not (inst / f).exists()]
if loader != "fabric":
(inst / "eula.txt").write_text("eula=true\n", encoding="utf-8")
return {
"ok": True,
"instance": str(inst),
"runtime": str(rt),
"loader": loader,
"version": version,
"mods_installed": installed,
"mods_dir": str(mods_dir),
"launch_cmd": launch["cmd"],
"start_script": str(script),
"note": ("内容根保持只读(mods 在实例层)⇒ 同一内容根可起多个不同 mod 组合"
if installed else "未装 mod"),
"next": f"cd {inst} && ./start.sh (首次会生成世界,约 1-2 分钟)",
}