search challenge: triangle marker on a crate's back face (rendered in vision, in digest), report tool with distance verification, smooth turn(duration), --free/--search autonomous modes, look_at crates; verified with gpt-5.6-luna

This commit is contained in:
opencode
2026-08-08 21:58:57 +03:00
parent 315762363e
commit daa0371519
9 changed files with 444 additions and 34 deletions
+19
View File
@@ -64,6 +64,25 @@ taken); stop the old one with `fuser -k 8765/tcp` or Ctrl-C in its terminal.
The demo prints a full transcript of tool calls/results to stdout and saves the The demo prints a full transcript of tool calls/results to stdout and saves the
capsule's final first-person frame to `demo_final_frame.png`. capsule's final first-person frame to `demo_final_frame.png`.
### Search challenge
A small orange triangle is painted on the **back side** of one of the crates
(random per run, always on a face hidden from the spawn corner). The agent must
explore the room, look at the crate faces, spot the triangle with `vision`,
and `report` it (the bridge verifies the report by distance):
```bash
scripts/run_chat.sh --provider polza --search
# autonomous exploration, real vision, smooth turns, cruise gliding;
# success = report verified
```
Search missions run in `--free` mode: no re-aim corrections, no collision
hints — the model plans its own exploration. `--free` also applies to beacon
missions if you want fewer guardrails. `look_at` now accepts crates too
(crate_red/crate_blue/crate_olive). Verified: gpt-5.6-luna explored the room
with smooth moves/turns and reported the triangle at 0.5 m.
### Smooth & proactive movement ### Smooth & proactive movement
The capsule no longer teleports or stops-and-thinks: The capsule no longer teleports or stops-and-thinks:
+78 -7
View File
@@ -113,6 +113,15 @@ class WorldQueryOutput(BaseModel):
tick: int tick: int
class ReportOutput(BaseModel):
discovery: str
success: bool
verified: bool
distance: float
message: str
tick: int
class EchoOutput(BaseModel): class EchoOutput(BaseModel):
value: str value: str
@@ -166,6 +175,17 @@ class RoomBridge(Bridge):
def _tick(self) -> int: def _tick(self) -> int:
return self.world.advance_tick() return self.world.advance_tick()
async def _animate_turn(
self, yaw_deg: float, pitch_deg: float, duration: float
) -> None:
"""Rotate smoothly over ``duration`` seconds (observable mid-turn)."""
n = max(1, min(int(duration / 0.1), 100))
dt = duration / n
for i in range(n):
self.world.turn(yaw_deg / n, pitch_deg / n)
if i < n - 1:
await asyncio.sleep(dt)
async def _animate_move( async def _animate_move(
self, forward: float, duration: float self, forward: float, duration: float
) -> tuple[float, Any | None]: ) -> tuple[float, Any | None]:
@@ -333,24 +353,48 @@ class RoomBridge(Bridge):
@self.tool( @self.tool(
cls=ToolClass.ACTUATOR, cls=ToolClass.ACTUATOR,
description="Rotate the camera: yaw_deg turns left/right, pitch_deg tilts up/down.", description=(
"Rotate the camera: yaw_deg turns left/right, pitch_deg tilts up/down. "
"duration (seconds) animates the rotation smoothly."
),
) )
async def turn(yaw_deg: float = 0.0, pitch_deg: float = 0.0) -> TurnOutput: async def turn(
yaw_deg: float = 0.0, pitch_deg: float = 0.0, duration: float = 0.0
) -> TurnOutput:
tick = self._tick() tick = self._tick()
world.turn(yaw_deg, pitch_deg) if duration < 0.0 or duration > 10.0:
raise ValueError(f"duration must be in [0, 10] s, got {duration}")
if duration > 0.0:
await self._animate_turn(yaw_deg, pitch_deg, duration)
else:
world.turn(yaw_deg, pitch_deg)
return TurnOutput(rotation=self._rotation(), tick=tick).model_dump() return TurnOutput(rotation=self._rotation(), tick=tick).model_dump()
@self.tool( @self.tool(
cls=ToolClass.ACTUATOR, cls=ToolClass.ACTUATOR,
description="Orient the camera toward a named target (e.g. 'beacon').", description=(
"Orient the camera toward a named target: 'beacon' or a crate "
"(crate_red, crate_blue, crate_olive)."
),
) )
async def look_at(target: str) -> LookAtOutput: async def look_at(target: str) -> LookAtOutput:
tick = self._tick() tick = self._tick()
if target != "beacon": box = next((b for b in world.boxes if b.id == target), None)
if target == "beacon":
yaw, pitch = world.look_at_beacon()
elif box is not None:
dx = box.cx - world.capsule.x
dz = box.cz - world.capsule.z
dist = math.hypot(dx, dz) or 1.0
yaw = math.degrees(math.atan2(dx, dz))
pitch = math.degrees(
math.atan2(box.height / 2 - world.capsule.eye_height, dist)
)
world.face(yaw, pitch)
else:
raise ValueError( raise ValueError(
f"unknown target {target!r}; available targets: beacon" f"unknown target {target!r}; available: beacon, crate_red, crate_blue, crate_olive"
) )
yaw, pitch = world.look_at_beacon()
return LookAtOutput( return LookAtOutput(
target=target, target=target,
rotation=Rotation( rotation=Rotation(
@@ -383,6 +427,33 @@ class RoomBridge(Bridge):
target=target, success=success, message=message, tick=tick target=target, success=success, message=message, tick=tick
).model_dump() ).model_dump()
@self.tool(
cls=ToolClass.ACTUATOR,
description=(
"Report a discovery to the operator (used in search missions): "
"say what you found. The report is verified against your position."
),
)
async def report(discovery: str = "triangle") -> ReportOutput:
tick = self._tick()
dist = world.distance_to_marker()
verified = world.marker is not None and dist <= 6.0
if verified:
message = f"report verified: {discovery} sighted {dist:.1f} m away"
else:
message = (
f"not verified: nothing matching {discovery!r} within 6 m "
f"(closest check {dist:.1f} m) — keep exploring"
)
return ReportOutput(
discovery=discovery,
success=True,
verified=verified,
distance=round(dist, 2),
message=message,
tick=tick,
).model_dump()
# -- conformance tools (design doc: register alongside world tools) -- # -- conformance tools (design doc: register alongside world tools) --
@self.tool( @self.tool(
+60 -13
View File
@@ -141,16 +141,30 @@ async def chat_loop(client: AICCClient, manifest, args: argparse.Namespace) -> i
async def mission_runner(goal_text: str) -> None: async def mission_runner(goal_text: str) -> None:
from testbed.llm_agent import run_llm_agent_loop from testbed.llm_agent import run_llm_agent_loop
controller.messages.append( if args.search:
{ from testbed.llm_agent import SEARCH_MISSION
"role": "user",
"content": ( controller.messages = [
f"MISSION (set by the user): {goal_text}. " {"role": "system", "content": SEARCH_MISSION},
"Keep calling tools and do not stop until the goal is achieved. " {
"Report only when done." "role": "user",
), "content": (
} f"MISSION: {goal_text}. "
) "Keep exploring with tools until the triangle is found and reported."
),
},
]
else:
controller.messages.append(
{
"role": "user",
"content": (
f"MISSION (set by the user): {goal_text}. "
"Keep calling tools and do not stop until the goal is achieved. "
"Report only when done."
),
}
)
saver = asyncio.create_task(map_saver()) saver = asyncio.create_task(map_saver())
try: try:
for attempt in range(1, args.mission_retries + 2): for attempt in range(1, args.mission_retries + 2):
@@ -165,12 +179,27 @@ async def chat_loop(client: AICCClient, manifest, args: argparse.Namespace) -> i
nudge_limit=args.mission_nudges, nudge_limit=args.mission_nudges,
look_every=args.look_every, look_every=args.look_every,
cruise=args.cruise, cruise=args.cruise,
autonomous=args.free or args.search,
is_success=(
(
lambda turn: any(
c.name == "report"
and c.ok
and isinstance(c.output, dict)
and c.output.get("verified")
for c in turn.calls
)
)
if args.search
else None
),
) )
except RuntimeError as exc: except RuntimeError as exc:
print(f" [mission] LLM error: {exc}") print(f" [mission] LLM error: {exc}")
break break
save_map() save_map()
if summary["interacted"]: done = summary.get("success") if args.search else summary["interacted"]
if done:
print(f"\n[mission] DONE: {summary['result']}") print(f"\n[mission] DONE: {summary['result']}")
return return
print( print(
@@ -226,7 +255,12 @@ async def chat_loop(client: AICCClient, manifest, args: argparse.Namespace) -> i
print(" (no mission running)") print(" (no mission running)")
await cmd_state() await cmd_state()
if args.mission: if args.mission or args.search:
if args.search:
mission_goal = (
"explore the room, find the orange triangle painted on the back "
"of one of the crates, and report it"
)
start_mission() start_mission()
while True: while True:
@@ -428,6 +462,17 @@ def main() -> int:
action="store_true", action="store_true",
help="start an autonomous mission on connect: reach and activate the beacon", help="start an autonomous mission on connect: reach and activate the beacon",
) )
parser.add_argument(
"--search",
action="store_true",
help="search challenge: explore the room and find the orange triangle "
"on the back of a crate, then report it (autonomous mode, fewer guardrails)",
)
parser.add_argument(
"--free",
action="store_true",
help="fewer restrictions: no re-aim corrections or collision hints; the model plans freely",
)
parser.add_argument( parser.add_argument(
"--mission-steps", "--mission-steps",
type=int, type=int,
@@ -479,7 +524,9 @@ def main() -> int:
model=args.model, model=args.model,
) )
if args.provider: if args.provider:
print(f"[chat] provider: {args.provider} -> {args.base_url} model: {args.model}") print(
f"[chat] provider: {args.provider} -> {args.base_url} model: {args.model}"
)
except ValueError as exc: except ValueError as exc:
print(f"[chat] {exc}") print(f"[chat] {exc}")
return 1 return 1
+8
View File
@@ -143,6 +143,7 @@ async def run_llm_agent(
digest: bool | None = None, digest: bool | None = None,
look_every: int = 0, look_every: int = 0,
cruise: float = 0.0, cruise: float = 0.0,
autonomous: bool = False,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Autonomous LLM run: controller + nudge/correct loop (see llm_agent).""" """Autonomous LLM run: controller + nudge/correct loop (see llm_agent)."""
from testbed.llm_agent import LLMController, run_llm_agent_loop from testbed.llm_agent import LLMController, run_llm_agent_loop
@@ -317,6 +318,7 @@ async def run_demo(args: argparse.Namespace) -> dict[str, Any]:
digest=args.digest, digest=args.digest,
look_every=args.look_every, look_every=args.look_every,
cruise=args.cruise, cruise=args.cruise,
autonomous=args.free,
) )
elif args.agent == "scripted": elif args.agent == "scripted":
summary = await run_scripted_agent( summary = await run_scripted_agent(
@@ -341,6 +343,7 @@ async def run_demo(args: argparse.Namespace) -> dict[str, Any]:
digest=args.digest, digest=args.digest,
look_every=args.look_every, look_every=args.look_every,
cruise=args.cruise, cruise=args.cruise,
autonomous=args.free,
) )
if summary.get("interacted"): if summary.get("interacted"):
return summary return summary
@@ -440,6 +443,11 @@ def main() -> int:
default=1.0, default=1.0,
help="proactive motion: glide forward (meters) while the LLM thinks (0 disables)", help="proactive motion: glide forward (meters) while the LLM thinks (0 disables)",
) )
parser.add_argument(
"--free",
action="store_true",
help="fewer restrictions: no re-aim corrections or collision hints; the model plans freely",
)
parser.add_argument( parser.add_argument(
"--frame", "--frame",
default="demo_final_frame.png", default="demo_final_frame.png",
+48 -14
View File
@@ -68,6 +68,19 @@ NUDGE = (
"then call a tool (turn or move, or interact if within 1.6 m)." "then call a tool (turn or move, or interact if within 1.6 m)."
) )
SEARCH_MISSION = (
"You are an explorer in a 16x16 m room. Three wooden crates stand in the room, "
"and a small ORANGE TRIANGLE is painted on the BACK side of ONE of them — the "
"side that faces away from the room's entrance, so it is only visible once you "
"walk around the crates. The glowing beacon in the corner is irrelevant: ignore it. "
"Your mission: explore the room until you see the orange triangle with your own "
'eyes (vision), then walk near it and call report(discovery="triangle"). '
"Strategy: walk a loop around the crates; every few steps look at the crate faces "
"with vision; check faces on all sides. The CURRENT STATE note in tool results "
"tells you your position and heading. Do not stop until you have seen and "
"reported the triangle."
)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Provider presets (OpenAI-compatible endpoints) # Provider presets (OpenAI-compatible endpoints)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -188,6 +201,7 @@ def looks_multimodal(model: str) -> bool:
COLOR_NAMES = [ COLOR_NAMES = [
("beacon_cyan", (120, 210, 235)), ("beacon_cyan", (120, 210, 235)),
("beacon_yellow", (255, 190, 90)), ("beacon_yellow", (255, 190, 90)),
("marker_orange", (255, 150, 40)),
("crate_red", (178, 64, 54)), ("crate_red", (178, 64, 54)),
("crate_blue", (64, 96, 178)), ("crate_blue", (64, 96, 178)),
("crate_olive", (128, 128, 60)), ("crate_olive", (128, 128, 60)),
@@ -606,14 +620,20 @@ async def run_llm_agent_loop(
nudge_limit: int = 1, nudge_limit: int = 1,
look_every: int = 0, look_every: int = 0,
cruise: float = 0.0, cruise: float = 0.0,
autonomous: bool = False,
is_success: Callable[[TurnResult], bool] | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Drive the controller until the mission is done or steps run out. """Drive the controller until the goal is done or steps run out.
``look_every``: attach a fresh camera frame every N steps so the model ``look_every``: attach a fresh camera frame every N steps so the model
always sees recent visual context without asking (0 disables). always sees recent visual context without asking (0 disables).
``cruise``: proactive motion while the model is thinking, the capsule ``cruise``: proactive motion while the model is thinking, the capsule
keeps gliding forward (meters per think, 0 disables). Collisions stop the keeps gliding forward (meters per think, 0 disables). Collisions stop the
drift and are reported to the model. drift and are reported to the model.
``autonomous``: fewer guardrails no re-aim corrections and no collision
hints; the model plans its own exploration (used for search missions).
``is_success``: custom goal predicate over a model turn; by default the
mission ends when interact succeeds.
""" """
summary: dict[str, Any] = { summary: dict[str, Any] = {
"steps": 0, "steps": 0,
@@ -730,23 +750,37 @@ async def run_llm_agent_loop(
and c.output.get("collision") and c.output.get("collision")
): ):
last_collision_step = step last_collision_step = step
obj = c.output.get("collision_normal") if not autonomous:
hint_msg = ( obj = c.output.get("collision_normal")
f"You collided with an obstacle (normal {obj}). Turn yaw_deg=90 and move " hint_msg = (
"forward twice to get around it, then follow the CURRENT STATE note to re-aim." f"You collided with an obstacle (normal {obj}). Turn yaw_deg=90 and move "
) "forward twice to get around it, then follow the CURRENT STATE note to re-aim."
if not ( )
controller.messages if not (
and controller.messages[-1].get("content") == hint_msg controller.messages
): and controller.messages[-1].get("content") == hint_msg
controller.messages.append({"role": "user", "content": hint_msg}) ):
log("agent", "(collision: go around)") controller.messages.append(
{"role": "user", "content": hint_msg}
)
log("agent", "(collision: go around)")
# Show the model what it just bumped into. # Show the model what it just bumped into.
await controller.auto_frame( await controller.auto_frame(
"You just bumped into something. Look at what is in front of you." "You just bumped into something. Look at what is in front of you."
) )
maybe_correct(step) if not autonomous:
if turn.interacted: maybe_correct(step)
if is_success is not None:
if is_success(turn):
summary["success"] = True
for c in turn.calls:
if c.name == "report" and c.ok and isinstance(c.output, dict):
summary["result"] = c.output.get("message", "reported")
break
else:
summary["result"] = "goal achieved"
return summary
elif turn.interacted:
summary["interacted"] = True summary["interacted"] = True
summary["result"] = turn.message summary["result"] = turn.message
return summary return summary
+76
View File
@@ -114,6 +114,7 @@ class Raycaster:
self._draw_beacon(room, pixels, depth, fx, fz, rx, rz, eye, tan_pitch) self._draw_beacon(room, pixels, depth, fx, fz, rx, rz, eye, tan_pitch)
img = Image.frombuffer("RGB", (w, h), bytes(pixels), "raw", "RGB", 0, 1) img = Image.frombuffer("RGB", (w, h), bytes(pixels), "raw", "RGB", 0, 1)
self._draw_marker(room, img, depth, fx, fz, rx, rz, eye, tan_pitch)
grid = self._depth_grid(depth, max_depth) grid = self._depth_grid(depth, max_depth)
return img, grid return img, grid
@@ -260,6 +261,65 @@ class Raycaster:
col = FLOOR_LINE col = FLOOR_LINE
return _fog(col, t) return _fog(col, t)
def _draw_marker(
self,
room: Room,
img: Image.Image,
depth: list[list[float]],
fx: float,
fz: float,
rx: float,
rz: float,
eye: float,
tan_pitch: float,
) -> None:
"""Draw the search-challenge triangle (a sprite on the crate's back
face), only when the anchor pixel is actually that face."""
m = room.marker
if m is None:
return
pos = room.marker_world_pos()
if pos is None:
return
w, h = self.width, self.height
cam = room.capsule
rel_x = pos[0] - cam.x
rel_z = pos[2] - cam.z
along = rel_x * fx + rel_z * fz
if along < 0.3:
return
right = rel_x * rx + rel_z * rz
col_c = (right / along / self.tan_fx + 1.0) / 2.0 * w
vv = (pos[1] - eye) / along - tan_pitch
row_c = (1.0 - vv / self.tan_fy) / 2.0 * h
r0, c0 = round(row_c), round(col_c)
if not (0 <= r0 < h and 0 <= c0 < w):
return
if abs(depth[r0][c0] - along) > 0.25:
return # the anchor pixel is not the marker's face (occluded)
half = max(2.5, m["size"] / along / self.tan_fx * w / 2.0)
draw = ImageDraw.Draw(img)
color = tuple(m["color"])
glow = tuple(min(255, c + 90) for c in color)
draw.ellipse(
[
col_c - half * 2.2,
row_c - half * 2.2,
col_c + half * 2.2,
row_c + half * 2.2,
],
fill=glow,
)
draw.polygon(
[
(col_c, row_c - half),
(col_c - half * 0.9, row_c + half * 0.7),
(col_c + half * 0.9, row_c + half * 0.7),
],
fill=color,
outline=(255, 255, 255),
)
def _draw_beacon( def _draw_beacon(
self, self,
room: Room, room: Room,
@@ -405,6 +465,22 @@ def render_topdown(room: Room, size: int = 400) -> Image.Image:
fx, fz = c.forward() fx, fz = c.forward()
tip = xy(c.x + fx * 0.7, c.z + fz * 0.7) tip = xy(c.x + fx * 0.7, c.z + fz * 0.7)
draw.line([(cx, cz), tip], fill=(30, 30, 40), width=3) draw.line([(cx, cz), tip], fill=(30, 30, 40), width=3)
m = room.marker
if m is not None:
pos = room.marker_world_pos()
if pos is not None:
mx, mz = xy(pos[0], pos[2])
r = m["size"] * scale
draw.polygon(
[
(mx, mz - r * 1.4),
(mx - r, mz + r),
(mx + r, mz + r),
],
fill=tuple(m["color"]),
outline=(20, 20, 26),
)
return img return img
+57
View File
@@ -8,6 +8,7 @@ from __future__ import annotations
import math import math
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any
ROOM_SIZE = 16.0 ROOM_SIZE = 16.0
ROOM_HEIGHT = 3.0 ROOM_HEIGHT = 3.0
@@ -124,6 +125,62 @@ class Room:
] ]
# Pending audio events, drained by the hear sensor. # Pending audio events, drained by the hear sensor.
self._audio: list[dict] = [] self._audio: list[dict] = []
# Search challenge: a small triangle painted on the BACK side of one
# of the crates (the face pointing away from the spawn corner). The
# agent must find it by exploring and looking with vision.
self.marker: dict[str, Any] = self._place_marker()
# ---------- search marker ----------
def _place_marker(self) -> dict[str, Any]:
"""Put the triangle on a random crate, on the face most hidden from
the spawn corner (1.5, 1.5)."""
import random
box = random.choice(self.boxes)
to_spawn = (1.5 - box.cx, 1.5 - box.cz)
normals = {
"x0": (-1.0, 0.0),
"x1": (1.0, 0.0),
"z0": (0.0, -1.0),
"z1": (0.0, 1.0),
}
faces = sorted(
normals,
key=lambda f: normals[f][0] * to_spawn[0] + normals[f][1] * to_spawn[1],
)[:2] # the two faces pointing away from spawn
return {
"box": box.id,
"face": random.choice(faces),
"u": 0.5,
"v": 0.55,
"size": 0.42,
"color": (255, 150, 40),
}
def marker_world_pos(self) -> tuple[float, float, float] | None:
"""World coordinates (x, y, z) of the marker center, or None."""
m = self.marker
if m is None:
return None
box = next((b for b in self.boxes if b.id == m["box"]), None)
if box is None:
return None
if m["face"] == "x0":
x, z = box.x_min(), box.cz - box.half_d + m["u"] * 2 * box.half_d
elif m["face"] == "x1":
x, z = box.x_max(), box.cz - box.half_d + m["u"] * 2 * box.half_d
elif m["face"] == "z0":
x, z = box.cx - box.half_w + m["u"] * 2 * box.half_w, box.z_min()
else:
x, z = box.cx - box.half_w + m["u"] * 2 * box.half_w, box.z_max()
return (x, m["v"] * box.height, z)
def distance_to_marker(self) -> float:
pos = self.marker_world_pos()
if pos is None:
return float("inf")
return math.hypot(pos[0] - self.capsule.x, pos[2] - self.capsule.z)
# ---------- ticks ---------- # ---------- ticks ----------
+50
View File
@@ -247,3 +247,53 @@ async def test_move_accepts_duration_zero_default(bridge):
res = (await client.call_tool("move", {"forward": 1.0})).output res = (await client.call_tool("move", {"forward": 1.0})).output
assert res["moved"] == pytest.approx(1.0) assert res["moved"] == pytest.approx(1.0)
assert res["duration"] == 0.0 assert res["duration"] == 0.0
async def test_report_verifies_near_marker(bridge):
t = InProcessTransport.start(bridge)
async with t:
client = AICCClient(t)
async with client:
await client.handshake()
out = (await client.call_tool("report", {"discovery": "triangle"})).output
assert out["success"] is True
assert out["verified"] is False # far away from spawn
pos = bridge.world.marker_world_pos()
bridge.world.capsule.x = pos[0]
bridge.world.capsule.z = pos[2]
out2 = (await client.call_tool("report", {})).output
assert out2["verified"] is True
assert "verified" in out2["message"]
async def test_turn_with_duration_is_smooth(bridge):
t1 = InProcessTransport.start(bridge)
t2 = InProcessTransport.start(bridge)
async with t1, t2:
mover = AICCClient(t1)
viewer = AICCClient(t2)
async with mover, viewer:
await mover.handshake()
await viewer.handshake()
task = asyncio.create_task(
mover.call_tool("turn", {"yaw_deg": 90.0, "duration": 0.8})
)
await asyncio.sleep(0.3)
mid = (await viewer.call_tool("proprioception", {})).output["rotation"]["yaw_deg"]
assert 0.0 < mid < 90.0, f"expected intermediate rotation, got {mid}"
res = (await task).output
assert res["rotation"]["yaw_deg"] == pytest.approx(135.0, abs=0.5)
async def test_look_at_crate(bridge):
t = InProcessTransport.start(bridge)
async with t:
client = AICCClient(t)
async with client:
await client.handshake()
out = (await client.call_tool("look_at", {"target": "crate_red"})).output
dx = bridge.world.boxes[0].cx - bridge.world.capsule.x
dz = bridge.world.boxes[0].cz - bridge.world.capsule.z
assert out["rotation"]["yaw_deg"] == pytest.approx(
math.degrees(math.atan2(dx, dz)) % 360.0, abs=0.5
)
+48
View File
@@ -131,3 +131,51 @@ def test_describe_layout():
assert d["room"]["width"] == ROOM_SIZE assert d["room"]["width"] == ROOM_SIZE
assert len(d["obstacles"]) == 3 assert len(d["obstacles"]) == 3
assert d["beacon"]["id"] == "beacon" assert d["beacon"]["id"] == "beacon"
def test_marker_is_on_a_back_face():
"""The triangle must sit on a face pointing away from the spawn corner."""
for _ in range(20):
room = Room()
pos = room.marker_world_pos()
assert pos is not None
box = next(b for b in room.boxes if b.id == room.marker["box"])
face = room.marker["face"]
# spawn is at (1.5, 1.5); the marker face normal must point away from it
to_spawn = (1.5 - box.cx, 1.5 - box.cz)
normal = {
"x0": (-1, 0), "x1": (1, 0), "z0": (0, -1), "z1": (0, 1),
}[face]
assert normal[0] * to_spawn[0] + normal[1] * to_spawn[1] < 0
assert pos[0] == pytest.approx(box.x_max()) if face == "x1" else True
def test_marker_not_visible_from_spawn():
"""Looking toward the beacon from spawn must not show the triangle."""
from testbed.room.render import Raycaster
room = Room()
# Spawn looks at the beacon; the marker is on a back face, so the anchor
# pixel of the marker must be occluded (different surface) or off-frame.
pos = room.marker_world_pos()
assert pos is not None
# Stand on the marker's side of the crate, facing it.
face = room.marker["face"]
offsets = {
"x0": (-1.0, 0.0, 90.0), # west face: stand west, face +x
"x1": (1.0, 0.0, 270.0), # east face: stand east, face -x
"z0": (0.0, -1.0, 0.0), # south face: stand south, face +z
"z1": (0.0, 1.0, 180.0), # north face: stand north, face -z
}[face]
room.capsule.x = pos[0] + offsets[0]
room.capsule.z = pos[2] + offsets[1]
room.capsule.yaw_deg = offsets[2]
img, _ = Raycaster().render(room)
px = img.load()
orange = 0
for y in range(0, 120, 2):
for x in range(0, 160, 2):
r, g, b = px[x, y]
if r > 200 and 100 < g < 190 and b < 90:
orange += 1
assert orange > 3, "triangle must be visible when looking at the marker face"