M10c:mod 环境下寻路 —— 问运行时而非猜名字(+ 语义表与覆盖率报告)

用户要求: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 项全通过。
This commit is contained in:
JianFeeeee
2026-09-26 14:29:58 +08:00
parent 9c5fed5808
commit 554ebc239a
7 changed files with 862 additions and 31 deletions

227
mcfold/modblocks.py Normal file
View File

@ -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("<none>", 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

View File

@ -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

View File

@ -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/<loader>-<version>/.mcfold/fabric/`,那正是「内容根共享、只读」
的用途所在。重新下载既慢又会多一份副本。
⇒ 这也是「同一版本多实例」在资产层面的体现:库只存一份,实例各自持有引用。
"""
out: list[str] = []
missing: list[str] = []
shared = STORE / f"fabric-{version}" / ".mcfold" / "fabric"
if not shared.exists():
# 退化:vanilla-<v> 内容根里也放过同一批(历史上这么建的)
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(

210
mcfold/solid.py Normal file
View File

@ -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}

View File

@ -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)

View File

@ -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寻路:全部通过")

View File

@ -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,