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