"""Tests for the room world: physics, collision, beacon, audio.""" import math import pytest from testbed.room.world import ROOM_SIZE, Room def test_spawn_state(): room = Room() assert room.capsule.x == 1.5 and room.capsule.z == 1.5 assert room.capsule.yaw_deg == 45.0 assert len(room.boxes) == 3 def test_move_forward_free(): room = Room() moved, hit = room.move_forward(2.0) assert moved == pytest.approx(2.0) assert hit is None assert room.capsule.x == pytest.approx(1.5 + 2.0 * math.sin(math.radians(45))) assert room.capsule.z == pytest.approx(1.5 + 2.0 * math.cos(math.radians(45))) def test_move_blocked_by_wall(): room = Room() room.capsule.yaw_deg = 180.0 # toward north wall (z=0) moved, hit = room.move_forward(10.0) assert moved == pytest.approx(1.5 - room.capsule.radius, abs=1e-6) assert hit is not None assert hit.other == "wall_north" assert hit.normal_z == pytest.approx(1.0) assert hit.impulse == pytest.approx(10.0 - moved) assert room.capsule.z == pytest.approx(room.capsule.radius) def test_move_blocked_by_box(): room = Room() # Crate red sits at (6,6) with half-extents 1. From spawn heading 45deg. room.capsule.yaw_deg = 45.0 moved, hit = room.move_forward(5.0) assert hit is not None assert hit.other == "crate_red" assert moved < 5.0 # Capsule must not overlap the box. b = room.boxes[0] near_x = min(max(room.capsule.x, b.x_min()), b.x_max()) near_z = min(max(room.capsule.z, b.z_min()), b.z_max()) assert ( math.hypot(room.capsule.x - near_x, room.capsule.z - near_z) >= room.capsule.radius - 1e-6 ) def test_corner_push_out_of_box(): room = Room() room.capsule.x = 6.0 room.capsule.z = 6.0 # inside crate_red ok, hit = room._try_displace(0.1, 0.1) assert not ok assert hit is not None and hit.other == "crate_red" near_x = min(max(room.capsule.x, room.boxes[0].x_min()), room.boxes[0].x_max()) near_z = min(max(room.capsule.z, room.boxes[0].z_min()), room.boxes[0].z_max()) assert ( math.hypot(room.capsule.x - near_x, room.capsule.z - near_z) >= room.capsule.radius - 1e-6 ) def test_turn_clamps_pitch(): room = Room() room.turn(yaw_deg=30.0, pitch_deg=200.0) assert room.capsule.pitch_deg == 85.0 room.turn(pitch_deg=-500.0) assert room.capsule.pitch_deg == -85.0 assert room.capsule.yaw_deg == pytest.approx(75.0) def test_look_at_beacon(): room = Room() yaw, _pitch = room.look_at_beacon() dx = room.beacon.x - room.capsule.x dz = room.beacon.z - room.capsule.z assert yaw == pytest.approx(math.degrees(math.atan2(dx, dz))) assert room.capsule.yaw_deg == pytest.approx(yaw) def test_interact_requires_reach(): room = Room() ok, msg = room.interact_beacon() assert not ok assert "too far" in msg room.capsule.x = room.beacon.x - 1.0 room.capsule.z = room.beacon.z ok, msg = room.interact_beacon() assert ok assert room.beacon.active ok2, _ = room.interact_beacon() assert ok2 # already active is still a success def test_hear_beacon_hum_only_in_range(): room = Room() sounds = room.hear_now() assert sounds == [] room.capsule.x = room.beacon.x - 3.0 room.capsule.z = room.beacon.z sounds = room.hear_now() assert any(s["kind"] == "beacon_hum" for s in sounds) assert sounds[0]["intensity"] > 0.0 def test_queue_and_drain_audio(): room = Room() room.queue_audio("thud", 90.0, 0.5) sounds = room.drain_audio() assert len(sounds) == 1 assert room.drain_audio() == [] def test_tick_advances(): room = Room() assert room.advance_tick() == 1 assert room.advance_tick() == 2 def test_describe_layout(): room = Room() d = room.describe() assert d["room"]["width"] == ROOM_SIZE assert len(d["obstacles"]) == 3 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"