"""Room world: the environment owned by the testbed bridge. Single source of truth for all world state. Nothing here is reachable by an agent except through the sensor tools in ``testbed.bridge``. """ from __future__ import annotations import math from dataclasses import dataclass, field ROOM_SIZE = 16.0 ROOM_HEIGHT = 3.0 @dataclass class Box: """Axis-aligned box obstacle.""" id: str cx: float cz: float half_w: float half_d: float height: float color: tuple[int, int, int] def x_min(self) -> float: return self.cx - self.half_w def x_max(self) -> float: return self.cx + self.half_w def z_min(self) -> float: return self.cz - self.half_d def z_max(self) -> float: return self.cz + self.half_d @dataclass class Beacon: """The single interactable object: a glowing pillar.""" id: str = "beacon" x: float = 12.5 z: float = 12.5 height: float = 1.6 radius: float = 0.35 active: bool = False activated_tick: int = -1 reach: float = 1.6 @dataclass class Capsule: """The agent's body: position, heading (yaw), camera pitch, velocity.""" x: float = 1.5 z: float = 1.5 yaw_deg: float = 45.0 pitch_deg: float = 0.0 radius: float = 0.35 eye_height: float = 0.55 health: float = 100.0 speed: float = 0.0 def forward(self) -> tuple[float, float]: """Unit vector in XZ plane along the current heading.""" rad = math.radians(self.yaw_deg) return math.sin(rad), math.cos(rad) @dataclass class Hit: """A collision: what was hit, where, and with what force.""" other: str normal_x: float normal_z: float impulse: float class Room: """A 16x16 room: floor, walls, a few boxes, one glowing beacon. Tick semantics: ``tick_mode`` is ``event`` — the world advances only when a tool is called. Every tool call bumps ``tick`` by one, so observations and events share a monotonic clock (protocol §8). """ def __init__(self) -> None: self.tick = 0 self.capsule = Capsule() self.beacon = Beacon() self.boxes: list[Box] = [ Box( id="crate_red", cx=6.0, cz=6.0, half_w=1.0, half_d=1.0, height=1.4, color=(178, 64, 54), ), Box( id="crate_blue", cx=11.0, cz=3.5, half_w=0.9, half_d=0.9, height=1.6, color=(64, 96, 178), ), Box( id="crate_olive", cx=4.0, cz=11.0, half_w=0.7, half_d=0.7, height=1.2, color=(128, 128, 60), ), ] # Pending audio events, drained by the hear sensor. self._audio: list[dict] = [] # ---------- ticks ---------- def advance_tick(self) -> int: """Bump the world clock; returns the new tick.""" self.tick += 1 return self.tick # ---------- audio ---------- def queue_audio(self, kind: str, direction_deg: float, intensity: float) -> None: self._audio.append( { "kind": kind, "direction_deg": round(direction_deg % 360.0, 1), "intensity": round(intensity, 3), } ) def drain_audio(self) -> list[dict]: sounds = self._audio self._audio = [] return sounds def hear_now(self) -> list[dict]: """The hear sensor: queued events plus the beacon hum if in range.""" sounds = self.drain_audio() dx = self.beacon.x - self.capsule.x dz = self.beacon.z - self.capsule.z dist = math.hypot(dx, dz) if dist <= 6.0: bearing = math.degrees(math.atan2(dx, dz)) - self.capsule.yaw_deg intensity = max(0.0, 1.0 - dist / 6.0) if self.beacon.active: intensity = min(1.0, intensity + 0.25) sounds.append( { "kind": "beacon_hum", "direction_deg": round(bearing % 360.0, 1), "intensity": round(intensity, 3), } ) return sounds # ---------- movement ---------- def move_forward(self, distance: float) -> tuple[float, Hit | None]: """Push the capsule forward along its heading, resolving collisions. Moves in 0.1 m substeps. Returns (distance actually moved, hit or None). """ moved = 0.0 hit: Hit | None = None remaining = max(0.0, distance) if remaining <= 0.0: return 0.0, None fx, fz = self.capsule.forward() while remaining > 0.0 and hit is None: step = min(0.1, remaining) bx, bz = self.capsule.x, self.capsule.z ok, h = self._try_displace(fx * step, fz * step) moved += math.hypot(self.capsule.x - bx, self.capsule.z - bz) if not ok and h is not None: h.impulse = max(0.0, distance - moved) hit = h break remaining -= step self.capsule.speed = moved / 0.1 if moved > 0 else 0.0 return moved, hit def _try_displace(self, dx: float, dz: float) -> tuple[bool, Hit | None]: """Move by (dx, dz) with circle-vs-AABB resolution. Returns (moved, hit). The capsule always ends at the resolved position: on a blocked step it rests at the contact point (wall or box face). """ cap = self.capsule r = cap.radius nx = cap.x + dx nz = cap.z + dz blocked: Hit | None = None if nx < r: nx, blocked = ( r, Hit(other="wall_west", normal_x=1.0, normal_z=0.0, impulse=0.0), ) elif nx > ROOM_SIZE - r: nx, blocked = ( ROOM_SIZE - r, Hit(other="wall_east", normal_x=-1.0, normal_z=0.0, impulse=0.0), ) elif nz < r: nz, blocked = ( r, Hit(other="wall_north", normal_x=0.0, normal_z=1.0, impulse=0.0), ) elif nz > ROOM_SIZE - r: nz, blocked = ( ROOM_SIZE - r, Hit(other="wall_south", normal_x=0.0, normal_z=-1.0, impulse=0.0), ) for _ in range(8): contact = self._box_contact(nx, nz, r) if contact is None: break nx, nz, normal_x, normal_z, other = contact blocked = Hit( other=other, normal_x=normal_x, normal_z=normal_z, impulse=0.0 ) cap.x = nx cap.z = nz return blocked is None, blocked def _box_contact( self, cx: float, cz: float, r: float ) -> tuple[float, float, float, float, str] | None: """If the circle at (cx, cz) overlaps a box, push it out and report contact.""" for box in self.boxes: min_x, max_x = box.x_min(), box.x_max() min_z, max_z = box.z_min(), box.z_max() near_x = min(max(cx, min_x), max_x) near_z = min(max(cz, min_z), max_z) dx = cx - near_x dz = cz - near_z d2 = dx * dx + dz * dz if d2 >= r * r: continue if d2 > 1e-12: d = math.sqrt(d2) push = (r - d) / d return ( cx + dx * push, cz + dz * push, dx / d, dz / d, box.id, ) # Center inside the box: push along the axis of least penetration. ox = min(cx - min_x + r, max_x - cx + r) oz = min(cz - min_z + r, max_z - cz + r) if ox < oz: nx = cx + ox if cx < box.cx else cx - ox return (nx, cz, 1.0 if cx < box.cx else -1.0, 0.0, box.id) nz = cz + oz if cz < box.cz else cz - oz return (cx, nz, 0.0, 1.0 if cz < box.cz else -1.0, box.id) return None def turn(self, yaw_deg: float = 0.0, pitch_deg: float = 0.0) -> None: cap = self.capsule cap.yaw_deg = (cap.yaw_deg + yaw_deg) % 360.0 cap.pitch_deg = min(85.0, max(-85.0, cap.pitch_deg + pitch_deg)) def face(self, yaw_deg: float, pitch_deg: float) -> None: self.capsule.yaw_deg = yaw_deg % 360.0 self.capsule.pitch_deg = min(85.0, max(-85.0, pitch_deg)) def look_at_beacon(self) -> tuple[float, float]: """Orient camera toward the beacon. Returns (yaw_deg, pitch_deg).""" cap = self.capsule dx = self.beacon.x - cap.x dz = self.beacon.z - cap.z dist = math.hypot(dx, dz) or 1.0 yaw = math.degrees(math.atan2(dx, dz)) pitch = math.degrees(math.atan2(self.beacon.height - cap.eye_height, dist)) self.face(yaw, pitch) return yaw, pitch def distance_to_beacon(self) -> float: return math.hypot( self.beacon.x - self.capsule.x, self.beacon.z - self.capsule.z ) def interact_beacon(self) -> tuple[bool, str]: """Try to activate the beacon. Returns (success, message).""" dist = self.distance_to_beacon() if dist > self.beacon.reach: return ( False, f"too far: {dist:.1f} m from the beacon (need <= {self.beacon.reach} m)", ) if self.beacon.active: return True, "the beacon is already active and glowing warm yellow" self.beacon.active = True self.beacon.activated_tick = self.tick return True, "the beacon lights up: a warm yellow glow floods the room" # ---------- introspection helpers ---------- def surface_distances(self, x: float, z: float) -> dict[str, float]: """Distances to the nearest wall on each side (used by demo steering).""" return { "west": x, "east": ROOM_SIZE - x, "north": z, "south": ROOM_SIZE - z, } def describe(self) -> dict: return { "room": { "name": "testbed_room_01", "width": ROOM_SIZE, "depth": ROOM_SIZE, "height": ROOM_HEIGHT, }, "obstacles": [ { "id": b.id, "x": round(b.cx, 2), "z": round(b.cz, 2), "width": round(b.half_w * 2, 2), "depth": round(b.half_d * 2, 2), "height": round(b.height, 2), } for b in self.boxes ], "beacon": { "id": self.beacon.id, "x": round(self.beacon.x, 2), "z": round(self.beacon.z, 2), "height": round(self.beacon.height, 2), }, }