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:
@ -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)
|
||||
|
||||
433
mcfold/rough.py
Normal file
433
mcfold/rough.py
Normal file
@ -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}°"
|
||||
39
plan.md
39
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 是真正的门槛**:从「能渲染」到「能玩」,缺的不是渲染,是**完整协议层**。
|
||||
|
||||
415
tests/test_rough.py
Normal file
415
tests/test_rough.py
Normal 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):全部通过")
|
||||
Reference in New Issue
Block a user