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

623 lines
27 KiB
Python
Raw Permalink 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 # 空气不是地面
# ★★ 先问**运行时缓存**(含 mod 方块),再退回本机注册表。
# 实测依据:本机 blocks.json 只有 1060 个 vanilla 方块,
# Create 的 643 个里 613 个认不出(覆盖率 4.7%)
# ⇒ 只能用运行中的官方注册表(M8 的 blockinfo 已在读它)。
sid = h["state_id"]
cache = getattr(self.w, "solid_cache", None)
if cache:
hit = cache.get(sid)
if hit is not None:
v = hit.get("isSolid")
return None if v is None else bool(v)
reg = self._reg()
if reg is None:
return None
return reg.is_solid(sid) # 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:
"""能否挖开这一格(需要世界改动的移动才用得到)。
★ 判据用**破坏速度**(运行时注册表给的 destroySpeed),
不靠名字猜「基岩挖不动」:
mod 方块里可能有各种不可破坏/极硬的方块,靠猜会规划出挖不动的路。
基岩 destroySpeed = -1(官方约定:负值 = 不可破坏)。
"""
if not self.allow_world_change:
return False
h = self.at(x, y, z)
if h["source"] not in ("registry", "unknown-state"):
return False
if h["name"] in ("water", "lava", "void_air", "end_portal"):
return False # 液体/传送门挖了也没用
sid = h["state_id"]
cache = getattr(self.w, "solid_cache", None)
if cache:
hit = cache.get(sid)
if hit is not None and hit.get("destroySpeed") is not None:
return float(hit["destroySpeed"]) > 0
reg = self._reg()
if reg is not None:
b = reg.by_name.get(h["name"] or "")
if b is not None and b.get("hardness") is not None:
return float(b["hardness"]) > 0
if h["name"] in ("bedrock",):
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