From 554ebc239a5c6319127f79a2aef59e8487076626 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Sat, 26 Sep 2026 14:29:58 +0800 Subject: [PATCH] =?UTF-8?q?M10c=EF=BC=9Amod=20=E7=8E=AF=E5=A2=83=E4=B8=8B?= =?UTF-8?q?=E5=AF=BB=E8=B7=AF=20=E2=80=94=E2=80=94=20=E9=97=AE=E8=BF=90?= =?UTF-8?q?=E8=A1=8C=E6=97=B6=E8=80=8C=E9=9D=9E=E7=8C=9C=E5=90=8D=E5=AD=97?= =?UTF-8?q?=EF=BC=88+=20=E8=AF=AD=E4=B9=89=E8=A1=A8=E4=B8=8E=E8=A6=86?= =?UTF-8?q?=E7=9B=96=E7=8E=87=E6=8A=A5=E5=91=8A=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户要求:Create 与 Aeronautics 会引入大量原版没有的方块与危险区, 寻路必须一起优化。用户裁定:危险区用「表 + 命名推测并标记」, 动态机械用「警告 + 提高代价」。 ## ★ 关键实测结论(本轮最重要) 我先按「给 mod 方块建语义表」做,实测覆盖率: Create 643 个方块 → 显式表命中 9、命名规则推测 21、完全不识 613 覆盖率 4.7% ⇒ **手写表是死路**(64 mod / 1465 方块永远追不上)。 ⇒ 但寻路真正需要的属性,**运行时全都有**: Java 侧 blockinfo 已在读 BuiltInRegistries.BLOCK(**含 mod 运行时注册的方块**), 并返回 isSolid / destroySpeed / lightEmission / isAir / hasBlockEntity。 ⇒ 正确做法是**问**,不是猜。 ## 改动 新增 mcfold/solid.py: - SolidSource 抽象:RegistrySolidSource(本机,快但无 mod) vs RuntimeSolidSource(问 JVM,**含 mod**,准但要 JVM) - 按 **state_id** 缓存(属性只依赖 state,不依赖坐标) - attach_runtime() 批量探测(一次问一格要一次往返,批量才可用) mcfold/pathfind.py:Terrain 改为「**先运行时缓存,再本机注册表**」; can_dig 用 destroySpeed 判可挖(基岩官方约定是 -1 = 不可破坏), 不再靠名字猜「基岩挖不动」 新增 mcfold/modblocks.py(危险/会动这部分的表,配套覆盖率报告): - 语义取���:ground/passable/hazard/breakable/solid/dynamic/carrier/unknown - 三级来源:显式表 → 命名规则(**标记 inferred**)→ **如实说不认识** - ★ vanilla 与 mod **分开算覆盖率**:vanilla 语义由注册表给, 把它算成「未知」会让人误以为「连石头都认不出」 WorldState:加 solid_cache 字段 ## 判据 test_pathfind 19 → 24 项,新增 5 项: - mod 方块优先用运行时 isSolid(本机注册表确实认不出) - 不可破坏吃 destroySpeed(含基岩 -1 约定) - 两边都无数据 ⇒ 不知道(**不谎称可走**) - 覆盖率报告如实(且区分 mod/vanilla) - 语义查询永远返回带 inferred 标记的结构 ★ 写判据时踩了两个坑(都是 fixture 形状错,不是代码错): 1. `Chunk.sections[i]` 对应 y = MIN_Y + 16*i,MIN_Y=-64 ⇒ 我按「第 0 个 section = y=0」写,查 y=0 拿到的是空气 2. 同一 section 内 16 格**只能一个值** ⇒ mod 方块与空气落同一 section 时, 空气格会变成 mod 方块(看着像代码 bug,其实是 fixture 形状问题) ⇒ 教训:**判据的 fixture 形状必须与被测代码的真实输入结构一致**。 226 项全通过。 --- mcfold/modblocks.py | 227 +++++++++++++++++++++++++++++++++++++++++ mcfold/pathfind.py | 40 +++++++- mcfold/servers.py | 225 +++++++++++++++++++++++++++++++++++++--- mcfold/solid.py | 210 ++++++++++++++++++++++++++++++++++++++ mcfold/state.py | 6 ++ tests/test_pathfind.py | 145 +++++++++++++++++++++++++- tests/test_servers.py | 40 ++++++-- 7 files changed, 862 insertions(+), 31 deletions(-) create mode 100644 mcfold/modblocks.py create mode 100644 mcfold/solid.py diff --git a/mcfold/modblocks.py b/mcfold/modblocks.py new file mode 100644 index 0000000..ef48d7b --- /dev/null +++ b/mcfold/modblocks.py @@ -0,0 +1,227 @@ +"""mod 方块的**语义表**:让寻路/感知认识 vanilla 之外的世界。 + +## 为什么需要它 + +Create 这样的 mod 引入了三类格子寻路没见过的方块(实测分类 643 个方块): + +| 类别 | Create 的例子 | 对寻路意味着什么 | +|---|---|---| +| **可站** | `create:track`、`create:fake_track` | 地面(`boundingBox` 多半是 block,但要确认) | +| **可拆** | `create:*_box`(32)、`cogwheel`(6)、`shaft`(6) | 障碍,但**能挖**(不是天然岩壁) | +| **会伤** | `create:blaze_burner`、`lit_blaze_burner` | 致命区,必须**绕开**,不能当空地 | + +而 `blocks.json` 里**没有这些**(vanilla 只有 1060 个方块), +`modjar` 只能给出**名字**(从 blockstates/ 提取),给不出**语义**。 + +⇒ 所以需要一张「名字 → 语义」的表。三个来源,按可信度排序: + +1. **显式表**(本文件 + 用户可扩展)—— 最可信 +2. **命名规则兜底**(`*burner*` 里有 blaze ⇒ 危险)—— 会猜 +3. **都查不到** → **如实说「不认识」**,不猜 + +## 关键纪律:推测必须标记 + +规则匹配出来的语义是**推测**(`inferred: True`)。使用者必须能看到它, +因为「以为知道」与「知道」在这个场景里差别很大: +按推测的语义走过去,可能正好走进燃烧室。 + +⇒ `semantics_of()` 永远返回 `(语义, 是否推测, 依据)`,不返回裸字符串。 +""" +from __future__ import annotations + +import fnmatch +from dataclasses import dataclass +from typing import Any + + +# ---------- 语义取值(与 pathfind 的用法对齐) ---------- + +#: 可站立(能当地面) +GROUND = "ground" +#: 可穿过(身体能过) +PASSABLE = "passable" +#: 致命/有害 ⇒ 既不能当地面也不能穿过 +HAZARD = "hazard" +#: 障碍但**可挖** +BREAKABLE = "breakable" +#: 天然障碍(挖不动,如基岩) +SOLID = "solid" +#: 会动的机械(路径经过时要警告) +DYNAMIC = "dynamic" +#: 移动载体(能载人,不当普通地面) +CARRIER = "carrier" +#: 完全不认识 +UNKNOWN = "unknown" + + +@dataclass(frozen=True) +class Semantics: + """一个方块的寻路语义。`inferred=True` 表示这是**推测**。""" + name: str + kind: str + inferred: bool = False + reason: str = "" + + def as_dict(self) -> dict[str, Any]: + return {"name": self.name, "kind": self.kind, + "inferred": self.inferred, "reason": self.reason} + + +# ---------- 显式表(可由用户扩展/覆盖) ---------- + +#: 精确匹配表:方块名(不带命名空间)→ 语义。 +#: 键用不带命名空间的名字,因为不同 mod 常用同名不同空间的方块。 +EXPLICIT: dict[str, str] = { + # --- Create:可站 --- + "track": GROUND, + "fake_track": GROUND, + "gantry_shaft": GROUND, + # --- Create:会伤 --- + "blaze_burner": HAZARD, + "lit_blaze_burner": HAZARD, + "deployer": DYNAMIC, + "hand_roll": GROUND, + "sand_basin": GROUND, + "cannon": DYNAMIC, + # --- Create:可拆的机械结构 --- + "box": BREAKABLE, + "andesite_belt_funnel": BREAKABLE, + "brass_belt_funnel": BREAKABLE, + "belt_funnel": BREAKABLE, + "gantry_carriage": CARRIER, + "contraption_carriage": CARRIER, + "carriage_wheel": BREAKABLE, +} + +#: 通配表:`*burner*` → 语义。用于兜住同类的新方块。 +PATTERNS: list[tuple[str, str]] = [ + ("*blaze_burner*", HAZARD), + ("*burner*", HAZARD), + ("*_saw*", DYNAMIC), + ("*_drill*", DYNAMIC), + ("*_cannon*", DYNAMIC), + ("*cannon*", DYNAMIC), + ("*flame_burner*", HAZARD), + ("*lava*", HAZARD), + ("*_track*", GROUND), + ("*_carriage*", CARRIER), + ("*_box*", BREAKABLE), + ("*cogwheel*", BREAKABLE), + ("*_shaft*", BREAKABLE), + ("*_bearing*", BREAKABLE), + ("*_gear*", BREAKABLE), + ("*_piston*", DYNAMIC), +] + + +#: vanilla 命名空间 —— 这些方块的语义由**注册表**给出(见 mcfold.solid), +#: 不走本表。本表只管「注册表里没有的」= mod 方块。 +VANILLA_NS = "minecraft" + + +def semantics_of(block_name: str | None, + table: dict[str, str] | None = None, + patterns: list[tuple[str, str]] | None = None, + *, in_registry=None) -> Semantics: + """方块名 → 语义。**永不返回裸字符串**,推测会标记。 + + 参数 + ---- + block_name 带或不带命名空间都行(`create:track` 与 `track` 等价) + table 覆盖显式表(可由用户/配置文件扩展) + patterns 覆盖通配表 + in_registry 可调用:给 state 判定是否「注册表里有」(有则语义交给注册表) + + ★ 为什么加 `in_registry`:vanilla 方块(`minecraft:stone`)的 + solid/硬度**注册表里就有**(mcfold.solid),不该被算成「未知」—— + 早先我的覆盖率报告把 `minecraft:stone` 记成 unknown, + 会让人误以为「连石头都认不出」。这两类「不知道」含义不同: + · 注册表里没有 ⇒ 需要 mod 语义表 + · 注册表有、但语义(危险/会动)没有 ⇒ 需要外部标注 + """ + if not block_name: + return Semantics("", UNKNOWN, False, "没有方块名") + ns = block_name.split(":")[0] if ":" in block_name else VANILLA_NS + bare = block_name.split(":")[-1] if ":" in block_name else block_name + t = EXPLICIT if table is None else {**EXPLICIT, **table} + pats = PATTERNS if patterns is None else patterns + + # ① vanilla:语义来自注册表,不查本表 + if ns == VANILLA_NS and bare not in t: + return Semantics(block_name, UNKNOWN, False, + "vanilla 方块:语义由注册表给出,不走 mod 语义表") + + # ① 显式表 —— 可信 + if bare in t: + return Semantics(block_name, t[bare], False, "显式表") + # ② 通配规则 —— **推测** + for pat, kind in pats: + if fnmatch.fnmatch(bare, pat): + return Semantics(block_name, kind, True, f"命名规则 {pat}") + # ③ 不认识 —— **如实说** + return Semantics(block_name, UNKNOWN, False, "表与规则都没命中") + + +# ---------- 汇总:把一批方块分类(诊断用) ---------- + +def classify_all(names: list[str], + table: dict[str, str] | None = None, + patterns: list[tuple[str, str]] | None = None, + ) -> dict[str, list[str]]: + """把方块名列表按语义分组(用于「这个 mod 引入了什么」这类问题)。""" + out: dict[str, list[str]] = {} + for n in names: + s = semantics_of(n, table, patterns) + out.setdefault(s.kind, []).append(n) + return {k: sorted(v) for k, v in sorted( + out.items(), key=lambda kv: -len(kv[1]))} + + +def coverage_report(names: list[str], + table: dict[str, str] | None = None, + patterns: list[tuple[str, str]] | None = None, + ) -> dict[str, Any]: + """覆盖率报告:多少认出了、多少是推测、多少完全不认识。 + + ★ 这个报告的价值:它让「工具包在 mod 环境下的能力边界」变成**可读的**, + 而不是让人以为「认不出 = 方块不存在」。 + """ + total = len(names) + # ★ vanilla 与 mod 要**分开算**:vanilla 的语义由注册表给(mcfold.solid), + # 把它算成「未知」会让人误以为「连石头都认不出」—— 那是两码事。 + mod_names = [n for n in names + if (n.split(":")[0] if ":" in n else VANILLA_NS) != VANILLA_NS] + vanilla_names = [n for n in names if n not in mod_names] + explicit, inferred, unknown = [], [], [] + for n in mod_names: + s = semantics_of(n, table, patterns) + if s.kind == UNKNOWN: + unknown.append(n) + elif s.inferred: + inferred.append(n) + else: + explicit.append(n) + m = len(mod_names) or 1 + return { + "total": total, + "mod_total": len(mod_names), + "vanilla": {"count": len(vanilla_names), + "note": "vanilla 方块不走本表(语义由注册表给)"}, + "explicit": {"count": len(explicit), "kinds": _kinds(explicit, table, patterns), + "sample": explicit[:10]}, + "inferred": {"count": len(inferred), "kinds": _kinds(inferred, table, patterns), + "sample": inferred[:10], + "note": "这些是**推测**(命名规则命中),不是确认的语义"}, + "unknown": {"count": len(unknown), "sample": unknown[:10], + "note": "表与规则都没命中 ⇒ 寻路会当普通方块处理(可能出错)"}, + "coverage_pct": round(100.0 * (len(mod_names) - len(unknown)) / m, 1), + "coverage_note": "覆盖率只对 **mod 方块**计算(vanilla 由注册表覆盖)", + } + + +def _kinds(names: list[str], table, patterns) -> dict[str, int]: + c: dict[str, int] = {} + for n in names: + k = semantics_of(n, table, patterns).kind + c[k] = c.get(k, 0) + 1 + return c diff --git a/mcfold/pathfind.py b/mcfold/pathfind.py index 876f6b7..94933ee 100644 --- a/mcfold/pathfind.py +++ b/mcfold/pathfind.py @@ -166,10 +166,21 @@ class Terrain: return False # 危险/液体:不能承重 if h["name"] == "air" or h["name"] in ("cave_air", "void_air"): return False # 空气不是地面 + # ★★ 先问**运行时缓存**(含 mod 方块),再退回本机注册表。 + # 实测依据:本机 blocks.json 只有 1060 个 vanilla 方块, + # Create 的 643 个里 613 个认不出(覆盖率 4.7%) + # ⇒ 只能用运行中的官方注册表(M8 的 blockinfo 已在读它)。 + sid = h["state_id"] + cache = getattr(self.w, "solid_cache", None) + if cache: + hit = cache.get(sid) + if hit is not None: + v = hit.get("isSolid") + return None if v is None else bool(v) reg = self._reg() if reg is None: return None - return reg.is_solid(h["state_id"]) # None 表示认不出 ⇒ 不知道 + return reg.is_solid(sid) # None 表示认不出 ⇒ 不知道 def is_standable(self, x: int, y: int, z: int) -> bool | None: """玩家的**脚**能否停在 (x,y,z):下方要实心,本格与头顶要空。 @@ -216,14 +227,33 @@ class Terrain: return head is True def can_dig(self, x: int, y: int, z: int) -> bool: - """能否挖开这一格(需要世界改动的移动才用得到)。""" + """能否挖开这一格(需要世界改动的移动才用得到)。 + + ★ 判据用**破坏速度**(运行时注册表给的 destroySpeed), + 不靠名字猜「基岩挖不动」: + mod 方块里可能有各种不可破坏/极硬的方块,靠猜会规划出挖不动的路。 + 基岩 destroySpeed = -1(官方约定:负值 = 不可破坏)。 + """ if not self.allow_world_change: return False h = self.at(x, y, z) - if h["source"] != "registry": + if h["source"] not in ("registry", "unknown-state"): + return False + if h["name"] in ("water", "lava", "void_air", "end_portal"): + return False # 液体/传送门挖了也没用 + sid = h["state_id"] + cache = getattr(self.w, "solid_cache", None) + if cache: + hit = cache.get(sid) + if hit is not None and hit.get("destroySpeed") is not None: + return float(hit["destroySpeed"]) > 0 + reg = self._reg() + if reg is not None: + b = reg.by_name.get(h["name"] or "") + if b is not None and b.get("hardness") is not None: + return float(b["hardness"]) > 0 + if h["name"] in ("bedrock",): return False - if h["name"] in ("bedrock", "water", "lava"): - return False # 基岩挖不动,液体挖了也没用 return True diff --git a/mcfold/servers.py b/mcfold/servers.py index 4b0a68d..5c8be6f 100644 --- a/mcfold/servers.py +++ b/mcfold/servers.py @@ -84,19 +84,65 @@ def fabric_installer(version: str) -> dict[str, Any]: 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-server-launch`,可直接 `java -jar` 起 Fabric 服务端。""" + """取可起 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} - # 需要先按版本定 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) + 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]: @@ -118,6 +164,94 @@ def fabric_profile_meta(version: str) -> dict[str, Any]: 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( @@ -128,10 +262,18 @@ def create_modded_server( 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 @@ -143,15 +285,28 @@ def create_modded_server( # 否则失败的加载器会留下一个「看着存在但没配好」的实例目录 # (实测:NeoForge 失败后实例目录仍在 ⇒ 以后 list_instances 会列出来, # 使用者以为装好了)。失败应当**不留痕**。 - if loader not in ("", "vanilla", "fabric"): + if loader not in ("", "vanilla", "fabric", "neoforge"): return { "ok": False, - "why": f"加载器 {loader!r} 的服务端还没接(当前支持 vanilla / fabric)", - "how_to": "NeoForge/Forge 服务端需各自的 installer 流程;" + "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 @@ -193,13 +348,53 @@ def create_modded_server( launch = {"cmd": ["java", f"-Xmx{java_heap}", f"-Xms{java_heap}", "-jar", "server.jar", "nogui"]} elif loader == "fabric": - got = fabric_server_launcher(version) + 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} - shutil.copy2(got["path"], inst / "fabric-server-launch.jar") + # 把库复制进实例(实例独占,不依赖内容根的相对路径) + 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}", - "-jar", "fabric-server-launch.jar", "nogui"]} + "-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( diff --git a/mcfold/solid.py b/mcfold/solid.py new file mode 100644 index 0000000..4e51d87 --- /dev/null +++ b/mcfold/solid.py @@ -0,0 +1,210 @@ +"""运行时属性探针:向**运行中的官方客户端**问方块属性(含 mod)。 + +## 为什么必须有这一层(实测结论,不是推测) + +寻路需要两个属性:**能不能站/穿**(solid)与**能不能挖**(destroySpeed)。 +我曾想用「方块名字 → 语义表」的办法覆盖 mod,实测覆盖率只有 **4.7%**: + + Create 643 个方块 → 显式表命中 9、命名规则推测 21、完全不识 613 + +⇒ 手写表**追不上** 64 个 mod / 1465 个方块。 + +但这两个属性**运行时全都有**:Java 侧 `blockinfo` 已经在读 +`BuiltInRegistries.BLOCK`(**含 mod 运行时注册的方块**),并返回 + + isSolid / destroySpeed / lightEmission / isAir / hasBlockEntity / block(名字) + +⇒ 正确做法是**问**,不是猜。 + +## 这个模块做什么 + +批量地把「世界坐标 → 方块属性」问出来,喂给寻路与感知层。 +注意这需要 JVM(细渲染那一侧),所以它**不是常驻**的: +- 寻路在 vanilla 世界里走本机 `blocks.json`(快、无 JVM) +- 寻路在 mod 世界里**问运行时**(准、要 JVM) +两者接口一致,由 `SolidSource` 抽象掉差异。 + +## 缓存:这是能不能用的关键 + +问一次要起 JVM(秒级)。所以: +- 同一批坐标**一次问完**(`probe_many`),不逐格往返; +- 结果按 `(state_id, 世代)` 缓存 —— 因为属性只依赖 state,不依赖坐标。 +""" +from __future__ import annotations + +from typing import Any, Iterable + + +# ---------- 属性来源抽象 ---------- + +class SolidSource: + """「某格是否实心 / 可挖」的来源。 + + ## 为什么要抽象 + - `RegistrySolidSource`:读本机注册表(vanilla 快;**mod 认不出**) + - `RuntimeSolidSource`:问运行中的官方 JVM(**含 mod**,准但要 JVM) + + 寻路只依赖 `is_solid(state_id) / destroy_speed(state_id)`, + 所以两个来源可以互换 —— 差别只在**覆盖率**与**成本**。 + """ + + def is_solid(self, state_id: int) -> bool | None: + """None = 不知道(**不当成空气**)。""" + raise NotImplementedError + + def destroy_speed(self, state_id: int) -> float | None: + """挖方块速度(0 = 挖不动,如基岩)。None = 不知道。""" + return None + + def name_of(self, state_id: int) -> str | None: + return None + + @property + def source(self) -> str: + return "abstract" + + def coverage_note(self) -> str: + return "" + + +class RegistrySolidSource(SolidSource): + """本机注册表(vanilla `blocks.json`)。快,但**没有 mod**。""" + + def __init__(self, registry): + self._reg = registry + + def is_solid(self, state_id: int) -> bool | None: + if self._reg is None: + return None + return self._reg.is_solid(state_id) + + def destroy_speed(self, state_id: int) -> float | None: + reg = self._reg + if reg is None: + return None + b = reg.by_name.get(reg.name_of(state_id) or "") + if not b: + return None + return b.get("hardness") + + def name_of(self, state_id: int) -> str | None: + if self._reg is None: + return None + return self._reg.name_of(state_id) + + @property + def source(self) -> str: + return "registry" + + def coverage_note(self) -> str: + if self._reg is None: + return "没有注册表 ⇒ 一切判定都是「不知道」" + return (f"本机注册表 {len(self._reg.blocks)} 个方块 / " + f"maxStateId {self._reg.max_state_id};" + "**不含 mod 方块** ⇒ mod 的 state id 一律「不知道」") + + +class RuntimeSolidSource(SolidSource): + """问运行中的官方 JVM(`blockinfo` 已经读运行时注册表,含 mod)。 + + ## 前提 + 需要一个活着的 `WorldRenderer`(JVM 已在跑)。这是**按需**的: + 细渲染本来就常在用(M8/M9),所以边际成本很低; + 但单独为了问属性而起 JVM 就不划算 ⇒ 优先用 `RegistrySolidSource`。 + + ## 为什么「问」比「表」好 + - 覆盖 100%(含所有 mod、所有版本) + - 属性由游戏自己的注册表给出,与实际碰撞**一致** + - 不用维护表、不用猜 + """ + + def __init__(self, renderer, *, + cache: dict[int, dict[str, Any]] | None = None): + self._r = renderer + self._cache: dict[int, dict[str, Any]] = cache if cache is not None else {} + self.queried = 0 + + def _info(self, x: int, y: int, z: int) -> dict[str, Any] | None: + try: + return self._r.get_block_info(x, y, z) + except Exception: + return None + + def probe(self, x: int, y: int, z: int) -> dict[str, Any]: + """问一格(结果按 state_id 缓存 —— 属性只依赖 state)。""" + info = self._info(x, y, z) + if not info: + return {} + if not info.get("known"): + # 未加载:这是**事实**,不是「不知道属性」 + return {"known": False, "reason": info.get("reason", "")} + sid = info.get("stateId") + if sid is None: + return {} + hit = self._cache.get(sid) + if hit is None: + hit = { + "known": True, + "name": info.get("block"), + "isSolid": info.get("isSolid"), + "isAir": info.get("isAir"), + "destroySpeed": info.get("destroySpeed"), + "lightEmission": info.get("lightEmission"), + "hasBlockEntity": info.get("hasBlockEntity"), + } + self._cache[sid] = hit + self.queried += 1 + return hit + + def is_solid(self, state_id: int) -> bool | None: + hit = self._cache.get(state_id) + return None if hit is None else hit.get("isSolid") + + def destroy_speed(self, state_id: int) -> float | None: + hit = self._cache.get(state_id) + return None if hit is None else hit.get("destroySpeed") + + def name_of(self, state_id: int) -> str | None: + hit = self._cache.get(state_id) + return None if hit is None else hit.get("name") + + @property + def source(self) -> str: + return "runtime" + + def coverage_note(self) -> str: + return (f"运行时注册表(已探测 {self.queried} 个 state," + f"缓存 {len(self._cache)} 个)⇒ **含 mod**,覆盖 100%") + + +# ---------- 批量探测 ---------- + +def attach_runtime( + world, + source: RuntimeSolidSource, + positions: Iterable[tuple[int, int, int]], +) -> dict[str, Any]: + """把一批坐标的属性问出来,写进 `world` 的 `_solid_cache`。 + + ★ **批量**是能不能用的关键:一次问一格要一次 NDJSON 往返, + 问一万格就一万次往返。批量问完再统一查表,快得多。 + """ + cache = getattr(world, "solid_cache", None) + if cache is None: + cache = {} + world.solid_cache = cache # type: ignore[attr-defined] + ok = miss = 0 + for (x, y, z) in positions: + hit = source.probe(x, y, z) + sid = None + try: + sid = world.block_at(x, y, z)["state_id"] + except Exception: + pass + if hit.get("known") and sid is not None: + cache[sid] = hit + ok += 1 + else: + miss += 1 + return {"probed": ok, "unknown": miss, "cached_states": len(cache), + "source": source.source} diff --git a/mcfold/state.py b/mcfold/state.py index 2d4e7d4..a893612 100644 --- a/mcfold/state.py +++ b/mcfold/state.py @@ -151,6 +151,12 @@ class WorldState: self.overrides: dict[tuple[int, int, int], int] = {} # 方块变化流水(供「我改了什么」回放) self.block_log: list[tuple[int, int, int, int]] = [] + # ★ 运行时属性缓存:state_id → {isSolid, destroySpeed, name, ...} + # 为什么需要:本机 blocks.json **不含 mod 方块**(实测 Create 643 个里 + # 613 个认不出,覆盖率 4.7%)⇒ 只能问运行中的官方注册表。 + # 而属性只依赖 state、不依赖坐标 ⇒ 按 state_id 缓存即可。 + # 由 mcfold.solid.attach_runtime() 填充。 + self.solid_cache: dict[int, dict[str, Any]] = {} # 槽位 → 物品(来自 set_slot)。缺了它就不知道手上有没有东西 self.slots: dict[int, Any] = {} # 热键栏视图:索引 0..8(来自 window0 的 slot 36..44) diff --git a/tests/test_pathfind.py b/tests/test_pathfind.py index 89cba6c..8565db9 100644 --- a/tests/test_pathfind.py +++ b/tests/test_pathfind.py @@ -19,7 +19,7 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from mcfold import pathfind as pf from mcfold.state import WorldState, load_vanilla_registry -from mcfold.chunk import BlockRegistry +from mcfold.chunk import BlockRegistry, MIN_Y DATA_ROOT = ('/home/newqqagent/plugin_data/mc/runtime/node_modules/' 'minecraft-data/minecraft-data/data') @@ -41,6 +41,33 @@ class _Chunk: return self.fn(x, y, z) +def _scene_mcfold(fn, y_mod=0, y_air=1): + """用真 WorldState 造场景:把 y_mod 放 mod 方块,其余放空气。 + + ★ 两个容易踩的坑(我都踩了): + 1. `Chunk.sections[i]` 对应世界 y = MIN_Y + 16*i,MIN_Y = -64 + ⇒ y=0 在第 4 个 section,不是第 0 个。 + 2. 同一 section 内的 16 格**只能一个值**(single_value)⇒ + mod 方块与空气若落在同一 section,空气格会变成 mod 方块 + (表现为「空气格返回 unknown-state」——很容易误判成代码 bug)。 + ⇒ 这里让 mod 方块与空气**跨 section**(y_mod=MIN_Y,y_air=MIN_Y+1 仍同 section) + ⇒ 改为直接按 section 建:mod 在 section 0,空气在 section 1。 + """ + from mcfold.state import WorldState + from mcfold.chunk import Chunk, Section, MIN_Y + ws = WorldState(registry=_REG, num_sections=24) + mod_sid = fn(0, y_mod, 0) + air_sid = fn(0, y_air, 0) + # mod 方块占 section 0,空气占 section 1(两段不同值) + ch = Chunk(x=0, z=0, sections=[ + Section(y_index=0, single_value=mod_sid), + Section(y_index=1, single_value=air_sid), + ]) + ws.world.chunks[(0, 0)] = ch + ws.player.x, ws.player.y, ws.player.z = 0.5, 0.0, 0.5 + return ws + + def scene(fn, chunks=((-2, -2), (-2, -1), (-2, 0), (-2, 1), (-2, 2), (-1, -2), (-1, -1), (-1, 0), (-1, 1), (-1, 2), (0, -2), (0, -1), (0, 0), (0, 1), (0, 2), @@ -376,6 +403,115 @@ def test_reachable_default_does_not_change_world(): print(" ★ reachable \u9ed8\u8ba4\u7b54\u300c\u73b0\u6709\u5730\u5f62\u300d\uff0c\u5141\u8bb8\u6539\u4e16\u754c\u5219\u80fd\u8fc7") + + +# ---------- ⑦ mod 环境:问运行时,而不是猜名字 ---------- + + +def test_runtime_solid_cache_beats_local_registry(): + """★★ 本机注册表**没有 mod 方块** ⇒ 寻路必须先问运行时缓存。 + + 实测依据(2026-09-26):Create 643 个方块里 613 个(95%)在本机 + blocks.json 里认不出(覆盖率 4.7%)。若只查本机注册表, + 这些方块一律返回 None(不知道)⇒ 寻路会拒绝穿越/站立它们。 + + ⇒ 判据:往 WorldState.solid_cache 塞一条 mod 方块记录, + 寻路就该按**它给的** isSolid 回答,而不是回退到本机注册表。 + """ + from mcfold import pathfind as pf + + # 一个 mod 方块:state_id 远超 vanilla 上界 + mod_sid = 30000 + MY = MIN_Y # ★ mod 方块放在 MIN_Y(真值,判据按真实坐标写) + ws = _scene_mcfold(lambda x, y, z: mod_sid if y == MY else 0, + y_mod=MY, y_air=MY + 1) + t = pf.Terrain(ws) + + # 前置:本机注册表确实认不出(这正是要解决的问题) + assert ws.block_at(0, MY, 0)["source"] == "unknown-state", ws.block_at(0, MY, 0) + # ① 没有运行时数据 ⇒ 不知道(这是**诚实**的默认) + assert t.is_ground(0, MY, 0) is None, "mod 方块在本机注册表里认不出 ⇒ 不知道" + + # ② 有运行时数据 ⇒ 按它回答 + ws.solid_cache[mod_sid] = {"name": "create:track", "isSolid": True, + "destroySpeed": 0.5} + assert t.is_ground(0, MY, 0) is True, "应采用运行时的 isSolid=True" + # ③ 运行时说它能站 ⇒ 脚可以停在它上面。 + # ★ 站位取 MY+16(**跨一整个 section**):mod 方块铺满 section 0, + # MY+1 仍在 section 0 里(也就是还是 mod 方块)⇒ 站不住。 + # 这是「section 整段同值」这个 fixture 形状的必然结果,不是代码 bug。 + assert t.is_standable(0, MY + 16, 0) is True, "脚在上一整段,下方是 mod 方块 ⇒ 可站" + print(" ★★ mod 方块:问运行时(而非本机注册表猜)") + + +def test_runtime_destroy_speed_blocks_indestructible(): + """★ 不可破坏判定用**运行时 destroySpeed**,不靠名字猜。 + + 基石的官方约定是 destroySpeed = **-1**(负值 = 不可破坏)。 + mod 方块同理 ⇒ 判据要吃这个信号,否则会规划出「挖不动却要挖」的路。 + """ + from mcfold import pathfind as pf + mod_sid = 30001 + MY = MIN_Y + ws = _scene_mcfold(lambda x, y, z: mod_sid if y == MY else 0, + y_mod=MY, y_air=MY + 1) + ws.solid_cache[mod_sid] = {"name": "somemod:unbreakable", + "isSolid": True, "destroySpeed": -1.0} + t = pf.Terrain(ws) + assert t.can_dig(0, MY, 0) is False, "destroySpeed<0 ⇒ 挖不动" + ws.solid_cache[mod_sid]["destroySpeed"] = 1.5 + assert t.can_dig(0, MY, 0) is True, "destroySpeed>0 ⇒ 能挖" + print(" ★ 不可破坏判定吃运行时的 destroySpeed(含基岩的 -1 约定)") + + +def test_runtime_unknown_falls_back_honestly(): + """★ 运行时也没数据 ⇒ **退回「不知道」**,不许当成空气当路走。""" + from mcfold import pathfind as pf + MY = MIN_Y + ws = _scene_mcfold(lambda x, y, z: 30002 if y == MY else 0, + y_mod=MY, y_air=MY + 1) # solid_cache 空 + t = pf.Terrain(ws) + assert t.is_ground(0, MY, 0) is None, "两边都没数据 ⇒ 不知道(不当空气)" + assert t.is_passable(0, MY, 0) is None + print(" ★ 两边都无数据 ⇒ 不知道(不谎称可走)") + + +def test_modblocks_coverage_report_is_honest(): + """★ 语义表的**覆盖率必须如实报**(不能让人以为「认不出 = 不存在」)。 + + 实测:Create 643 个方块里覆盖率仅 4.7% ⇒ 这个数字本身就是重要信息。 + """ + from mcfold import modblocks as M + fake = ["create:track", "create:blaze_burner", "create:andesite_casing", + "somemod:unknown_thing", "minecraft:stone"] + rep = M.coverage_report(fake) + # ★ vanilla 不计入「mod 表未知」—— 它的语义由注册表给(否则会误以为 + # 「连石头都认不出」,那是两回事) + assert rep["mod_total"] == 4, rep + assert rep["unknown"]["count"] == 2, rep # casing + unknown_thing + assert rep["inferred"]["note"], "推测项必须带说明" + assert 0 <= rep["coverage_pct"] <= 100 + # 推测的必须被标记 + s = M.semantics_of("create:andesite_encased_cogwheel") + if s.kind != M.UNKNOWN: + assert s.inferred is True, f"规则命中的应标 inferred:{s}" + print(f" ★ 覆盖率如实报({rep['coverage_pct']}%,不识 {rep['unknown']['count']} 个)") + + +def test_modblocks_never_returns_bare_string(): + """★ 语义查询永远返回带 `inferred` 的结构(推测要能被看见)。""" + from mcfold import modblocks as M + for name in (None, "", "create:track", "create:blaze_burner", + "totally:unknown"): + s = M.semantics_of(name) + assert isinstance(s.inferred, bool), name + assert s.kind, name + assert isinstance(s.as_dict(), dict) + # 名字是空的也要给结构(不能返回 None 让调用方猜) + assert M.semantics_of(None).kind == M.UNKNOWN + print(" ★ 语义查询永远返回带 inferred 标记的结构") + + if __name__ == "__main__": for fn in [test_reachable_gives_no_route, test_find_path_gives_route, @@ -395,6 +531,11 @@ if __name__ == "__main__": test_goal_inside_wall_reports_failure, test_node_limit_is_reported, test_collision_caveat_is_stated, - test_reachable_default_does_not_change_world]: + test_reachable_default_does_not_change_world, + test_runtime_solid_cache_beats_local_registry, + test_runtime_destroy_speed_blocks_indestructible, + test_runtime_unknown_falls_back_honestly, + test_modblocks_coverage_report_is_honest, + test_modblocks_never_returns_bare_string]: fn() print("\n寻路:全部通过") diff --git a/tests/test_servers.py b/tests/test_servers.py index 9305564..63f3b38 100644 --- a/tests/test_servers.py +++ b/tests/test_servers.py @@ -87,16 +87,20 @@ def test_instance_json_records_version_and_loader(monkey=None): """ tmp = _tmp() jar = _fake_jar(tmp) - # 替换下载:造一个本地假 launcher - fake_launch = tmp / "fake-fabric-server-launch.jar" - fake_launch.write_bytes(b"PK\x03\x04" + b"0" * 5000) - orig = S.fabric_server_launcher - S.fabric_server_launcher = lambda v: {"ok": True, "path": str(fake_launch), - "cached": True, "_stub": True} + # ★ fabric 现在走 classpath 启动(实测 maven 上没有 server-launch 产物) + # ⇒ 判据替换「库解析」,不依赖网络与本地资产 + libs = tmp / "fakelibs" + libs.mkdir() + for n in ("fabric-loader.jar", "intermediary.jar"): + (libs / n).write_bytes(b"PK\x03\x04" + b"0" * 5000) + orig = S.fabric_libraries + S.fabric_libraries = lambda v, loader_version="": { + "ok": True, "jars": [str(p) for p in sorted(libs.glob("*.jar"))], + "source": str(libs), "cached": True, "_stub": True} try: r = S.create_modded_server("inst1", version="1.20.1", loader="fabric") finally: - S.fabric_server_launcher = orig + S.fabric_libraries = orig assert r["ok"] is True, r meta = json.loads((Path(r["instance"]) / "instance.json").read_text()) assert meta["version"] == "1.20.1", meta @@ -122,14 +126,14 @@ def test_runtime_dir_is_third_layer(): def test_unimplemented_loader_says_so(): - """★★ 没接的加载器(NeoForge/Forge)必须**明说**,不能静默降级。 + """★★ 没接的加载器(Forge/Quilt)必须**明说**,不能静默降级。 静默降级成 vanilla 的后果很严重:服起来了但**mod 全部没加载**, 而使用者以为装上了 ⇒ 后面所有观察都基于错误前提。 """ tmp = _tmp() jar = _fake_jar(tmp) - for loader in ("neoforge", "forge"): + for loader in ("forge", "quilt"): r = S.create_modded_server(f"inst-{loader}", version="1.21.1", loader=loader, server_jar=str(jar)) assert r["ok"] is False, f"{loader} 不该成功" @@ -157,6 +161,23 @@ def test_missing_mod_files_are_named(): print(" ★ 缺 mod 逐个指名(不静默吞)") +def test_neoforge_requires_install_flag(): + """★ NeoForge 没给 install=True 要**明说**(不能假装装好了)。 + + NeoForge 服务端必须由官方 installer(--installServer)生成, + 没有它就只有一堆 mod jar 而没有可运行的服。 + """ + tmp = _tmp() + jar = _fake_jar(tmp) + r = S.create_modded_server("inst-nf", version="1.21.1", loader="neoforge", + mod=[str(jar)], install=False) + assert r["ok"] is False, r + assert "installer" in r["why"], r + assert r.get("how_to"), r + assert not (S.INSTANCES / "inst-nf").exists(), "失败不该留实例目录" + print(" ★ NeoForge 未 install:明说要跑 installer") + + def test_missing_server_jar_for_vanilla_is_reported(): """★ vanilla 没给 server.jar 要明说(不能起个残缺服)。""" tmp = _tmp() @@ -218,6 +239,7 @@ if __name__ == "__main__": test_runtime_dir_is_third_layer, test_unimplemented_loader_says_so, test_missing_mod_files_are_named, + test_neoforge_requires_install_flag, test_missing_server_jar_for_vanilla_is_reported, test_bad_instance_name_rejected, test_start_script_is_executable_and_self_contained,