新增 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 项变异全被抓(其中一项初次未抓到 ⇒ 补判据覆盖面后才抓住)
416 lines
16 KiB
Python
416 lines
16 KiB
Python
"""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):全部通过")
|