寻路: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 但致命的那批方块 → 改用仙人掌才抓到。
This commit is contained in:
@ -99,6 +99,22 @@ TOOLS: dict[str, dict[str, Any]] = {
|
||||
"params": {"radius": "float,默认 16", "limit": "int,默认 20"},
|
||||
"returns": "entities/count",
|
||||
},
|
||||
"reachable": {
|
||||
"summary": "【工具1】可达性判断:到得了吗 / 大概多远(不给路线)",
|
||||
"params": {"x": "int,缺省=当前格", "y": "int,缺省=当前格",
|
||||
"z": "int,缺省=当前格",
|
||||
"max_radius": "int,默认 48",
|
||||
"allow_world_change": "bool,默认 False ⇒ 只答「现有地形」能不能过"},
|
||||
"returns": "reachable/approximate_steps/cost/without_changing_world/stopped_because",
|
||||
},
|
||||
"find_path": {
|
||||
"summary": "【工具2】完整版寻路:具体怎么走(含挖开/搭桥)",
|
||||
"params": {"x": "int,缺省=当前格", "y": "int,缺省=当前格",
|
||||
"z": "int,缺省=当前格",
|
||||
"max_radius": "int,默认 48",
|
||||
"allow_world_change": "bool,默认 True(允许挖/放)"},
|
||||
"returns": "reached/moves/world_changes(要挖/放哪些方块)/collision_caveat",
|
||||
},
|
||||
"inventory": {
|
||||
"summary": "我手上/背包里有什么",
|
||||
"params": {},
|
||||
@ -166,6 +182,12 @@ class Tools:
|
||||
"out-of-world": "射线出界",
|
||||
"solid": "撞到东西",
|
||||
},
|
||||
"寻路两个工具的分工": {
|
||||
"reachable": "只答「能不能到 + 大概多远」,默认不挖不放(现有地形)",
|
||||
"find_path": "给具体路线,允许 dig/place;world_changes 列出要改的方块",
|
||||
"未加载": "走到边界为止并如实报告 —— 绝不把「没见过」当「空旷」",
|
||||
"碰撞局限": "数据只有 block/empty 两档,半砖/楼梯当整块 ⇒ 可能绕路但不撞墙",
|
||||
},
|
||||
"can_see reason": {
|
||||
"clear": "看得见",
|
||||
"blocked": "被挡(by/at 说明被谁挡)",
|
||||
@ -339,6 +361,54 @@ class Tools:
|
||||
f"热键栏 {len(hotbar)} 格有东西"),
|
||||
}
|
||||
|
||||
# ---------- 寻路(两个独立工具) ----------
|
||||
|
||||
def reachable(self, *, 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】**可达性判断**:到得了吗、大概多远。**不给路线**。
|
||||
|
||||
★ 与 find_path 分开(用户明确要求):
|
||||
这个便宜、省 token,agent 常常只想知道「那边能过去吗」。
|
||||
默认 `allow_world_change=False` ⇒ 答的是「**现有地形**能不能过」;
|
||||
要「挖开/搭桥过去」请用 find_path。
|
||||
"""
|
||||
from . import pathfind as _pf
|
||||
r = _pf.reachable(self.s.state, x, y, z, max_radius=max_radius,
|
||||
allow_world_change=allow_world_change)
|
||||
r["ok"] = True
|
||||
if r["reachable"]:
|
||||
r["describe"] = (f"能到 ({r['to']}),约 {r['approximate_steps']} 步"
|
||||
+ ("(不需要挖/放)" if not allow_world_change else ""))
|
||||
else:
|
||||
r["describe"] = f"到不了 ({r['to']}):{r['note']}"
|
||||
return r
|
||||
|
||||
def find_path(self, *, x: int | None = None, y: int | None = None,
|
||||
z: int | None = None, max_radius: int = 48,
|
||||
allow_world_change: bool = True) -> dict[str, Any]:
|
||||
"""【工具2】**完整版寻路**:具体怎么走,含需要挖开/搭桥的步骤。
|
||||
|
||||
返回值里 `world_changes` 列出**要改哪些方块**,
|
||||
配合 dig/place 工具执行;`needs_world_change=True` 的步骤可能失败
|
||||
(权限/放不下),失败时要重新寻路。
|
||||
"""
|
||||
from . import pathfind as _pf
|
||||
r = _pf.find_path(self.s.state, x, y, z, max_radius=max_radius,
|
||||
allow_world_change=allow_world_change)
|
||||
r["ok"] = True
|
||||
if r["reached"]:
|
||||
wc = r["world_changes"]
|
||||
r["describe"] = (f"{r['steps']} 步到 ({r['to']}),代价 {r['cost']}"
|
||||
+ (f";需改动 {wc['count']} 个方块"
|
||||
if wc["count"] else ";不需改世界"))
|
||||
else:
|
||||
r["describe"] = (f"未到达({r.get('stopped_because')})"
|
||||
f",已走到 {r.get('stopped_at')},共 {r['steps']} 步")
|
||||
if r["collision_caveat"]:
|
||||
r["caveat"] = r["collision_caveat"]
|
||||
return r
|
||||
|
||||
# ---------- 事件 ----------
|
||||
|
||||
def events(self, *, limit: int = 40, fine_prefix: str | None = None) -> dict[str, Any]:
|
||||
|
||||
592
mcfold/pathfind.py
Normal file
592
mcfold/pathfind.py
Normal file
@ -0,0 +1,592 @@
|
||||
"""寻路:两个**独立**的工具(用户裁定:可达性判断 与 完整版寻路 分开)
|
||||
|
||||
## 为什么要分成两个
|
||||
|
||||
| 工具 | 回答 | 成本 |
|
||||
|---|---|---|
|
||||
| `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
|
||||
44
plan.md
44
plan.md
@ -786,6 +786,50 @@ dig(x=..,y=..,z=..) ok=True → events 收到 block_changed
|
||||
⇒ **恢复文件必须用 git(已知正确版本)**,且**每个变异单独确认基线是绿的**。
|
||||
修好基线后重做该变异,才确认判据真的有效。
|
||||
|
||||
### 寻路(用户 2026-09-26 追加裁定,编号外的独立一节)
|
||||
|
||||
用户裁定要点:
|
||||
- **完整版**寻路 + **可达性判断**,且这两个是**两个独立工具**
|
||||
- 执行:两者都给(逐步 / 整段)
|
||||
- 未加载:**走到边界为止 + 如实报告**(绝不乐观推断)
|
||||
|
||||
新增 `mcfold/pathfind.py` + API 的 `reachable` / `find_path`(工具数 23→25)。
|
||||
|
||||
**实测(构造场景 + 真服务器)**:
|
||||
```
|
||||
平地 reachable=True find_path 10 步纯 forward 改世界 0
|
||||
1 格障碍 reachable=True step_up/jump 跨过 改世界 0
|
||||
2/3/4 格墙 reachable=False 挖穿一列 2/3/4 格,无空气 改世界 N
|
||||
深坑 3 格 reachable=False place 搭桥 1 块
|
||||
岩浆沟 reachable=False place 搭桥
|
||||
未加载边界 reachable=False stopped_because=hit_unloaded
|
||||
停在 (15,-60,8),走了 19 步 +「再往前没数据」
|
||||
```
|
||||
|
||||
**本轮修的真 bug(6 个)**:
|
||||
1. **`is_ground` 与 `is_standable` 语义混淆**(差一格):
|
||||
混用导致平地上找不到落脚点,寻路只肯搭桥。
|
||||
2. **`can_already_pass` 判据用错对象**(拿「更上方格子 standable」
|
||||
当「已能通过」,而那是挖完才成立的)⇒ dig 永不生成,2 格墙走不通。
|
||||
3. **dig 只支持挖 1 格** ⇒ 4 格墙永远走不通。改为「挖穿一列」,
|
||||
代价按格数线性增长(挖 4 格远贵于挖 1 格 ⇒ A* 先试绕)。
|
||||
4. **代价权重失衡**:1 格台阶被挖而不是 step_up
|
||||
(dig 6.0 vs 上下一级 2.7)⇒ 荒谬地挖穿一排墙。
|
||||
调高 dig/place 代价 + 加「背离目标的额外代价」后修正。
|
||||
5. **未加载被报成 `exhausted`**(听起来像「地形不通」):
|
||||
根因是 `neighbors()` 在未加载格上不生成移动 ⇒ A* 里
|
||||
「if not known(...)」**永远执行不到**。改为主动探测边界。
|
||||
6. `Terrain.known` 调用签名错、挖清单会包含空气(挖到墙顶之上)。
|
||||
|
||||
**判据**:`tests/test_pathfind.py` 19 项;5 项变异全部被抓。
|
||||
★ 其中「岩浆当地面」这个变异**第一次没被抓** —— 查下去发现我的判据
|
||||
测的是**冗余代码**:`lava`/`water` 本来就 `solid=False`,
|
||||
去掉 hazard 检查也不会红。
|
||||
真正需要 hazard 检查的是 **solid=True 但致命**的
|
||||
(`cactus`/`magma_block`/`campfire`/`scaffolding`)⇒ 改用这些方块才抓到。
|
||||
**判据必须打在「真正承载语义的那段代码」上,否则测的是冗余。**
|
||||
|
||||
|
||||
**建议顺序:M0 → M1 → M2**。
|
||||
- M0/M1 是解阻断(M1 是我已经查到根因的那个 classpath bug)
|
||||
- **M2 是真正的门槛**:从「能渲染」到「能玩」,缺的不是渲染,是**完整协议层**。
|
||||
|
||||
400
tests/test_pathfind.py
Normal file
400
tests/test_pathfind.py
Normal file
@ -0,0 +1,400 @@
|
||||
"""寻路判据: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寻路:全部通过")
|
||||
Reference in New Issue
Block a user