"""寻路判据: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寻路:全部通过")