"""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/-/server/ 内容根:jar / libraries(只读共享) instances// 实例:mods / config(各装各的) runtime// 运行时: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_libraries(version: str, loader_version: str = "") -> dict[str, Any]: """取 Fabric 服务端要的库(loader / intermediary / asm / sponge-mixin)。 ★ **优先复用本地资产**:客户端路线(M8 之前)已经把同一批库装进 `store/-/.mcfold/fabric/`,那正是「内容根共享、只读」 的用途所在。重新下载既慢又会多一份副本。 ⇒ 这也是「同一版本多实例」在资产层面的体现:库只存一份,实例各自持有引用。 """ out: list[str] = [] missing: list[str] = [] shared = STORE / f"fabric-{version}" / ".mcfold" / "fabric" if not shared.exists(): # 退化:vanilla- 内容根里也放过同一批(历史上这么建的) shared = STORE / f"vanilla-{version}" / ".mcfold" / "fabric" if shared.is_dir(): out += [str(p) for p in sorted(shared.glob("*.jar"))] if out: return {"ok": True, "jars": out, "source": str(shared), "cached": True} return {"ok": False, "why": f"本地没有 {version} 的 Fabric 库(找过 {shared})", "how_to": "用 fabric_installer() 下载安装器后安装,或先把客户端路线的" "fabric 库准备好", "missing": missing} def fabric_server_launcher(version: str) -> dict[str, Any]: """取可起 Fabric 服务端的启动件。 ★ 实测:`fabric-server-launch` 在 maven 上**不存在**(404)—— Fabric 服务端的正确起法是把 loader + intermediary + asm + mixin 放上 classpath,然后跑官方 `net.fabricmc.loader.impl.launch.knot.KnotServer`。 我先前按「有个 server-launch jar」的假设拼 URL,拿到的是 404。 """ libs = fabric_libraries(version) if not libs.get("ok"): return libs 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} return {"ok": False, "why": "没有 fabric-server-launch 这种产物(实测 404)", "how_to": "改用 classpath 方式:loader+intermediary+asm+mixin 走 " "KnotServer(见 fabric_classpath_launch)", "classpath": libs["jars"]} def fabric_classpath_launch(version: str) -> dict[str, Any]: """Fabric 服务端的正确启动方式:classpath + 官方 KnotServer 主类。""" libs = fabric_libraries(version) if not libs.get("ok"): return libs cp = ":".join(libs["jars"]) return { "ok": True, "classpath": cp, "main_class": "net.fabricmc.loader.impl.launch.knot.KnotServer", "n_jars": len(libs["jars"]), "source": libs.get("source"), "how_to": "java -cp <上面那串> net.fabricmc.loader.impl.launch.knot.KnotServer", } 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]} # ---------- NeoForge(生产服用的那条路) ---------- #: MC 版本 → NeoForge 版本前缀。1.21.1 → "21.1.",1.20.1 → "20.1." _NEOFORGE_PREFIX = { "1.21.1": "21.1.", "1.21": "21.1.", "1.20.1": "20.1.", "1.20.4": "20.4.", "1.20.6": "20.6.", "1.20.2": "20.2.", "1.20.3": "20.3.", "1.19.4": "19.4.", } def neoforge_versions(mc_version: str) -> dict[str, Any]: """查某 MC 版本可用的 NeoForge 版本列表(从官方 maven 读)。""" import urllib.request import json as _json url = "https://maven.neoforged.net/api/maven/versions/releases/net/neoforged/neoforge" try: with urllib.request.urlopen(url, timeout=60) as r: all_v = _json.loads(r.read())["versions"] except Exception as e: return {"ok": False, "why": f"查 NeoForge 版本失败:{type(e).__name__}: {e}", "how_to": "检查能否访问 maven.neoforged.net"} pre = _NEOFORGE_PREFIX.get(mc_version) if pre is None: return {"ok": False, "why": f"不知道 {mc_version} 对应的 NeoForge 版本前缀", "how_to": f"在 _NEOFORGE_PREFIX 里补一条,或直接给 neoforge_version", "supported": sorted(_NEOFORGE_PREFIX)} vs = [v for v in all_v if v.startswith(pre)] if not vs: return {"ok": False, "why": f"NeoForge 没有匹配 {mc_version} 的版本(前缀 {pre})"} return {"ok": True, "versions": vs, "prefix": pre} def neoforge_installer(mc_version: str, neoforge_version: str = "", latest: bool = True) -> dict[str, Any]: """取 NeoForge installer(显式给版本,或取该 MC 版本的最新版)。""" if not neoforge_version: if not latest: return {"ok": False, "why": "需要 neoforge_version(或 latest=True)", "how_to": f"可选:{neoforge_versions(mc_version).get('versions', [])[:5]}"} got = neoforge_versions(mc_version) if not got.get("ok"): return got neoforge_version = got["versions"][0] p = LOADER_CACHE / f"neoforge-installer-{neoforge_version}.jar" if p.exists() and p.stat().st_size > 100_000: return {"ok": True, "path": str(p), "cached": True, "version": neoforge_version} url = (f"https://maven.neoforged.net/releases/net/neoforged/neoforge/" f"{neoforge_version}/neoforge-{neoforge_version}-installer.jar") r = _download(url, p, timeout=900) if r.get("ok"): r["version"] = neoforge_version return r def run_installer(installer: str, cwd: str | os.PathLike[str], *, kind: str, timeout: int = 1800) -> dict[str, Any]: """跑加载器安装器(NeoForge/Fabric 都用这个形态)。 ★ 安装器是**执行第三方 jar** ⇒ 失败必须把 stderr 尾部带回来 (否则只看到「退出码 1」,无法判断是网络、Java 版本还是 mod 冲突)。 """ cwd = Path(cwd) cmd = ["java", "-jar", str(installer)] if kind == "neoforge": cmd.append("--installServer") elif kind == "fabric": cmd.append("server") try: p = subprocess.run(cmd, cwd=str(cwd), capture_output=True, text=True, timeout=timeout) except subprocess.TimeoutExpired: return {"ok": False, "why": f"安装器超时(>{timeout}s)", "how_to": "网络慢或 mod 冲突;可加大 timeout 重试"} except Exception as e: return {"ok": False, "why": f"跑安装器失败 {type(e).__name__}: {e}"} if p.returncode != 0: tail = ((p.stderr or "") + (p.stdout or ""))[-800:] return {"ok": False, "why": f"安装器退出码 {p.returncode}", "how_to": "看下面的输出判断(网络/Java 版本/mod 冲突)", "output_tail": tail} return {"ok": True, "returncode": 0} def neoforge_run_cmd(nf_version: str) -> list[str]: """NeoForge 服务端的启动命令(官方 userdev/jars 形态)。""" return ["java", "@userdev/java.args", "nogui"] # ---------- 装一个「带 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", neoforge_version: str = "", install: bool = False, timeout: int = 900, ) -> dict[str, Any]: """在**实例层**里装一个 modded 服务端(内容根只读共享)。 参数 ---- loader vanilla / fabric / neoforge neoforge_version 显式给 NeoForge 版本;缺省取该 MC 版本的最新 install NeoForge 必须为 True(要跑官方 installer 生成服务端) 返回 `{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", "neoforge"): return { "ok": False, "why": f"加载器 {loader!r} 的服务端还没接" f"(当前支持 vanilla / fabric / neoforge)", "how_to": "Forge/Quilt 需各自的 installer 流程;" "客户端渲染路线见 scaffold/LOADERS.md", "left_nothing": True, } # ★ NeoForge 缺 install=True 要在**建目录之前**就拒: # 否则会留下一个「看着存在但没配好」的实例目录 # (list_instances 会把它列出来,使用者以为装好了)。 if loader == "neoforge" and not install: return { "ok": False, "why": "NeoForge 需要先跑官方 installer(--installServer)" "才能得到服务端运行时", "how_to": "传 install=True;首次约 1-3 分钟(要下依赖)", "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_classpath_launch(version) if not got.get("ok"): return {"ok": False, "why": got.get("why"), "how_to": got.get("how_to"), "installed": installed} # 把库复制进实例(实例独占,不依赖内容根的相对路径) libs_dir = inst / "fabric-libs" libs_dir.mkdir(exist_ok=True) copied = [] for j in got["classpath"].split(":"): if Path(j).exists(): shutil.copy2(j, libs_dir / Path(j).name) copied.append(Path(j).name) cp = ":".join(f"fabric-libs/{n}" for n in copied) launch = {"cmd": ["java", f"-Xmx{java_heap}", f"-Xms{java_heap}", "-cp", cp, "net.fabricmc.loader.impl.launch.knot.KnotServer"]} elif loader == "neoforge": got = neoforge_installer(version, neoforge_version) if not got.get("ok"): return {"ok": False, "why": got.get("why"), "how_to": got.get("how_to"), "installed": installed} run = run_installer(got["path"], inst, kind="neoforge", timeout=1800) if not run.get("ok"): return {"ok": False, "why": run.get("why"), "how_to": run.get("how_to"), "output_tail": run.get("output_tail"), "installed": installed} args = inst / "userdev" / "java.args" if not args.exists(): return {"ok": False, "why": f"installer 跑完但没产出 {args}", "how_to": "检查 neoforge 版本是否支持该 MC 版本;或改用 fabric", "installed": installed, "neoforge_version": got.get("version")} launch = {"cmd": ["java", f"-Xmx{java_heap}", f"-Xms{java_heap}", "@userdev/java.args", "nogui"]} meta["neoforge_version"] = got.get("version") (inst / "instance.json").write_text( json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8") return { "ok": True, "instance": str(inst), "runtime": str(rt), "loader": "neoforge", "version": version, "neoforge_version": got.get("version"), "mods_installed": installed, "mods_dir": str(mods_dir), "launch_cmd": launch["cmd"], "next": (f"cd {inst} && java {launch['cmd'][1]} " f"(mod 已就位,NeoForge 启动时加载)"), "note": "NeoForge 服务端由官方 installer 生成;mods 在实例层", } # 写启动脚本(内容根零写入) 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 分钟)", }