Files
agentmc/tests/test_pathfind.py
JianFeeeee 93243165bf 寻路:reachable 与 find_path 两个独立工具(完整版 + 可达性判断)
用户裁定:完整版寻路 + 可达性判断,且这两个是**两个独立工具**;
未加载走到边界为止并如实报告;执行方式两者都给。

新增 mcfold/pathfind.py:
- 移动词表对齐 mineflayer movements.js:forward/step_up/step_down/drop/jump/dig/place
- A*(八向 + 高度),可按「节点数/半径」有界
- reachable:只答「能不能到 + 大概多远」,**不给路线**,默认不挖不放
- find_path:给具体路线 + world_changes(要挖/放哪些方块,逐格列出)

修的真 bug(6 个):
1. is_ground(这格是实心底)与 is_standable(脚能停这格)语义混淆,差一格
   → 混用导致平地找不到落脚点,只肯搭桥
2. can_already_pass 判据用错对象(拿「更上方 standable」当「已能通过」,
   而那是挖完才成立)→ dig 永不生成,2 格墙走不通
3. dig 只支持挖 1 格 → 4 格墙永远不通。改「挖穿一列」,代价按格数线性
4. 代价权重失衡 → 1 格台阶被挖而不是 step_up(挖穿一排墙)
   调高 dig/place 代价 + 加「背离目标的额外代价」后修正
5. ★ 未加载被报成 exhausted(听起来像「地形不通」):根因是 neighbors()
   在未加载格不生成移动 ⇒ A* 的未加载检测永远执行不到
   → 改为主动探测边界,现在报 hit_unloaded + 停在边界 + 已走步数
6. Terrain.known 调用签名错;挖清单会含空气

诚实性(用户最在意的一条):
- 未加载格所有判定回 None,**不当成空旷**
- 岩浆/水不当地面;solid=True 但致命的(仙人掌/岩浆块/篝火)也被排除
- 认不出的方块 → 不知道(None),不猜
- 碰撞粒度只有 block/empty 两档这个限制**写在返回值里**

判据 19 项;5 项变异全被抓。
★ 「岩浆当地面」变异第一次**没被抓** —— 查下去发现我的判据测的是冗余代码
  (lava/water 本来 solid=False,去掉 hazard 检查也不会红);
  真正承载语义的是 solid=True 但致命的那批方块 → 改用仙人掌才抓到。
2026-09-26 13:42:32 +08:00

401 lines
17 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
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(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")
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]:
fn()
print("\n寻路:全部通过")