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:
227
mcfold/modblocks.py
Normal file
227
mcfold/modblocks.py
Normal 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
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
@ -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
210
mcfold/solid.py
Normal 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}
|
||||
@ -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)
|
||||
|
||||
@ -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寻路:全部通过")
|
||||
|
||||
@ -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,
|
||||
|
||||
Reference in New Issue
Block a user