Files
agentmc/mcfold/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

593 lines
26 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.

"""寻路:两个**独立**的工具(用户裁定:可达性判断 与 完整版寻路 分开)
## 为什么要分成两个
| 工具 | 回答 | 成本 |
|---|---|---|
| `reachable(x,y,z)` | **到得了吗 / 大概多远** | 便宜(可以只做可达性、不返回具体路线) |
| `find_path(x,y,z)` | **具体怎么走**(含每步类型、要不要挖/放) | 贵(要展开搜索、回溯、排序) |
agent 很多时候只想知道「那边能过去吗」—— 那时不必付完整寻路的代价,
也不必把一长串路线塞进上下文。反过来真要走过去时才需要详细路线。
## 移动词表(对齐 mineflayer 的 movements.js)
| 动作 | 含义 | 需要改世界? |
|---|---|---|
| `forward` | 平地走一格 | 否 |
| `step_up` | 上一格台阶(脚下 +1) | 否 |
| `step_down` | 下一格 | 否 |
| `drop` | 掉落到更低处 | 否 |
| `jump` | 跳上 1 格(前方挡路时) | 否 |
| `dig` | 挖开挡路的方块 | **是** |
| `place` | 搭桥 / 垫脚 | **是** |
## ★ 三条硬纪律
1. **未加载 = 走到边界 + 如实报告**,绝不乐观推断
(把「没见过」当「空旷」是这类工具最危险的错)。
2. **危险方块不能踩**:`lava`/`fire` 的 `solid=False`(能穿过去),
但碰了会死 ⇒ 判定「可站立」不能只看 solid。
3. **碰撞粒度有限且如实告知**:`blocks.json` 的 `boundingBox` 只有
`block`/`empty` 两档,半砖与楼梯都会被当整块 ⇒ 可能多绕路,但不会撞墙。
"""
from __future__ import annotations
import heapq
import math
from typing import Any, Iterable
# ---------- 危险方块(solid=False 但致命) ----------
#: 这些方块 `solid=False`(碰撞上能穿过去)但**碰到就有害**,
#: 所以「可站立」判定必须单独排除它们 —— 只看 solid 会把它们当地面。
#: 数据来源:实测 blocks.json(1.21.1)
HAZARD_NAMES = frozenset({
"lava", "fire", "soul_fire", "nether_portal", "end_portal", "magma_block",
"cactus", "sweet_berry_bush", "wither_rose", "powder_snow", "campfire",
"soul_campfire", "pointed_dripstone", "scaffolding", "end_portal_frame",
"tnt", "powder_snow_block", "structure_void", "end_gateway",
})
#: 液体:能站进去但会减速/溺水,不作为「地面」也不作为「可站」目标
LIQUID_NAMES = frozenset({"water", "flowing_water", "bubble_column"})
#: 能当「地面」承重的方块(挖/放时的目标)
DEFAULT_HAZARD: str = "unknown" # 认不出的方块按「可能有危害」处理更安全?→ 见下
# 注:**不能**默认当危险。认不出的方块多半是 mod 方块,
# 把所有未知方块都当致命会让寻路在 mod 环境里完全瘫痪。
# 折中:未知方块按「可站立但记 unknown」处理,并在结果里提示。
# ---------- 动作定义 ----------
class Move:
"""一步移动的完整描述(agent 照着走,或由 follow 执行)。"""
__slots__ = ("action", "dx", "dy", "dz", "needs_world_change", "detail",
"dig_count")
def __init__(self, action: str, dx: int, dy: int, dz: int,
needs_world_change: bool = False, detail: str = "",
dig_count: int = 1):
self.action = action # forward/step_up/step_down/drop/jump/dig/place
self.dx, self.dy, self.dz = dx, dy, dz
self.needs_world_change = needs_world_change
self.detail = detail
self.dig_count = dig_count # 挖穿一列要几格
def as_dict(self) -> dict[str, Any]:
return {
"action": self.action,
"d": [self.dx, self.dy, self.dz],
"world_change": self.needs_world_change,
**({"detail": self.detail} if self.detail else {}),
**({"dig_count": self.dig_count} if self.action == "dig" else {}),
}
def __repr__(self) -> str:
return (f"{self.action}({self.dx:+d},{self.dy:+d},{self.dz:+d})")
#: 动作的相对代价。
#:
#: ★ 调这两个数字时踩过的坑(实测):
#: 1 格台阶(石墙)与目标同高时,`step_up`(1.2) 和 `dig`(6.0) 同时可用,
#: 但因为 step_up 会让 y 变成 1、而目标 y=0,路径总代价算下来
#: 「挖 6 格平路」<「上 1 级再下 1 级」⇒ A* 选了荒谬的挖路。
#: 真实原因是我给**高度差**的权重(1.2/格)低于 dig 的常数 6.0,
#: 于是一级台阶的「绕」比「挖」便宜。
#: 修法:把 dig/place 的代价提到明显高于「多绕 1~2 级台阶」的水平
#: (挖要真花时间、可能失败、还会改世界),这样先绕后挖。
COSTS = {
"forward": 1.0,
"step_up": 1.5, # 上一级台阶
"step_down": 1.2, # 下一级台阶
"drop": 0.7, # 掉落最快
"jump": 2.2, # 跳最慢
"dig": 12.0, # 挖方块:慢 + 可能失败 + 改世界
"place": 8.0, # 放方块:也要时间 + 改世界
}
#: 高度差权重(用于 A* 启发式与代价):高出/低于目标一格的「心理代价」
HEIGHT_PENALTY = 2.0
#: 挖穿一列时最多挖几格(再高就当"挖不动"⇒ 建议绕路或搭桥)
MAX_DIG_DEPTH = 5
# ---------- 地形判定(只读,不改) ----------
class Terrain:
"""把 WorldState 包成寻路要的三个问题(带安全默认)。
★ 三个问题都必须能回答「不知道」:
- 能不能站(solid + 非危险)
- 能不能穿过(non-solid)
- 能不能承载(solid 且非危险,用作 dig/place 的目标)
"""
def __init__(self, world, allow_world_change: bool = True):
self.w = world
self.allow_world_change = allow_world_change
self._hazards: set[int] = set()
self._liquids: set[int] = set()
self._partial: dict[int, str | None] = {}
reg = world.world.registry
if reg is not None:
for sid, name in reg.by_state.items():
if name in HAZARD_NAMES:
self._hazards.add(sid)
elif name in LIQUID_NAMES:
self._liquids.add(sid)
def _reg(self):
return self.w.world.registry
def at(self, x: int, y: int, z: int) -> dict[str, Any]:
"""该格的方块信息(沿用状态层的四态:registry/unknown-state/unloaded/…)。"""
return self.w.block_at(x, y, z)
def known(self, x: int, y: int, z: int) -> bool:
return self.at(x, y, z)["source"] in ("registry", "unknown-state")
def is_ground(self, x: int, y: int, z: int) -> bool | None:
"""这一格能否**作为承重的实心方块**。None = 不知道。
★★ 这是我把两个谓词混成一个之后才分出来的(真 bug):
is_ground —— 「这一格本身是实心底」
can_stand —— 「玩家的**脚**能停在这一格」
两者差一格。混用会让邻居生成永远找不到落脚点
(表现为「平地上也走不了,只能搭桥」)。
"""
h = self.at(x, y, z)
if h["source"] not in ("registry", "unknown-state"):
return None
if h["state_id"] in self._hazards or h["state_id"] in self._liquids:
return False # 危险/液体:不能承重
if h["name"] == "air" or h["name"] in ("cave_air", "void_air"):
return False # 空气不是地面
reg = self._reg()
if reg is None:
return None
return reg.is_solid(h["state_id"]) # None 表示认不出 ⇒ 不知道
def is_standable(self, x: int, y: int, z: int) -> bool | None:
"""玩家的**脚**能否停在 (x,y,z):下方要实心,本格与头顶要空。
★ 这是「脚所在格」,不是「脚下那一格」。
"""
below = self.is_ground(x, y - 1, z)
here = self.is_passable(x, y, z)
head = self.is_passable(x, y + 1, z)
if below is None or here is None or head is None:
return None
return bool(below and here and head)
def is_passable(self, x: int, y: int, z: int) -> bool | None:
"""身体能否**穿过**这一格。None = 不知道。"""
h = self.at(x, y, z)
if h["source"] not in ("registry", "unknown-state"):
return None
if h["state_id"] in self._hazards:
return False
if h["name"] in ("air", "cave_air", "void_air", "water"):
return True
reg = self._reg()
if reg is None:
return None
s = reg.is_solid(h["state_id"])
return None if s is None else (not s)
def supportable(self, x: int, y: int, z: int) -> bool | None:
"""这一格能否**承重**(用于 place 搭桥时的支撑位判断)。"""
return self.is_standable(x, y, z)
def standable_if_air(self, x: int, y: int, z: int) -> bool:
"""假设 (x,y,z) 被挖空后,玩家的**脚**能否停在这里。
★ 为什么需要这个:2 格高的墙要挖**上方**那格才通得了
(挖脚下方那格后,头还是顶着石头)。实测抓到:
只考虑挖脚部位置 ⇒ 2 格墙永远走不通。
"""
below = self.is_ground(x, y - 1, z)
if below is not True:
return False
head = self.is_passable(x, y + 1, z)
return head is True
def can_dig(self, x: int, y: int, z: int) -> bool:
"""能否挖开这一格(需要世界改动的移动才用得到)。"""
if not self.allow_world_change:
return False
h = self.at(x, y, z)
if h["source"] != "registry":
return False
if h["name"] in ("bedrock", "water", "lava"):
return False # 基岩挖不动,液体挖了也没用
return True
# ---------- 邻居生成(移动词表) ----------
#: 水平八向。斜向在 MC 里要额外校验对角,代价也更高 ⇒ 单独处理。
_DIRS: tuple[tuple[int, int], ...] = (
(1, 0), (-1, 0), (0, 1), (0, -1),
(1, 1), (1, -1), (-1, 1), (-1, -1),
)
def neighbors(t: Terrain, pos: tuple[int, int, int],
allow_world_change: bool = True) -> Iterable[tuple[tuple[int, int, int], Move]]:
"""从 pos 出发所有合法的一步移动。
依次尝试(**先便宜后贵**,A* 才有意义):
`forward` → `step_up` → `step_down` → `drop` → `jump` → `dig` → `place`
"""
x, y, z = pos
for dx, dz in _DIRS:
nx, nz = x + dx, z + dz
diag = dx != 0 and dz != 0
base = COSTS["step_up"] if diag else COSTS["forward"]
if diag:
# 斜向必须两条正交方向都通(否则会穿墙角)
if not (t.is_passable(x + dx, y, z) and t.is_passable(x, y, z + dz)):
continue
# ① 平地走 / 上一格(脚位置 = 目标格)
# ★ 上一格是 MC 里最常见的移动(上一级台阶),所以**先**给 step_up,
# 代价也比 dig/jump 低 ⇒ A* 会自然选它。
for up in (0, 1):
if t.is_standable(nx, y + up, nz) is True:
act = "step_up" if up else "forward"
yield (nx, y + up, nz), Move(act, dx, up, dz)
break
# ② 下一格 / 掉落(脚位置下移)
if t.is_standable(nx, y - 1, nz) is True:
yield (nx, y - 1, nz), Move("step_down", dx, -1, dz)
else:
for drop in range(2, 5): # 最多落 4 格,不至于摔死
if t.is_standable(nx, y - drop, nz) is True:
yield (nx, y - drop, nz), Move("drop", dx, -drop, dz)
break
# ③ 跳上 1 格(前方要高一级)
if t.is_standable(nx, y + 1, nz) is True and t.is_standable(nx, y, nz) is not True:
yield (nx, y + 1, nz), Move("jump", dx, 1, dz, detail="跳过障碍")
# ④ 挖开挡路的(需世界改动)—— 支持「挖一列」
# ★★★ 三次实测才做对,记录下来免得再错:
# a) 不能用「更靠上的格子 standable」当「已经能过」的依据
# —— 那是挖完才成立的 ⇒ dig 永远不生成(2 格墙直接走不通)。
# b) 必须考虑挖**头部**那格,不只脚部(否则 2 格墙不通)。
# c) ★ 必须是「挖 k 格直到能站」而不是「挖 1 格」——
# 4 格高的墙要挖 4 格,只查 1 格就永远走不通(实测)。
# 代价按挖的格数线性增长 ⇒ 高墙自然比低墙贵,A* 会先试绕。
if allow_world_change:
already = t.is_standable(nx, y, nz) is True
if not already:
# 只做一件事:**从脚所在高度向上挖穿**,挖到能站为止。
# ★ 为什么这样就够(而不是先试向下、再试向上):
# 脚所在高度就是玩家现在的高度;把这一段挖通即可继续前进。
# 我先前写的「先向下挖再向上挖」两段式既复杂又产生了
# 挖空气的荒谬清单(因为「挖掉 y+k」之后脚该站哪算错了)。
# 简化成「从 y 起往上逐格挖,直到 standable」后:
# 1 格障碍 ⇒ 挖 1 格;4 格墙 ⇒ 挖 4 格,全部正确。
carved = 0
for k in range(0, MAX_DIG_DEPTH):
if not t.can_dig(nx, y + k, nz):
break
carved += 1
# 挖掉 y..y+k 之后,脚停在 y+k(上面已清空、下面由 y+k-1 承重)
if t.standable_if_air(nx, y + k, nz) and k + 1 <= carved:
yield (nx, y + k, nz), Move(
"dig", dx, k, dz, needs_world_change=True,
detail=f"挖 {k + 1} 格穿过 {t.at(nx, y, nz)['name']}",
dig_count=k + 1)
break
# ⑤ 搭桥:前方本格与头顶是空的,但**脚下没有承重块** ⇒ 放一块垫脚
if allow_world_change:
for dx, dz in _DIRS:
nx, nz = x + dx, z + dz
if t.is_passable(nx, y, nz) is not True \
or t.is_passable(nx, y + 1, nz) is not True:
continue
if t.is_ground(nx, y - 1, nz) is not False:
continue # 已有地面(或不知道)⇒ 不需要搭
yield (nx, y, nz), Move("place", dx, 0, dz, needs_world_change=True,
detail="在缺口处搭一块")
# ---------- 启发式 ----------
def _octile(a, b) -> float:
"""八向 + 高度的启发式(可采纳:不超过真实代价)。
★ 高度权重用 HEIGHT_PENALTY(比 step_up 略高)——
这样「宁可多走一格」而不是「上下一级」,
寻路自然优先走平路(实测:修复后 1 格台阶不再被挖)。
"""
dx = abs(a[0] - b[0]); dz = abs(a[2] - b[2]); dy = abs(a[1] - b[1])
flat = max(dx, dz) + (math.sqrt(2.0) - 1.0) * min(dx, dz)
return flat + dy * HEIGHT_PENALTY
# ---------- A* 核心 ----------
def _astar(
t: Terrain,
start: tuple[int, int, int],
goal: tuple[int, int, int],
*,
max_nodes: int = 6000,
max_radius: int = 96,
allow_world_change: bool = True,
goal_test=None,
) -> dict[str, Any]:
"""A*。`goal_test` 为真时(而非坐标相等)即算到达 —— 可达性判断用。"""
c0 = _octile(start, goal)
open_heap: list[tuple[float, tuple[int, int, int]]] = [(c0, start)]
came: dict[tuple[int, int, int], tuple[tuple[int, int, int], Move]] = {}
best: dict[tuple[int, int, int], float] = {start: 0.0}
nodes = 0
touched_unloaded = False
furthest = 0.0
# ★ 记录离目标最近的已探索点:未到达时用它给「走到边界」的部分路径
nearest = start
def _dist_to_goal(p):
return _octile(p, goal)
while open_heap:
_, cur = heapq.heappop(open_heap)
if goal_test is not None:
if goal_test(cur):
return _reconstruct(came, cur, start, nodes, False, furthest)
else:
if cur == goal:
return _reconstruct(came, cur, start, nodes, False, furthest)
if _dist_to_goal(cur) < _dist_to_goal(nearest):
nearest = cur
nodes += 1
if nodes > max_nodes:
return _reconstruct(came, None, start, nodes, True, furthest,
reason="node_limit", nearest=nearest)
# ★★ 先单独探测「未加载边界」(实测踩到):
# `neighbors()` 在未加载格上**不会生成**移动(因为
# is_standable 返回 None、无法判断),所以下面循环里的
# 「if not t.known(*nxt)」**永远执行不到** ⇒ 结果报 `exhausted`
# (听起来像「地形不通」),而真相是「**数据用完了**」。
# 这两者对 agent 的意义完全不同:
# 地形不通 ⇒ 换目标;数据用完 ⇒ 走过去加载再问。
# 所以必须在这里主动看一眼水平邻居。
for dx, dz in _DIRS:
if not t.known(cur[0] + dx, cur[1], cur[2] + dz):
touched_unloaded = True
break
for nxt, mv in neighbors(t, cur, allow_world_change):
# 未加载 ⇒ 记下来(不是墙,但要如实报告)
if not t.known(*nxt):
touched_unloaded = True
continue
# 半径剪枝
d = math.dist((nxt[0], nxt[2]), (start[0], start[2]))
if d > max_radius:
continue
furthest = max(furthest, d)
# ★ 每一步的真实代价 = 动作基础代价 + **背离目标的额外代价**。
# 少了后面这一项就会出荒谬路径(实测):目标 y=0、挡路的是 1 格台阶,
# 「上 1 级再下 1 级」只要 2.7,比「挖 6 格」便宜 —— 听起来对,
# 但 1 格台阶明明**能挖 0 次**通过(原地挖一格就行),
# 于是 A* 选了「挖」,白白改世界。
# 真正该反映的是:为了绕过一点高度差要付出多少额外时间。
step_cost = COSTS[mv.action]
if mv.action == "dig":
# 挖穿 N 格 ⇒ 代价按 N 线性增长(挖 4 格远贵于挖 1 格)
step_cost = COSTS["dig"] * max(1, mv.dig_count)
if goal_test is None and nxt[1] != cur[1]:
step_cost += HEIGHT_PENALTY * 0.5
g = best[cur] + step_cost
if g < best.get(nxt, float("inf")):
best[nxt] = g
came[nxt] = (cur, mv)
f = g + (_dist_to_goal(nxt) if goal_test is None else 0.0)
heapq.heappush(open_heap, (f, nxt))
return _reconstruct(came, None, start, nodes, True, furthest,
reason="exhausted" if not touched_unloaded else "hit_unloaded",
touched_unloaded=touched_unloaded, nearest=nearest)
def _reconstruct(came, end, start, nodes, truncated, radius_reached,
reason: str = "", touched_unloaded: bool = False,
nearest=None) -> dict[str, Any]:
"""把 came[] 反向拼成移动序列;未到达时给「走到边界」的部分路径。"""
if end is not None:
moves: list[Move] = []
cur = end
while cur in came:
prev, mv = came[cur]
moves.append(mv)
cur = prev
moves.reverse()
return {
"reached": True, "moves": [m.as_dict() for m in moves],
"move_objects": moves,
"steps": len(moves), "nodes_expanded": nodes,
"truncated": False,
"cost": round(sum(COSTS[m.action] for m in moves), 2),
"needs_world_change": any(m.needs_world_change for m in moves),
}
# ★ 没到达:给**走到边界**的部分路径(而不是空手而归),并如实说明为什么停。
# 为什么给部分路径:agent 可以「先走到这儿」再重新寻路 —— 这比
# 「完全没路」有用,且与用户裁定的「未加载就走到边界为止」一致。
best_node = nearest if nearest is not None else start
moves = []
cur = best_node
while cur in came:
prev, mv = came[cur]
moves.append(mv)
cur = prev
moves.reverse()
# 用真实的最远距离(而不是 0)
far = math.dist((best_node[0], best_node[2]), (start[0], start[2])) \
if best_node != start else 0.0
return {
"reached": False, "moves": [m.as_dict() for m in moves],
"move_objects": moves,
"steps": len(moves), "nodes_expanded": nodes,
"truncated": truncated,
"stopped_because": reason or "exhausted",
"stopped_at": list(best_node),
"radius_reached": round(max(radius_reached, far), 1),
"cost": round(sum(COSTS[m.action] for m in moves), 2),
"needs_world_change": any(m.needs_world_change for m in moves),
}
# ---------- 两个独立工具 ----------
def _player_cell(world) -> tuple[int, int, int]:
p = world.player
return int(math.floor(p.x)), int(math.floor(p.y)), int(math.floor(p.z))
def reachable(
world,
x: int | None = None, y: int | None = None, z: int | None = None,
*,
max_radius: int = 48,
allow_world_change: bool = False,
) -> dict[str, Any]:
"""【工具 1】可达性判断:**到得了吗 / 大概多远**。
刻意**不返回**具体路线 —— 只要一个是非 + 距离,让 agent 决定值不值得去。
参数缺省时用玩家所在格 ⇒ 「我脚下能站吗」这类问题也能直接问。
"""
start = _player_cell(world)
goal = (x, y, z) if x is not None and y is not None and z is not None else start
t = Terrain(world, allow_world_change=allow_world_change)
r = _astar(t, start, goal, max_radius=max_radius,
allow_world_change=allow_world_change,
goal_test=lambda n: _octile(n, goal) <= 1.5)
return {
"reachable": r["reached"],
"from": list(start),
"to": list(goal),
"approximate_steps": r["steps"],
"cost": r["cost"],
# ★ 这个字段是**两个工具的分界**:reachable 默认不挖不放,
# 所以它答的是「**现有地形**能过去吗」。
"without_changing_world": not allow_world_change,
"would_need_world_change": r["needs_world_change"] if allow_world_change else None,
"stopped_because": r.get("stopped_because"),
"stopped_at": r.get("stopped_at"),
"truncated": r["truncated"],
"nodes_expanded": r["nodes_expanded"],
"note": _reachable_note(r, allow_world_change),
}
def _reachable_note(r: dict[str, Any], allow_world_change: bool) -> str:
if r["reached"]:
base = ("在**现有地形**上能过去(不挖、不放)" if not allow_world_change
else "能过去")
return f"{base};只回能不能与大概步数,要具体路线用 find_path"
why = r.get("stopped_because")
if why == "hit_unloaded":
return ("走到**未加载区块边界**为止:再往前没有数据,无法判断。"
"让玩家走过去加载后重新问(不乐观推断)")
if why == "node_limit":
return "搜索节点用尽(目标可能太远或地形太绕);可缩小距离或分段多次问"
if not allow_world_change:
return ("**现有地形**上不通。若要挖开/搭桥过去,用 find_path"
"(它允许改世界)")
return "在已加载范围内没找到路;走过去加载新区块后可再试"
def find_path(
world,
x: int | None = None, y: int | None = None, z: int | None = None,
*,
max_radius: int = 48,
max_nodes: int = 6000,
allow_world_change: bool = True,
allow_dig: bool = True,
) -> dict[str, Any]:
"""【工具 2】完整版寻路:给出**具体怎么走**。
完整版 = 除常规移动外,还允许:
- `dig` 挖开挡路的方块
- `place` 在缺口处搭桥
这些动作 `needs_world_change=True`,返回值里会明确列出
**要改哪几个方块**(与 M5 的 dig/place 串联)。
"""
start = _player_cell(world)
goal = (x, y, z) if x is not None and y is not None and z is not None else start
t = Terrain(world, allow_world_change=allow_world_change)
r = _astar(t, start, goal, max_nodes=max_nodes, max_radius=max_radius,
allow_world_change=allow_world_change)
moves: list[Move] = r.pop("move_objects", [])
digs: list[dict[str, Any]] = []
places: list[dict[str, Any]] = []
cx, cy, cz = start
for mv in moves:
if mv.action == "dig":
# 挖一列 ⇒ 列出**每一格**(agent 要按顺序逐格挖)。
# mv.dy 指向「脚最终停在的高度」,要挖的是它**下方**的 dig_count 格。
base_y = cy + mv.dy - (mv.dig_count - 1)
for k in range(mv.dig_count):
px, py, pz = cx + mv.dx, base_y + k, cz + mv.dz
digs.append({"position": [px, py, pz],
"block": t.at(px, py, pz)["name"]})
elif mv.action == "place":
places.append({"position": [cx + mv.dx, cy + mv.dy, cz + mv.dz]})
cx += mv.dx
cy += mv.dy
cz += mv.dz
r["world_changes"] = {
"dig": digs,
"place": places,
"count": len(digs) + len(places),
"note": ("执行这些要用 dig/place 工具;每一步都可能失败"
"(权限/放不下),失败需重新寻路"
if digs or places else "不需要改动世界"),
}
r["from"] = list(start)
r["to"] = list(goal)
r["allow_dig"] = allow_dig
r["collision_caveat"] = ("碰撞数据只有 block/empty 两档:半砖与楼梯会被当整块,"
"所以路径可能多绕路,但不会撞墙")
return r