diff --git a/mcfold/chunk.py b/mcfold/chunk.py index 168f449..187b241 100644 --- a/mcfold/chunk.py +++ b/mcfold/chunk.py @@ -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) diff --git a/mcfold/rough.py b/mcfold/rough.py new file mode 100644 index 0000000..62fbc3d --- /dev/null +++ b/mcfold/rough.py @@ -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}°" diff --git a/plan.md b/plan.md index 37f1c14..38167a6 100644 --- a/plan.md +++ b/plan.md @@ -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 是真正的门槛**:从「能渲染」到「能玩」,缺的不是渲染,是**完整协议层**。 diff --git a/tests/test_rough.py b/tests/test_rough.py new file mode 100644 index 0000000..d44ae2c --- /dev/null +++ b/tests/test_rough.py @@ -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):全部通过")