用户要求: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 项全通过。
542 lines
24 KiB
Python
542 lines
24 KiB
Python
"""寻路判据: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寻路:全部通过")
|