Files
agentmc/mcfold/rough.py
JianFeeeee 27b5c448b0 M6:粗渲染/常驻感知层(纯 Python,无 JVM)+ 20 项判据
新增 mcfold/rough.py:agent 日常游玩的感知层
- raycast(Amanatides & Woo 逐格体素遍历,不采样不插值)
- can_see / can_stand / walkable / slice_view / surroundings
- BlockRegistry 扩展 solid/transparent(从 blocks.json 读,不编)

修的真 bug:
- can_see 射到 max_distance ⇒ 越过目标撞身后的墙,把「看得见」误报「被挡」
  → 改为射程只到目标距离
- 切面从脚下往上取 ⇒ 全是空气看不出地形 → 基准下移让地面入画
- walkable 的 ? 漏统计「已加载但认不出名字」的方块

纪律:
- 有界(超范围拒绝并说明,不静默缩小)
- 不撒谎(unloaded / unknown-state / None 三态分明;把「没见过」当「空旷」
  是这层最危险的错)
- 不决策(只给观察,不告诉 agent 该走哪)

判据用构造场景(plan §4.5:粗渲染不需要细渲染参与):
单格薄墙、空走廊、未加载区、缺残注册表。
146 项全通过;6 项变异全被抓(其中一项初次未抓到 ⇒ 补判据覆盖面后才抓住)
2026-09-26 13:13:15 +08:00

434 lines
17 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.

"""M6 粗渲染(ass):agent 的**常驻感知层**。纯 Python,不依赖 JVM。
## 它回答什么(这是它的全部职责)
agent 日常游玩问的问题,全是「感知」类,且必须**廉价**:
1. **这里是什么** —— `describe_at` / `column_profile`
2. **挡不挡视线** —— `raycast`(到那个点看得见吗)
3. **那边是什么** —— `raycast`(我瞄着的地方是什么)
4. **前面走得通吗** —— `walkable` / `step_up`(跨台阶、能不能站上去)
5. **周围有什么** —— `surroundings`(有界统计,不是全量倾倒)
**它不回答**「该不该走」「哪个更好」「这是不是陷阱」—— 那是决策,留给 agent。
## 为什么不复用细渲染
细渲染要起 JVM(20-40s)。这些感知问题 agent 每一步都可能问,
用细渲染回答会拖垮整个循环。粗渲染是**独立的感知手段**,
不是细渲染的降级 —— 两者服务不同问题,**不要求像素级一致**
(见 plan.md §4.5 的验证口径)。
## 三条硬纪律
1. **有界**:所有返回都有上限,剩余部分给计数而非静默截断。
2. **不撒谎**:分不清就说分不清(`unloaded` / `unknown-state` / `None`),
绝不为了让回答好看而猜测。
3. **不决策**:给观察,不给建议。
"""
from __future__ import annotations
import math
from typing import Any
from .state import WorldState
# ---------- 体素遍历(Amanatides & Woo) ----------
def raycast(
world: WorldState,
ox: float, oy: float, oz: float,
dx: float, dy: float, dz: float,
max_distance: float = 32.0,
) -> dict[str, Any]:
"""从 (ox,oy,oz) 沿方向投射,返回**第一个挡路的方块**。
## 返回形状
```python
{"hit": bool, "block": str|None, "state_id": int|None,
"position": (x,y,z)|None, "normal": (nx,ny,nz)|None,
"distance": float, "reason": str, # 没命中时说明为什么
"steps": int, "truncated": bool}
```
## 为什么要分「命中 / 没命中 / 走不完」
三者含义完全不同,混在一起会让 agent 误判:
- `hit=True` —— 路上有东西(要么视线被挡,要么前方是实体位置)
- `hit=False, reason="clear"` —— 确认通畅
- `hit=False, reason="unloaded"` —— **这段没加载**,不能当通畅
(把「没见过」当「空旷」是这类工具最典型的撒谎)
## 算法
Amanatides & Woo 的体素遍历:逐格前进,不采样不插值。
★ 步进**必须**逐格做;用「每 N 格采样一次」会穿过薄墙(半砖、栅栏),
得到「明明有墙却说通畅」的错误结论。
"""
dx, dy, dz = float(dx), float(dy), float(dz)
norm = math.sqrt(dx * dx + dy * dy + dz * dz)
if norm == 0.0:
return _no_hit(0.0, "zero_direction", 0, False)
dx, dy, dz = dx / norm, dy / norm, dz / norm
x, y, z = int(math.floor(ox)), int(math.floor(oy)), int(math.floor(oz))
step_x = 1 if dx > 0 else (-1 if dx < 0 else 0)
step_y = 1 if dy > 0 else (-1 if dy < 0 else 0)
step_z = 1 if dz > 0 else (-1 if dz < 0 else 0)
# 到下一格边界的 t
INF = float("inf")
t_max_x = ((x + (1 if step_x > 0 else 0)) - ox) / dx if step_x else INF
t_max_y = ((y + (1 if step_y > 0 else 0)) - oy) / dy if step_y else INF
t_max_z = ((z + (1 if step_z > 0 else 0)) - oz) / dz if step_z else INF
t_delta_x = abs(1.0 / dx) if step_x else INF
t_delta_y = abs(1.0 / dy) if step_y else INF
t_delta_z = abs(1.0 / dz) if step_z else INF
# 起点所在格:若已不可通行,也算命中(起点就在墙里)
steps = 0
truncated = False
LIMIT = 4096
while True:
steps += 1
if steps > LIMIT:
truncated = True
return _no_hit(max_distance, "step_limit", steps, True)
# 走到当前格中心
cx, cy, cz = x + 0.5, y + 0.5, z + 0.5
t = math.sqrt((cx - ox) ** 2 + (cy - oy) ** 2 + (cz - oz) ** 2)
if t > max_distance:
return _no_hit(max_distance, "clear", steps, False)
hit = world.block_at(x, y, z)
# ★ WorldState.block_at 返回 **dict**(含 source/name/state_id)
if hit["source"] == "out-of-world":
return _no_hit(t, "out-of-world", steps, False)
if hit["source"] == "unloaded":
# ★ 明确说「没加载」,绝不当成空气
return _no_hit(t, "unloaded", steps, False)
solid = _solid_of(world, hit)
if solid:
# 命中面:从进入方向反推法线
nx = ny = nz = 0
if step_x: nx = -step_x
elif step_y: ny = -step_y
else: nz = -step_z
return {
"hit": True,
"block": hit["name"],
"state_id": hit["state_id"],
"position": (x, y, z),
"normal": (nx, ny, nz),
"distance": round(t, 3),
"reason": "solid",
"steps": steps,
"truncated": False,
}
if t > max_distance:
return _no_hit(max_distance, "clear", steps, False)
# 前进到下一格
if t_max_x < t_max_y and t_max_x < t_max_z:
if t_max_x > max_distance:
return _no_hit(max_distance, "clear", steps, False)
x += step_x
t_max_x += t_delta_x
elif t_max_y < t_max_z:
if t_max_y > max_distance:
return _no_hit(max_distance, "clear", steps, False)
y += step_y
t_max_y += t_delta_y
else:
if t_max_z > max_distance:
return _no_hit(max_distance, "clear", steps, False)
z += step_z
t_max_z += t_delta_z
def _no_hit(dist: float, reason: str, steps: int, truncated: bool) -> dict[str, Any]:
return {
"hit": False, "block": None, "state_id": None,
"position": None, "normal": None,
"distance": round(dist, 3), "reason": reason,
"steps": steps, "truncated": truncated,
}
def _solid_of(world: WorldState, hit: dict[str, Any]) -> bool | None:
"""这个方块挡不挡路?**未知返回 None**(不当成空气)。
★ WorldState.block_at 返回的是 dict(含 source/name/state_id),
而 World.block_at 返回 BlockHit 对象 —— 两者形状不同。
本模块统一用 WorldState(dict 形状),避免混用。
"""
reg = world.world.registry
if reg is None:
return None
return reg.is_solid(hit["state_id"])
def can_see(world: WorldState, target: tuple[float, float, float],
eye_height: float = 1.62, max_distance: float = 32.0) -> dict[str, Any]:
"""从当前位置能否**看见**某点(视线不被挡)。
★★ 关键点:射线长度必须**只到目标为止**。
我先前写成「射到 max_distance,看第一个碰撞是不是目标格」⇒
射线会**越过目标继续走**,撞上目标身后的墙 ⇒ 把「看得见」
误报成「被挡」(实测:目标 (1.5,2,0.5),墙在 x=3,
结果报 blocked by stone at (3,2,0))。
★ 判定顺序也很重要:先看目标格本身是否可穿,再看路上有无阻挡。
"""
p = world.player
ex, ey, ez = p.x, p.y + eye_height, p.z
dx = target[0] - ex
dy = target[1] - ey
dz = target[2] - ez
dist = math.sqrt(dx * dx + dy * dy + dz * dz)
if dist > max_distance:
return {"visible": False, "reason": "too_far",
"distance": round(dist, 3), "max_distance": max_distance}
if dist == 0.0:
return {"visible": True, "reason": "same_point", "distance": 0.0}
# ★ 射程 = 到目标的距离(再放一点点容差,避免浮点误差把目标格本身漏掉)
r = raycast(world, ex, ey, ez, dx, dy, dz, max_distance=dist + 1e-6)
tx = int(math.floor(target[0]))
ty = int(math.floor(target[1]))
tz = int(math.floor(target[2]))
if r["hit"]:
# 命中目标所在格(或目标自身就是实体方块)⇒ 看得见
if r["position"] == (tx, ty, tz):
return {"visible": True, "reason": "target_reached",
"block": r["block"], "distance": r["distance"]}
return {"visible": False, "reason": "blocked",
"by": r["block"], "at": r["position"],
"distance": r["distance"], "target": (tx, ty, tz)}
if r["reason"] == "unloaded":
return {"visible": False, "reason": "unknown_unloaded",
"note": "这段区块未加载,无法断言可见性"}
if r["reason"] == "out-of-world":
return {"visible": False, "reason": "out_of_world",
"note": "目标在世界外"}
# ★ 射线走到目标距离仍未撞到时不能直接说「看得见」:
# 必须确认目标格本身已被加载(否则「没见过」≠「空旷」)。
th = world.block_at(tx, ty, tz)
if th["source"] in ("unloaded", "out-of-world"):
return {"visible": False, "reason": "target_unloaded",
"note": f"目标格 {th['source']},无法断言"}
return {"visible": True, "reason": "clear",
"distance": r["distance"], "target_block": th["name"]}
# ---------- 行走(够用即可,不做完整寻路) ----------
#: 玩家能自动跨上的台阶高度(MC 规则:0.5 格)
STEP_HEIGHT = 1.0
def can_stand(world: WorldState, x: int, y: int, z: int) -> dict[str, Any]:
"""能否站在 (x,y,z) 这个**脚部位置**上。
规则(简化但诚实):
- 脚下方块必须可站立(solid)
- 身体所处两格(脚下 + 头)必须可通行
- 未加载 ⇒ 报 unknown,不当可站
"""
below = world.block_at(x, y - 1, z)
here = world.block_at(x, y, z)
head = world.block_at(x, y + 1, z)
if any(b["source"] in ("unloaded", "out-of-world") for b in (below, here, head)):
return {"standable": None, "reason": "unloaded_or_out_of_world",
"below": below["source"]}
reg = world.world.registry
if reg is None:
return {"standable": None, "reason": "no_registry"}
s_below = reg.is_solid(below["state_id"])
s_here = reg.is_solid(here["state_id"])
s_head = reg.is_solid(head["state_id"])
return {
"standable": bool(s_below and not s_here and not s_head),
"ground": below["name"], "at": here["name"], "head": head["name"],
"reason": "ok",
}
def walkable(world: WorldState, radius: int = 3,
max_samples: int = 512) -> dict[str, Any]:
"""以玩家为中心的**可站立分布**(有界网格)。
输出是一张字符网格 —— 那是 LLM 最省 token 也能读懂的形状。
符号:`.` 可站 / `#` 障碍 / `?` 未知(未加载或认不出)
"""
p = world.player
cx, cz = int(math.floor(p.x)), int(math.floor(p.z))
cy = int(math.floor(p.y))
if radius * 2 + 1 > max_samples:
return {"error": "范围过大", "scanned": False, "requested_radius": radius}
rows: list[str] = []
unknown = 0
blocked = 0
for dz in range(-radius, radius + 1):
row = []
for dx in range(-radius, radius + 1):
r = can_stand(world, cx + dx, cy, cz + dz)
if r["standable"] is True:
row.append(".")
elif r["standable"] is False:
row.append("#")
blocked += 1
else:
row.append("?")
unknown += 1
rows.append("".join(row))
return {
"center": (cx, cy, cz),
"radius": radius,
"scanned": True,
"grid": rows, # 第 0 行 = 最北(z 最小)
"legend": ". 可站 / # 障碍 / ? 未知",
"stats": {"open": (2 * radius + 1) ** 2 - blocked - unknown,
"blocked": blocked, "unknown": unknown},
"row0_is": "z = center_z - radius(最北)",
}
# ---------- ASCII 切面(agent 最爱看的形状) ----------
_GLYPH = {
"air": " ",
"cave_air": " ", "void_air": " ",
"stone": "S", "dirt": "d", "grass_block": "g", "sand": "s",
"bedrock": "B", "water": "~", "lava": "L", "oak_log": "T",
"oak_planks": "P", "cobblestone": "c", "gravel": "v", "clay": "y",
}
def glyph_for(name: str | None) -> str:
"""方块 → 一个字符。**认不出一律 '?'**(不乱画)。"""
if name is None:
return "?"
if name in _GLYPH:
return _GLYPH[name]
# 草/花/树叶等:按材质粗分类,也认不出就用 '?'
if name.startswith(("oak_", "birch_", "spruce_", "jungle_")):
if "leaves" in name:
return "F"
if "flower" in name or "tall_grass" in name or "fern" in name:
return "f"
return "T"
if name.endswith("_leaves"):
return "F"
return "?"
def slice_view(
world: WorldState,
axis: str = "z",
at: int | None = None,
width: int = 16,
height: int = 12,
max_cells: int = 4096,
) -> dict[str, Any]:
"""垂直切面的 ASCII 图(agent 判断地形起伏最直观的方式)。
axis: 'x'(东西切面)、'z'(南北切面)、'y'(水平切面)
at: 切面所在的另一个水平坐标;省略则用玩家所在位置
★ 有界:width×height 之外一律不扫;超 max_cells 直接拒绝。
★ 认不出的方块画 '?',**不**画成看起来合理的形状 ——
一张「看起来对」的错图比一张带问号的图危险得多。
"""
if width * height > max_cells:
return {"error": f"{width}×{height} 超过上限 {max_cells}", "scanned": False}
p = world.player
bx, by, bz = int(math.floor(p.x)), int(math.floor(p.y)), int(math.floor(p.z))
if axis not in ("x", "z", "y"):
return {"error": f"axis 须为 x/z/y,收到 {axis}", "scanned": False}
# ★ 垂直切面要把**脚下**也包含进来:玩家站在地面上时,
# 「从脚往上」全是空气 ⇒ 切面一片空白,看不出地形起伏(实测如此)。
# 改为以脚下方为基准往上取,让地面线落在图的下部。
base_y = by - max(2, height // 3)
if axis == "x":
at = bx if at is None else int(at)
cols = [at + i - width // 2 for i in range(width)]
rows = [base_y + j for j in range(height)]
elif axis == "z":
at = bz if at is None else int(at)
cols = [at + i - width // 2 for i in range(width)]
rows = [base_y + j for j in range(height)]
else:
ay = by if at is None else int(at)
cols = [bx + i - width // 2 for i in range(width)]
rows = [bz + i - width // 2 for i in range(width)]
lines: list[str] = []
unknown = 0
# 行内自上而下打印(高处在上,符合直觉)
for yy in reversed(rows):
buf = []
for c in cols:
if axis == "x":
hit = world.block_at(c, yy, at)
elif axis == "z":
hit = world.block_at(at, yy, c)
else:
hit = world.block_at(c, ay, yy)
if hit["source"] in ("unloaded", "out-of-world"):
unknown += 1
buf.append("?")
else:
g = glyph_for(hit["name"])
if g == "?":
unknown += 1
buf.append(g)
lines.append("".join(buf))
return {
"scanned": True,
"axis": axis, "at": at,
"width": width, "height": height,
"rows_top_to_bottom": lines,
"row0_is": (f"y = {rows[-1]}(最低)" if axis in ("x", "z")
else f"z = {rows[-1]}(最南)"),
"unknown_cells": unknown,
"note": "? = 未加载 / 界外 / 认不出的方块(不猜)",
}
# ---------- 周边总览(一次回答多个日常问题) ----------
def surroundings(world: WorldState, radius: int = 4) -> dict[str, Any]:
"""一次回答「我在哪 / 脚踩什么 / 哪能站 / 前面是什么」。
这是 agent 最常问的一组问题,合成一次调用以**省往返**。
"""
p = world.player
bx, by, bz = int(math.floor(p.x)), int(math.floor(p.y)), int(math.floor(p.z))
look_dir = _look_vector(p.yaw, p.pitch)
ahead = raycast(world, p.x, p.y + 1.62, p.z, *look_dir, max_distance=radius * 2.0)
return {
"player": {"x": p.x, "y": p.y, "z": p.z,
"yaw": p.yaw, "pitch": p.pitch,
"facing": _facing_name(p.yaw)},
"standing_on": world.block_at(bx, by - 1, bz),
"feet": world.block_at(bx, by, bz),
"head": world.block_at(bx, by + 1, bz),
"can_stand_here": can_stand(world, bx, by, bz),
"ahead": {"hit": ahead["hit"], "block": ahead["block"],
"distance": ahead["distance"], "reason": ahead["reason"]},
"nearby_kinds": world.nearby_blocks(bx, by, bz, radius=radius),
"chunk_columns_loaded": world.world.loaded_count,
}
def _look_vector(yaw: float, pitch: float) -> tuple[float, float, float]:
"""yaw/pitch → 单位方向向量(MC 约定:yaw 0 = +Z,yaw 90 = -X)。"""
y = math.radians(yaw)
p = math.radians(pitch)
cp, sp = math.cos(p), math.sin(p)
return (-math.sin(y) * cp, -sp, math.cos(y) * cp)
def _facing_name(yaw: float) -> str:
y = yaw % 360.0
names = {0.0: "南(+Z)", 90.0: "西(-X)", 180.0: "北(-Z)", 270.0: "东(+X)"}
best = min(names, key=lambda n: min(abs(y - n), 360 - abs(y - n)))
return names[best] if min(abs(y - best), 360 - abs(y - best)) < 45 else f"{y:.0f}°"