Files
agentmc/tests/test_pathfind.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

542 lines
24 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.

"""寻路判据:pathfind.py + api 的两个工具。
## 为什么必须用构造场景
线上是 flat world(平地),测不出任何寻路逻辑 ——
「在平地上不崩」不等于「寻路对」。
所以判据全部**构造**:平地、单格障碍、高墙、深坑、岩浆沟、封闭墙、台阶山、
未加载边界。每条都断言语义(走哪、挖哪、停在哪),不只断「没崩」。
## 两条铁律
1. **两个工具是分开的**:`reachable` 只答能不能(不给路线)、
`find_path` 给路线。判据要保证它们**不会退化成同一个东西**。
2. **未加载 = 走到边界 + 如实说**,绝不报「地形不通」
—— 后者会让 agent 以为那边是死路而放弃。
"""
import sys, os, math
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, MIN_Y
DATA_ROOT = ('/home/newqqagent/plugin_data/mc/runtime/node_modules/'
'minecraft-data/minecraft-data/data')
_REG = load_vanilla_registry('1.21.1', DATA_ROOT)
# 真实 state id(从注册表取,不硬编)
SID = {n: _REG.by_name[n]["minStateId"] for n in
("air", "stone", "grass_block", "dirt", "lava", "water", "bedrock",
"oak_planks", "cobblestone", "sand", "cactus", "magma_block")}
GRASS, AIR, STONE, LAVA, WATER = (
SID["grass_block"], SID["air"], SID["stone"], SID["lava"], SID["water"])
class _Chunk:
def __init__(self, fn):
self.fn = fn
def state_id_at(self, x, y, z):
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),
(1, -2), (1, -1), (1, 0), (1, 1), (1, 2),
(2, -2), (2, -1), (2, 0), (2, 1), (2, 2)),
start=(0.5, 0.0, 0.5)):
ws = WorldState(registry=_REG, num_sections=24)
for cx, cz in chunks:
ws.world.chunks[(cx, cz)] = _Chunk(fn)
ws.player.x, ws.player.y, ws.player.z = start
return ws
def flat(x, y, z):
return GRASS if y < 0 else AIR
# ---------- ① 两个工具必须是分开的 ----------
def test_reachable_gives_no_route():
"""★ `reachable` **只答能不能**,不返回路线(省 token / 省计算)。"""
ws = scene(flat)
r = pf.reachable(ws, x=10, y=0, z=0)
assert r["reachable"] is True, r
assert "moves" not in r, f"reachable 不该给路线:{list(r)}"
assert r["approximate_steps"] > 0
assert r["without_changing_world"] is True
print(f" ★ reachable 只给结论({r['approximate_steps']} 步,无路线)")
def test_find_path_gives_route():
"""★ `find_path` 给**具体路线**与每步动作。"""
ws = scene(flat)
r = pf.find_path(ws, x=10, y=0, z=0)
assert r["reached"] is True, r
assert r["steps"] == 10, r["steps"]
assert len(r["moves"]) == 10
assert all(m["action"] == "forward" for m in r["moves"]), r["moves"][:3]
print(f" ★ find_path 给路线({r['steps']} 步,纯 forward)")
# ---------- ② 地形语义 ----------
def test_flat_needs_no_world_change():
"""★ 平地上**不该**产生任何 dig/place(否则就是无谓改世界)。"""
ws = scene(flat)
r = pf.find_path(ws, x=10, y=0, z=0)
assert r["world_changes"]["count"] == 0, r["world_changes"]
assert r["needs_world_change"] is False
print(" ★ 平地:不挖不放")
def test_single_block_uses_step_up_not_dig():
"""★ 1 格障碍应当 **step_up 跨过去**,不是挖。
这是实测抓到的真实缺陷:早期代价权重让 dig(6.0) 比
「上 1 级再下 1 级」便宜 ⇒ 挖 6 格平路,荒谬。
"""
def one_block(x, y, z):
return GRASS if y < 0 else (STONE if (x == 5 and y == 0) else AIR)
ws = scene(one_block)
r = pf.find_path(ws, x=10, y=0, z=0)
assert r["reached"] is True, r
assert r["world_changes"]["count"] == 0, \
f"1 格障碍不该挖:{r['world_changes']['dig']}"
assert "step_up" in {m["action"] for m in r["moves"]} or \
"jump" in {m["action"] for m in r["moves"]}, r["moves"]
print(" ★ 1 格障碍:step_up/jump 跨过,零改世界")
def test_tall_wall_is_dug_one_column():
"""★ 高墙要**挖穿一列**,且挖的格数与墙高对得上、**不挖空气**。"""
for h in (2, 3, 4):
def wall(x, y, z, h=h):
return GRASS if y < 0 else (STONE if (x == 5 and 0 <= y <= h - 1) else AIR)
ws = scene(wall)
r = pf.find_path(ws, x=10, y=0, z=0)
assert r["reached"] is True, f"墙高{h} 应可达:{r.get('stopped_because')}"
digs = r["world_changes"]["dig"]
assert digs, f"墙高{h} 没能绕过也没挖穿"
# ★ 绝不能挖空气
assert not [d for d in digs if d["block"] == "air"], digs
# 墙越高,挖得越多(单调)
if h > 2:
assert len(digs) >= 2, digs
print(f" ★ 高墙:挖穿一列(2/3/4 格墙分别挖 {[len(pf.find_path(scene(lambda x,y,z: GRASS if y<0 else (STONE if (x==5 and 0<=y<=h-1) else AIR)), x=10,y=0,z=0)['world_changes']['dig']) for h in (2,3,4)]} 格),无空气)")
def test_pit_is_bridged_not_drowned():
"""★ 深坑要 **place 搭桥**(不是掉下去、也不是挖)。"""
def pit(x, y, z):
return AIR if (x == 5 and -3 <= y < 0) else (GRASS if y < 0 else AIR)
ws = scene(pit)
r = pf.find_path(ws, x=10, y=0, z=0)
assert r["reached"] is True, r
places = r["world_changes"]["place"]
assert places, f"深坑应搭桥:{r['moves']}"
print(f" ★ 深坑:place 搭桥 {len(places)} 块")
def test_lava_is_never_stepped_into():
"""★★ 岩浆/火 `solid=False`(碰撞上能穿过去)但**致命**
⇒ 「可站立」判定必须单独排除它们,只看 solid 会把它们当地面。
★ 这条判据**必须先直接测判定函数**再测路径(我第一版只测路径 ⇒ 抓不到 bug):
只断言「路径没踏入岩浆」时,即使 `is_ground(岩浆)=True`,
寻路也可能因别的约束(如 place 更便宜)而恰好绕开 ⇒ 判据看着绿、实则没测到。
⇒ 判据要打在**被测语义本身**(is_ground/is_passable 对岩浆的回答),
再用路径做端到端确认。
"""
def lava(x, y, z):
if x == 5 and y < 0:
return LAVA
return GRASS if y < 0 else AIR
ws = scene(lava)
t = pf.Terrain(ws)
# ① 直接测判定(这才是语义所在)
assert t.is_ground(5, -1, 0) is False, \
f"岩浆不能当地面,实得 {t.is_ground(5, -1, 0)}"
assert t.is_passable(5, -1, 0) is False, \
f"岩浆不能被「穿过去」,实得 {t.is_passable(5, -1, 0)}"
assert t.is_standable(5, 0, 0) is not True, \
"站在岩浆上方那格不该判定为可站"
# ② 端到端:路径不踏入
r = pf.find_path(ws, x=10, y=0, z=0)
assert r["reached"] is True, r
cx, cy, cz = 0, 0, 0
for m in r["moves"]:
cx += m["d"][0]; cy += m["d"][1]; cz += m["d"][2]
if (cx, cy, cz) == (5, -1, 0):
raise AssertionError(f"路径走进了岩浆格:{m}")
print(" ★★ 岩浆:判定函数直接排除 + 路径不踏入")
def test_solid_but_deadly_is_excluded():
"""★★ 真正需要 hazard 检查的是 **solid=True 但致命**的方块。
★ 这条判据修正了我的一个错误认识:
我原以为「岩浆 solid=False ⇒ 必须靠 hazard 检查才不当地面」,
但实测数据是:lava/water 本来就 solid=False ⇒ is_ground 自然 False,
我的 hazard 检查对它们**是冗余的**(变异掉也不会红)。
真正靠 hazard 检查的是 solid=True 却致命的:
cactus / magma_block / campfire / scaffolding
⇒ 判据必须用这些方块,否则测的是冗余代码(变异不掉)。
"""
for name in ("cactus", "magma_block"):
sid = SID[name]
def hazard(x, y, z, sid=sid):
return sid if (x == 5 and y < 0) else (GRASS if y < 0 else AIR)
ws = scene(hazard)
t = pf.Terrain(ws)
assert _REG.is_solid(sid) is True, f"{name} 前提应是 solid=True(否则测不到 hazard 逻辑)"
assert t.is_ground(5, -1, 0) is False, \
f"{name} 是实心但致命,不能当地面,实得 {t.is_ground(5, -1, 0)}"
assert t.is_passable(5, -1, 0) is False, \
f"{name} 不能被当作可穿过,实得 {t.is_passable(5, -1, 0)}"
print(" ★★ 实心但致命(仙人掌/岩浆块):被 hazard 检查排除")
def test_water_also_not_ground():
"""★ 水同样不能当地面(会溺水/减速)。"""
def water(x, y, z):
if x == 5 and y < 0:
return WATER
return GRASS if y < 0 else AIR
ws = scene(water)
t = pf.Terrain(ws)
assert t.is_ground(5, -1, 0) is False, "水不该算承重"
assert t.is_standable(5, 0, 0) is not True
print(" ★ 水:不作为地面")
def test_bedrock_cannot_be_dug():
"""★ 基岩挖不动 ⇒ dig 规则要排除。"""
def wall(x, y, z):
return GRASS if y < 0 else (SID["bedrock"] if (x == 5 and 0 <= y <= 3) else AIR)
ws = scene(wall)
r = pf.find_path(ws, x=10, y=0, z=0)
digs = r["world_changes"]["dig"]
assert not [d for d in digs if d["block"] == "bedrock"], \
f"基岩不该出现在挖清单里:{digs}"
print(" ★ 基岩:不会被挖")
# ---------- ③ 未加载:走到边界 + 如实报告(用户最在意) ----------
def test_unloaded_reports_boundary_not_terrain_blocked():
"""★★ 未加载必须报 `hit_unloaded` 并给出**走到哪**,不得报「地形不通」。
★ 这是实测抓到的真 bug:`neighbors()` 在未加载格上不生成移动,
所以 A* 里「if not t.known(...)」永远执行不到 ⇒ 报 `exhausted`
(听起来像死路),而真相是「数据用完了,让玩家走过去加载」。
两者对 agent 的意义完全不同:
地形不通 ⇒ 换目标;数据用完 ⇒ 走过去再问。
"""
# 只加载 (0,0) 与 (-1,0) ⇒ 边界在 x=15
limited = ((-1, 0), (0, 0))
ws = scene(flat, chunks=limited)
r = pf.reachable(ws, x=200, y=0, z=0, max_radius=64)
assert r["reachable"] is False, r
assert r["stopped_because"] == "hit_unloaded", \
f"应报 hit_unloaded,实际 {r['stopped_because']}('exhausted' 会被误读成死路)"
assert r["stopped_at"] is not None, "要说明走到哪了"
assert r["stopped_at"][0] <= 15, r["stopped_at"]
assert r["approximate_steps"] > 0, "至少该报告走到了边界"
assert "未加载" in r["note"], r["note"]
print(f" ★★ 未加载:报 hit_unloaded,停在 {r['stopped_at']},走了 {r['approximate_steps']} 步")
def test_find_path_also_reports_unloaded_boundary():
"""★ find_path 未到达时也要给**部分路径** + 如实原因。"""
ws = scene(flat, chunks=((-1, 0), (0, 0)))
r = pf.find_path(ws, x=200, y=0, z=0, max_radius=64)
assert r["reached"] is False, r
assert r["stopped_because"] == "hit_unloaded", r["stopped_because"]
assert r["steps"] > 0, "应给走到边界的部分路径"
assert r["stopped_at"] is not None
print(f" ★ find_path 未到达:给 {r['steps']} 步部分路径 + 原因")
def test_unloaded_is_not_treated_as_open():
"""★★ 未加载区域绝不能被当作「空旷可走」(那会规划出根本不存在的路)。"""
ws = scene(flat, chunks=((-1, 0), (0, 0)))
t = pf.Terrain(ws)
# 未加载的格子:所有判定都必须回 None(不知道),不是 True/False
assert t.known(40, 0, 0) is False
assert t.is_ground(40, 0, 0) is None, "未加载不该说 True/False"
assert t.is_passable(40, 0, 0) is None
assert t.is_standable(40, 0, 0) is None
# 且 neighbors 不会生成通往上方的移动
moves = list(pf.neighbors(t, (15, 0, 0), True))
assert not [m for m, _ in moves if m[0] > 15], \
f"不该越过未加载边界:{[m for m,_ in moves if m[0] > 15]}"
print(" ★★ 未加载格:所有判定回 None,不当空旷")
# ---------- ④ 诚实性:无法判断时明说 ----------
def test_unrecognized_state_blocks_rather_than_guesses():
"""★ 用缺残注册表时,未知方块**不许**被当成空气当路走。"""
tiny = BlockRegistry([{"name": "stone", "minStateId": STONE, "maxStateId": STONE}])
ws = WorldState(registry=tiny, num_sections=24)
for cx, cz in ((0, 0),):
ws.world.chunks[(cx, cz)] = _Chunk(
lambda x, y, z: GRASS if y < 0 else 99999) # 99999 = 认不出的 id
ws.player.x, ws.player.y, ws.player.z = 0.5, 0.0, 0.5
t = pf.Terrain(ws)
# 认不出的 id:is_ground 走 registry.is_solid → None(不知道)
assert t.is_ground(1, 0, 0) is None, \
f"认不出的方块必须回 None(不知道),实得 {t.is_ground(1, 0, 0)}"
print(" ★ 认不出的方块 → 不知道(None),不当成路")
def test_missing_registry_reports_unknown():
"""★ 没有注册表时所有判定回 None(而不是崩或猜)。"""
ws = WorldState(registry=None, num_sections=24)
ws.world.chunks[(0, 0)] = _Chunk(flat)
t = pf.Terrain(ws)
assert t.is_ground(1, 0, 0) is None
assert t.is_passable(1, 1, 0) is None
print(" ✓ 无注册表:回 None(不知道)")
def test_goal_inside_wall_reports_failure():
"""★ 目标本身是石头 ⇒ 走不到,且**不该**假装成功。"""
def wall(x, y, z):
if x >= 5 and 0 <= y <= 2:
return STONE
return GRASS if y < 0 else AIR
ws = scene(wall)
r = pf.find_path(ws, x=8, y=0, z=0)
assert r["reached"] is False, f"目标在墙内应失败:{r['reached']}"
assert r["truncated"] or r["stopped_because"]
print(f" ★ 目标在墙内:明确失败({r.get('stopped_because')})")
# ---------- ⑤ 代价与上限 ----------
def test_node_limit_is_reported():
"""★ 搜索超上限要**明确说**,不是静默给半条路。"""
def maze(x, y, z):
# 交错迷宫,制造爆炸式扩展
if y < 0:
return GRASS
if x > 0 and (x + z) % 2 == 0:
return STONE
return AIR
ws = scene(maze)
r = pf.find_path(ws, x=12, y=0, z=12, max_nodes=200)
if not r["reached"]:
assert r["truncated"] is True or r["stopped_because"] == "node_limit", r
print(" ★ 超节点上限:明确报告 truncated")
else:
print(f" ✓ 迷宫也找到了({r['steps']} 步)")
def test_collision_caveat_is_stated():
"""★ 碰撞粒度有限这个限制**必须**告知调用方。"""
ws = scene(flat)
r = pf.find_path(ws, x=10, y=0, z=0)
assert r["collision_caveat"], "缺少碰撞局限说明"
assert "半砖" in r["collision_caveat"] or "两档" in r["collision_caveat"]
print(" ★ 碰撞局限已如实告知")
def test_reachable_default_does_not_change_world():
"""★ `reachable` 默认 `allow_world_change=False`
⇒ 答的是「**现有地形**能不能过」,不假设可以挖。"""
def wall(x, y, z):
return GRASS if y < 0 else (STONE if (x == 5 and 0 <= y <= 2) else AIR)
ws = scene(wall)
a = pf.reachable(ws, x=10, y=0, z=0) # 默认
b = pf.reachable(ws, x=10, y=0, z=0, allow_world_change=True)
assert a["without_changing_world"] is True
# 现有地形过不去,但允许改世界就能过 ← 这正是两者的分界
assert a["reachable"] is False, a
assert b["reachable"] is True, b
# note 要指出「去改世界请用 find_path」或「数据不够」——
# 两者都合格(取决于先撞上墙还是先撞上未加载边界)。
note = a["note"]
assert ("find_path" in note or "\u6316\u5f00" in note or "\u672a\u52a0\u8f7d" in note), note
# 反向对照:两个工具的结论确实不同(没退化成同一个东西)
assert a["reachable"] != b["reachable"]
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,
test_flat_needs_no_world_change,
test_single_block_uses_step_up_not_dig,
test_tall_wall_is_dug_one_column,
test_pit_is_bridged_not_drowned,
test_lava_is_never_stepped_into,
test_solid_but_deadly_is_excluded,
test_water_also_not_ground,
test_bedrock_cannot_be_dug,
test_unloaded_reports_boundary_not_terrain_blocked,
test_find_path_also_reports_unloaded_boundary,
test_unloaded_is_not_treated_as_open,
test_unrecognized_state_blocks_rather_than_guesses,
test_missing_registry_reports_unknown,
test_goal_inside_wall_reports_failure,
test_node_limit_is_reported,
test_collision_caveat_is_stated,
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寻路:全部通过")