Files
agentmc/tests/test_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

416 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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):全部通过")