Files
agentmc/mcfold/solid.py
JianFeeeee 554ebc239a 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 项全通过。
2026-09-26 14:29:58 +08:00

211 lines
7.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""运行时属性探针:向**运行中的官方客户端**问方块属性(含 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}