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

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