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 项变异全被抓(其中一项初次未抓到 ⇒ 补判据覆盖面后才抓住)
This commit is contained in:
JianFeeeee
2026-09-26 13:13:15 +08:00
parent 1a6f197572
commit 27b5c448b0
4 changed files with 910 additions and 1 deletions

View File

@ -237,13 +237,24 @@ class BlockRegistry:
self.blocks = blocks
self.by_state: dict[int, str] = {}
self.by_name: dict[str, dict[str, Any]] = {}
# ★ M6 感知层需要「走得通吗 / 挡不挡视线」⇒ 要碰撞与透明性。
# 都从 blocks.json 读,**不自己编**(编了就会与游戏内表现不一致)。
# boundingBox 的取值实测只有 'block' / 'empty' 两种,
# 粒度粗(半砖也是 'block')⇒ 使用者必须知道这一点,
# 所以另提供 strict 标志(见 solid_at)。
self.solid: dict[int, bool] = {}
self.transparent: dict[int, bool] = {}
for b in blocks:
name = b["name"]
self.by_name[name] = b
lo = b.get("minStateId", 0)
hi = b.get("maxStateId", lo)
is_solid = b.get("boundingBox", "block") != "empty"
is_trans = bool(b.get("transparent", False))
for sid in range(lo, hi + 1):
self.by_state[sid] = name
self.solid[sid] = is_solid
self.transparent[sid] = is_trans
self.max_state_id = max(self.by_state) if self.by_state else -1
@classmethod
@ -263,3 +274,16 @@ class BlockRegistry:
def is_out_of_range(self, state_id: int) -> bool:
"""越界 ⇒ 该 id 极可能来自 mod 方块(本机注册表没有它)。"""
return state_id > self.max_state_id
def is_solid(self, state_id: int) -> bool | None:
"""state id 是否可碰撞(挡路)。**未知就 None**,不猜。
⚠ 粒度警告:blocks.json 的 boundingBox 只有 block/empty 两档,
半砖、栅栏、单格高植物都会报 'block'。用于「大概能不能站」可以,
用于精确寻路则不足 —— 那种场合得走细渲染或完整碰撞形状数据。
"""
return self.solid.get(state_id)
def is_transparent(self, state_id: int) -> bool | None:
"""state id 是否透明(不挡视线)。未知就 None。"""
return self.transparent.get(state_id)

433
mcfold/rough.py Normal file
View File

@ -0,0 +1,433 @@
"""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}°"

39
plan.md
View File

@ -566,7 +566,7 @@ shot.rough → ASCII + 线索(给 agent 看它为什么选了这个角度)
| **M3** | **状态层**:World / entities / inventory 从**服务端真状态**来 | M2 | ✅ |
| **M4** | **事件层**:粗粒度 + 细分层(照 mineflayer,省 token) | M2 | ✅ |
| **M5** | **操作层**:chat / look / move / dig / place(能改世界) | M2, M3 | ✅ |
| **M6** | **粗渲染**(常驻感知):基于 M3 的真状态 | M3 | ⬜ |
| **M6** | **粗渲染**(常驻感知):基于 M3 的真状态 | M3 | ✅ |
| **M7** | **LLM 友好的 API 形状**(§1d):自省 / 可读返回 / 限量 | M3–M6 | ⬜ |
| **M8** | **细渲染**(单次调用):出图,带光影 | M2 | ⬜ |
| **M9** | 第一个用例:`capture_photo()`(粗搜机位 → 细出图) | M6, M8 | ⬜ |
@ -713,6 +713,43 @@ look(90,45) / select_slot / jump / sneak / sprint / swing 均被服务端接受
核心判据是「**16 个操作的包都能被官方 protodef 精确解回(0 余字节)**」——
这是唯一能**离线**证明「包写对了」的办法(本层缺陷几乎全是静默的)。
### M6 实测结论(2026-09-26)
新增 `mcfold/rough.py`(纯 Python、**不依赖 JVM**):agent 的常驻感知层。
**回答的五类日常问题**:
`raycast`(挡不挡视线/前面是什么)、`can_see`、`can_stand`、`walkable`(走得通吗)、
`slice_view`(ASCII 切面)、`surroundings`(一次总览,省往返)。
**实测(真服务器)**:
```
朝向 南(+Z) 脚下 grass_block 可站立 True 前面 clear
可行走网格 11×11:120 可站 / 1 障碍 / 0 未知
南北切面(宽 24 高 9):
| | ← 8 行空气
|gggggggggggggggggggggggg| ← grass 层
|dddddddddddddddddddddddd| ← dirt
|dddddddddddddddddddddddd|
```
**判据口径(plan §4.5)**:粗渲染不需要细渲染参与 —— 用**构造场景**判
(线上 flat world 太平凡,测不出射线对不对):单格薄墙、空走廊、斜坡、
未加载区、缺残注册表。
**本轮修的真 bug(3 个)**:
1. **`can_see` 越过目标撞身后的墙**:我射到 `max_distance` 再看首个命中
是不是目标格 ⇒ 目标 (1.5,2,0.5) 在墙前,射线越过它撞到 x=3 的墙,
把「看得见」误报成「被挡」。改为**射程只到目标距离**。
2. **切面从脚下往上取 ⇒ 全是空气,看不出地形**:改为基准下移,
让地面线落进画面(实测从「9 行全空」变成能看见 g/d 分层)。
3. **`walkable` 网格的 `?` 覆盖不足**:原来只统计「未加载」,
已加载但**认不出名字**的方块会被漏算。
**判据**:`tests/test_rough.py` 20 项;6 项变异全部被抓。
★ 其中变异 F(认不出的方块改画 `.`)**第一次没抓到** —— 因为我的
「未知格」判据只构造了**未加载**路径,覆盖面不足 ⇒ 补上「已加载但缺残注册表」
的场景后才抓住。**变异抓不到时要先怀疑判据覆盖面,而不是以为实现没问题。**
**建议顺序:M0 → M1 → M2**。
- M0/M1 是解阻断(M1 是我已经查到根因的那个 classpath bug)
- **M2 是真正的门槛**:从「能渲染」到「能玩」,缺的不是渲染,是**完整协议层**。

415
tests/test_rough.py Normal file
View File

@ -0,0 +1,415 @@
"""M6 粗渲染(ass)判据:rough.py。
## 判据口径(plan.md §4.5 明确要求)
粗渲染的判据**不需要**细渲染参与,各判各的用途:
1. `block_at` 已区分 unloaded / unknown-state ⇒ 不能把未加载当空气
2. `cast_ray` 结果与**人工推算**一致(单方块、空走廊、斜坡)
3. 局部切面的 ASCII 与**直接读方块数据**一致
## 为什么用构造场景而不是线上数据
线上 flat world 是最平凡的地形(平地 + 几块石头),
用它当判据只能测「在平地上不崩」,测不出射线对不对。
⇒ 判据必须**构造**:单方块挡路、空走廊、斜坡、未加载区域。
"""
import sys, os
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from mcfold.state import WorldState, load_vanilla_registry
from mcfold.chunk import BlockRegistry
from mcfold import rough
DATA_ROOT = ('/home/newqqagent/plugin_data/mc/runtime/node_modules/'
'minecraft-data/minecraft-data/data')
# 真实注册表(要 solid/transparent 判定,必须是真的)
_REG = load_vanilla_registry('1.21.1', DATA_ROOT)
# 常用 state id(从真实 blocks.json 取,不硬编名字)
AIR, STONE, DIRT, GRASS, BEDROCK, WATER, OAK_LOG = 0, 1, 10, 9, 79, 15, 0
class _Chunk:
"""按 (x,y,z) 函数给出 state id 的假区块(只服务判据,构造可控地形)。"""
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=((0, 0),)):
"""造一个只加载了指定区块列的 WorldState。"""
ws = WorldState(registry=_REG, num_sections=24)
for cx, cz in chunks:
# 区块内部坐标是 0..15;这里把区块内坐标当作世界坐标(cx=cz=0 即可)
ws.world.chunks[(cx, cz)] = _Chunk(fn)
ws.player.x, ws.player.y, ws.player.z = 0.5, 0.0, 0.5
return ws
def _flat_floor(y_floor=-1, solid=STONE):
def fn(x, y, z):
return solid if y <= y_floor else AIR
return fn
# ---------- ① 射线:撞到东西 / 不撞 / 未加载不撒谎 ----------
def test_raycast_hits_wall_at_exact_cell():
"""★ 单方块挡路:命中格与距离要与人工推算一致。
构造:x>=3 一堵墙 ⇒ 从 (0.5,1.62,0.5) 沿 +X 射,
期望命中 (3,1,0)(眼高 1.62 落在 y=1),距离约 3.0。
"""
def fn(x, y, z):
if y < 0:
return STONE
return STONE if x >= 3 else AIR
ws = _scene(fn)
r = rough.raycast(ws, 0.5, 1.62, 0.5, 1, 0, 0, max_distance=10)
assert r["hit"] is True, r
assert r["position"] == (3, 1, 0), r
assert r["block"] == "stone", r
assert 2.9 < r["distance"] < 3.1, r
assert r["normal"] == (-1, 0, 0), "法线应指向来路"
print(f" ★ 撞墙:命中 {r['position']} 距离 {r['distance']} 法线 {r['normal']}")
def test_raycast_floor_distance():
"""往下射应撞到脚下地板,距离 ≈ 眼高到格心。"""
ws = _scene(_flat_floor())
r = rough.raycast(ws, 0.5, 1.62, 0.5, 0, -1, 0, max_distance=10)
assert r["hit"] and r["position"] == (0, -1, 0), r
assert 2.0 < r["distance"] < 2.3, r
assert r["normal"] == (0, 1, 0), r
print(f" ★ 地板:命中 {r['position']} 距离 {r['distance']}")
def test_raycast_empty_corridor_is_clear():
"""★ 空走廊 ⇒ clear(不是「撞到空气」也不是未知)。"""
def fn(x, y, z):
if x >= 8:
return STONE # 只在远处有墙,10 格内应通畅
return STONE if y < 0 else AIR
ws = _scene(fn)
r = rough.raycast(ws, 0.5, 1.62, 0.5, 1, 0, 0, max_distance=7.5)
assert r["hit"] is False, r
assert r["reason"] == "clear", r
print(" ★ 空走廊 → clear(正确区分「通畅」与「命中」)")
def test_raycast_unloaded_is_not_clear():
"""★★ 未加载区域必须报 `unloaded`,**不能**报 clear。
这是本层最关键的诚实性判据:把「没见过」当「空旷」
会让 agent 撞进没加载的墙里。
"""
def fn(x, y, z):
return STONE if y < 0 else AIR
# 只加载 (0,0) 一个区块 ⇒ 往 -X 走(区块 -1)立刻出未加载
ws = _scene(fn, chunks=[(0, 0)])
r = rough.raycast(ws, 0.5, 1.62, 0.5, -1, 0, 0, max_distance=10)
assert r["hit"] is False, r
assert r["reason"] == "unloaded", f"必须报 unloaded,实际 {r['reason']}"
assert r["block"] is None
print(" ★★ 未加载区报 unloaded(不谎称 clear)")
def test_raycast_zero_direction_safe():
"""零向量不能崩(会算 NaN)。"""
ws = _scene(_flat_floor())
r = rough.raycast(ws, 0, 0, 0, 0, 0, 0)
assert r["hit"] is False and r["reason"] == "zero_direction", r
print(" ✓ 零向量安全降级")
def test_raycast_steps_through_thin_barrier():
"""★ 必须**逐格**遍历:中间一格障碍不能被跳过。
若实现偷懒「每 N 格采样」,会穿过单格薄墙 ⇒ 报 clear(错)。
构造:x=5 处只有一格墙(y=1),沿 +X 射必须撞上。
"""
def fn(x, y, z):
if y < 0:
return STONE
return STONE if (x == 5 and y == 1) else AIR
ws = _scene(fn)
r = rough.raycast(ws, 0.5, 1.62, 0.5, 1, 0, 0, max_distance=10)
assert r["hit"] is True, f"单格薄墙被跳过了:{r}"
assert r["position"] == (5, 1, 0), r
print(" ★ 单格薄墙能被射线捕捉(逐格遍历生效)")
# ---------- ② 视线可见性 ----------
def test_can_see_does_not_overshoot_target():
"""★★ 射线只能打到**目标**为止,不能越过目标再撞身后的墙。
这是本层抓到的真 bug:我先前射到 max_distance,
目标 (1.5,2,0.5) 在墙前,射线却越过它撞到 x=3 的墙
⇒ 把「看得见」误报成「被挡」。
"""
def fn(x, y, z):
if y < 0:
return STONE
return STONE if x >= 3 else AIR
ws = _scene(fn)
before = rough.can_see(ws, (1.5, 2.0, 0.5))
assert before["visible"] is True, f"墙前的目标应看得见,实际 {before}"
after = rough.can_see(ws, (6.0, 1.0, 0.5))
assert after["visible"] is False and after["reason"] == "blocked", after
assert after["by"] == "stone" and after["at"] == (3, 1, 0), after
print(" ★ 墙前看得见 / 墙后被挡(不越过目标)")
def test_can_see_unloaded_reports_unknown():
"""未加载的视线 ⇒ 报 unknown_unloaded,**不**说「看得见」也不说「被挡」。"""
def fn(x, y, z):
return STONE if y < 0 else AIR
ws = _scene(fn, chunks=[(0, 0)])
r = rough.can_see(ws, (-40.0, 1.0, 0.0)) # 在未加载的区块
assert r["visible"] is False, r
assert r["reason"] in ("unknown_unloaded", "target_unloaded", "too_far"), r
print(f" ★ 未加载视线报「不确定」(reason={r['reason']})")
def test_can_see_same_point():
"""看向**眼位本身** ⇒ same_point(零距离,不该去撞墙)。"""
ws = _scene(_flat_floor())
eye = (ws.player.x, ws.player.y + 1.62, ws.player.z)
r = rough.can_see(ws, eye)
assert r["visible"] is True and r["reason"] == "same_point", r
print(" ✓ 看向眼位本身 = same_point(不是去撞墙)")
# ---------- ③ 可站立 / 可行走 ----------
def test_can_stand_rules():
ws = _scene(_flat_floor())
assert rough.can_stand(ws, 0, 0, 0)["standable"] is True
# 在地板上但脚下有方块 ⇒ 不可站
def fn_wall(x, y, z):
if y < 0:
return STONE
return STONE if y <= 1 else AIR # y=0,1 是墙
ws2 = _scene(fn_wall)
assert rough.can_stand(ws2, 0, 0, 0)["standable"] is False
print(" ✓ 可站立规则(下方实心、脚下与头为空)")
def test_can_stand_unloaded_is_unknown_not_false():
"""★ 未加载 ⇒ standable=None(不确定),**不**说不可站。
把「不知道」说成「不行」会让 agent 放弃一整片可走区域。
"""
ws = _scene(_flat_floor(), chunks=[(0, 0)])
# 在未加载区块
r = rough.can_stand(ws, 40, 0, 0)
assert r["standable"] is None, r
assert "unload" in r["reason"], r
print(" ★ 未加载处 standable=None(不确定而非不可站)")
def test_walkable_grid_marks_unknown():
"""★ 可行走网格:'.'可站 '#'障碍 '?'未知,统计与网格一致。"""
def fn(x, y, z):
if y < 0:
return STONE
return STONE if x == 2 else AIR # 一道 1 格宽的矮墙
ws = _scene(fn, chunks=[(0, 0)])
w = rough.walkable(ws, radius=4)
assert w["scanned"] is True
grid = w["grid"]
assert len(grid) == 9 and all(len(r) == 9 for r in grid), grid
# 统计必须与网格字符数一致
flat = "".join(grid)
assert w["stats"]["blocked"] == flat.count("#"), (w["stats"], flat)
assert w["stats"]["open"] == flat.count("."), (w["stats"], flat)
assert w["stats"]["open"] + w["stats"]["blocked"] + w["stats"]["unknown"] == 81
# 玩家正前方(center_x+2)应是墙
row_center = grid[4] # dz=0 那一行
assert row_center[4 + 2] == "#", grid
print(" ★ 可行走网格统计与字符一致(墙在正前方 2 格)")
# ---------- ④ ASCII 切面 ----------
def test_slice_view_matches_direct_reads():
"""★ 切面每一格都必须与「直接读该格方块」一致(plan §4.5 要求)。"""
def fn(x, y, z):
if y == -1:
return STONE
if y == 0:
return DIRT
if y == 1 and 3 <= x <= 6:
return STONE # 一段矮墙
return AIR
ws = _scene(fn)
sv = rough.slice_view(ws, axis="x", at=4, width=8, height=4)
assert sv["scanned"] is True
rows = sv["rows_top_to_bottom"] # 上 → 下
cols = [4 + i - 8 // 2 for i in range(8)]
# ★ 不写死「哪一行是哪个 y」—— 那是实现细节(base_y 有偏移策略)。
# 判据只断言**逐格与直接读一致**,这才是 plan §4.5 的要求。
base_y = ws.player.y - max(2, 4 // 3)
ys = [base_y + j for j in range(4)][::-1] # 上 → 下
checked = 0
for ri, y in enumerate(ys):
for ci, x in enumerate(cols):
expect = rough.glyph_for(ws.block_at(x, y, 4)["name"])
got = rows[ri][ci]
assert got == expect, \
f"({x},{y},4) 切面画 {got!r},直读是 {expect!r}"
checked += 1
assert checked == 32
# 反向对照:场景里确实有非空气格,否则这条判据等于没测
assert any(c != " " for row in rows for c in row), rows
assert "S" in "".join(rows), "构造的矮墙应出现 S"
print(f" ★ 切面逐格与直接读方块一致({checked} 格全部核对通过)")
def test_slice_view_unknown_cells_marked():
"""★ 未加载/认不出 ⇒ 画 '?',不画成合理形状。
一张「看起来对」的错图比带问号的图危险得多。
"""
def fn(x, y, z):
if y < 0:
return STONE
return AIR
ws = _scene(fn, chunks=[(0, 0)])
# 切面横跨到未加载区块
sv = rough.slice_view(ws, axis="x", at=4, width=16, height=3)
assert "?" in "".join(sv["rows_top_to_bottom"]), sv["rows_top_to_bottom"]
assert sv["unknown_cells"] > 0
print(" ★ 未加载格画 '?'(不猜)")
def test_slice_view_refuses_oversized():
"""★ 超范围拒绝并说明,不静默缩小。"""
ws = _scene(_flat_floor())
r = rough.slice_view(ws, axis="x", width=200, height=200)
assert r["scanned"] is False and "超过上限" in r["error"], r
print(" ★ 超大切面被拒(不静默缩小)")
def test_glyph_unknown_is_question_mark():
"""认不出的方块一律 '?'。"""
assert rough.glyph_for("air") == " "
assert rough.glyph_for("stone") == "S"
assert rough.glyph_for("grass_block") == "g"
assert rough.glyph_for("some_mod_block_xyz") == "?"
assert rough.glyph_for(None) == "?"
print(" ✓ 未知方块一律 '?'")
# ---------- ⑤ 总览与有界 ----------
def test_surroundings_answers_daily_questions():
"""★ 一次回答「我在哪/踩什么/前面是什么/能否站立」。"""
def fn(x, y, z):
if y < 0:
return GRASS
return STONE if x >= 3 else AIR
ws = _scene(fn)
ws.player.yaw = 270.0 # 朝 +X
su = rough.surroundings(ws, radius=3)
assert su["standing_on"]["name"] == "grass_block", su["standing_on"]
assert su["feet"]["name"] == "air"
assert su["can_stand_here"]["standable"] is True
assert su["ahead"]["hit"] is True and su["ahead"]["block"] == "stone", su["ahead"]
assert su["player"]["facing"], su["player"]
print(f" ★ 总览:踩 {su['standing_on']['name']},前面 {su['ahead']['block']}")
def test_look_vector_facing_convention():
"""★ yaw 约定:0=+Z(南) 90=-X(西) 180=-Z(北) 270=+X(东)。"""
import math
cases = {0: (0, 1), 90: (-1, 0), 180: (0, -1), 270: (1, 0)}
for yaw, (ex, ez) in cases.items():
v = rough._look_vector(float(yaw), 0.0)
assert abs(v[0] - ex) < 1e-6 and abs(v[2] - ez) < 1e-6, \
f"yaw={yaw} → {v},期望 x={ex}, z={ez}"
print(" ★ yaw→方向向量约定正确(四方位)")
def test_registry_collision_and_light():
"""★ 注册表能答 solid / transparent,且未知返回 None。"""
assert _REG.is_solid(AIR) is False
assert _REG.is_solid(STONE) is True
assert _REG.is_transparent(AIR) is True
assert _REG.is_solid(999_999) is None, "未知 id 必须是 None(不当空气)"
assert _REG.is_transparent(999_999) is None
print(" ✓ solid/transparent 判定正确,未知为 None")
def test_slice_loaded_but_unrecognized_block_is_unknown():
"""★★ 已加载但**认不出名字**的方块也必须画 '?' 并计数。
这条是补的口子:我原有的「未知格」判据只构造了**未加载**路径,
于是变异 B(把「认不出名字」的格子改画成 '.')**测试依然全绿**
—— 判据覆盖面不足,测到的是噪声。
构造:一张只认识 stone 的缺残注册表 ⇒ 场景里的 grass/dirt 全认不出。
"""
tiny = BlockRegistry([{"name": "stone", "minStateId": 1, "maxStateId": 1}])
def fn(x, y, z):
if y == -1:
return STONE
if y == 0:
return DIRT # 这张注册表不认识 dirt
return AIR
ws = WorldState(registry=tiny, num_sections=24)
ws.world.chunks[(0, 0)] = _Chunk(fn)
ws.player.x, ws.player.y, ws.player.z = 0.5, 0.0, 0.5
sv = rough.slice_view(ws, axis="x", at=4, width=8, height=4)
rows = sv["rows_top_to_bottom"]
flat = "".join(rows)
# ★ dirt 认不出 ⇒ 必须画 '?'
assert "?" in flat, f"认不出的方块没画 '?': {rows}"
assert sv["unknown_cells"] > 0, sv
# ★ 反向对照:stone 认得出 ⇒ 'S' 必须出现(证明不是「全都 ?」)
assert "S" in flat, f"认得出的 stone 应画 'S': {rows}"
# ★ 且 unknown_cells 必须与实际 '?' 个数一致(不能只加不数)
assert sv["unknown_cells"] == flat.count("?"), \
f"unknown_cells={sv['unknown_cells']} 但 '?' 有 {flat.count('?')}"
print(f" ★★ 已加载但认不出的方块画 '?' 并计数({sv['unknown_cells']} 个,与字符数一致)")
if __name__ == "__main__":
for fn in [test_raycast_hits_wall_at_exact_cell,
test_raycast_floor_distance,
test_raycast_empty_corridor_is_clear,
test_raycast_unloaded_is_not_clear,
test_raycast_zero_direction_safe,
test_raycast_steps_through_thin_barrier,
test_can_see_does_not_overshoot_target,
test_can_see_unloaded_reports_unknown,
test_can_see_same_point,
test_can_stand_rules,
test_can_stand_unloaded_is_unknown_not_false,
test_walkable_grid_marks_unknown,
test_slice_view_matches_direct_reads,
test_slice_view_unknown_cells_marked,
test_slice_view_refuses_oversized,
test_glyph_unknown_is_question_mark,
test_surroundings_answers_daily_questions,
test_look_vector_facing_convention,
test_registry_collision_and_light,
test_slice_loaded_but_unrecognized_block_is_unknown]:
fn()
print("\n粗渲染(ass):全部通过")