用户要求: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 项全通过。
211 lines
7.3 KiB
Python
211 lines
7.3 KiB
Python
"""运行时属性探针:向**运行中的官方客户端**问方块属性(含 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}
|