diff --git a/src/mudlib/render/room.py b/src/mudlib/render/room.py new file mode 100644 index 0000000..43b51fe --- /dev/null +++ b/src/mudlib/render/room.py @@ -0,0 +1,139 @@ +"""Pure functions for structured room display.""" + +from __future__ import annotations + + +def render_where(zone_name: str) -> str: + """Render the zone description line. + + Args: + zone_name: The zone's description/name to display + + Returns: + Formatted string like "Where: The Overworld" + """ + return f"Where: {zone_name}" + + +def render_location(zone, x: int, y: int) -> str: + """Render location with quadrant and coordinates. + + Args: + zone: The zone object (needs width and height attributes) + x: X coordinate + y: Y coordinate + + Returns: + Formatted string like "Location: northeast 47, 53" + """ + # Calculate quadrant based on zone thirds + third_w = zone.width / 3 + third_h = zone.height / 3 + + # Determine horizontal component (west/center/east) + if x < third_w: + h_part = "west" + elif x < 2 * third_w: + h_part = "center" + else: + h_part = "east" + + # Determine vertical component (north/center/south) + if y < third_h: + v_part = "north" + elif y < 2 * third_h: + v_part = "center" + else: + v_part = "south" + + # Combine into quadrant name + if h_part == "center" and v_part == "center": + quadrant = "center" + elif h_part == "center": + quadrant = v_part + elif v_part == "center": + quadrant = h_part + else: + quadrant = f"{v_part}{h_part}" + + return f"Location: {quadrant} {x}, {y}" + + +def render_nearby(entities: list, viewer) -> str: + """Render nearby entities line. + + Args: + entities: List of entities in viewport but not on viewer's tile + viewer: The viewing entity (unused for now) + + Returns: + Formatted string like "Nearby: (3) saibaman / Master Roshi / Goku" + or empty string if no nearby entities + """ + if not entities: + return "" + + count = len(entities) + names = " / ".join(entity.name for entity in entities) + return f"Nearby: ({count}) {names}" + + +def render_exits(zone, x: int, y: int) -> str: + """Render available exits from current position. + + Args: + zone: The zone object (needs is_passable method) + x: Current X coordinate + y: Current Y coordinate + + Returns: + Formatted string like "Exits: north south east west" + """ + exits = [] + + # Check cardinal directions + # NOTE: up/down exits deferred until z-axis movement is implemented + if zone.is_passable(x, y - 1): # north (y decreases going up) + exits.append("north") + if zone.is_passable(x, y + 1): # south + exits.append("south") + if zone.is_passable(x + 1, y): # east + exits.append("east") + if zone.is_passable(x - 1, y): # west + exits.append("west") + + if exits: + return f"Exits: {' '.join(exits)}" + return "Exits:" + + +_POSTURE_MESSAGES = { + "standing": "is standing here.", + "resting": "is resting here.", + "flying": "is flying above.", + "fighting": "is fighting here.", + "unconscious": "is unconscious.", +} + + +def render_entity_lines(entities: list, viewer) -> str: + """Render entity descriptions based on posture. + + Args: + entities: List of entities on viewer's tile (excluding viewer) + viewer: The viewing entity (unused for now) + + Returns: + Newline-separated entity descriptions, or empty string if none + """ + if not entities: + return "" + + lines = [] + for entity in entities: + # Get posture with fallback to "standing" + posture = getattr(entity, "posture", "standing") + msg = _POSTURE_MESSAGES.get(posture, "is standing here.") + lines.append(f"{entity.name} {msg}") + + return "\n".join(lines) diff --git a/tests/test_room_render.py b/tests/test_room_render.py new file mode 100644 index 0000000..cf26eed --- /dev/null +++ b/tests/test_room_render.py @@ -0,0 +1,171 @@ +"""Tests for room rendering functions.""" + +from mudlib.entity import Mob +from mudlib.render.room import ( + render_entity_lines, + render_exits, + render_location, + render_nearby, + render_where, +) +from mudlib.zone import Zone + + +def test_render_where(): + """render_where returns 'Where: {zone description}'.""" + zone = Zone(name="overworld", description="The Overworld") + assert render_where(zone.description) == "Where: The Overworld" + + zone2 = Zone(name="cave", description="Dark Cave") + assert render_where(zone2.description) == "Where: Dark Cave" + + +def test_render_location_center(): + """render_location shows 'center' for positions near zone center.""" + zone = Zone(name="test", width=50, height=50, terrain=[]) + # Center should be around 25, 25 (middle third of both axes) + assert render_location(zone, 25, 25) == "Location: center 25, 25" + assert render_location(zone, 20, 20) == "Location: center 20, 20" + assert render_location(zone, 30, 30) == "Location: center 30, 30" + + +def test_render_location_quadrants(): + """render_location shows correct quadrants based on position.""" + zone = Zone(name="test", width=60, height=60, terrain=[]) + # Thirds: 0-19 west/north, 20-39 center, 40-59 east/south + + # Northwest + assert render_location(zone, 5, 5) == "Location: northwest 5, 5" + assert render_location(zone, 10, 10) == "Location: northwest 10, 10" + + # North (center horizontally, north vertically) + assert render_location(zone, 30, 5) == "Location: north 30, 5" + + # Northeast + assert render_location(zone, 50, 5) == "Location: northeast 50, 5" + + # West (west horizontally, center vertically) + assert render_location(zone, 5, 30) == "Location: west 5, 30" + + # East + assert render_location(zone, 50, 30) == "Location: east 50, 30" + + # Southwest + assert render_location(zone, 5, 50) == "Location: southwest 5, 50" + + # South + assert render_location(zone, 30, 50) == "Location: south 30, 50" + + # Southeast + assert render_location(zone, 50, 50) == "Location: southeast 50, 50" + + +def test_render_nearby_empty(): + """render_nearby returns empty string when no nearby entities.""" + assert render_nearby([], None) == "" + + +def test_render_nearby_single(): + """render_nearby shows count and single entity name.""" + mob = Mob(name="saibaman", x=10, y=10) + result = render_nearby([mob], None) + assert result == "Nearby: (1) saibaman" + + +def test_render_nearby_multiple(): + """render_nearby shows count and names separated by '/'.""" + mobs = [ + Mob(name="saibaman", x=10, y=10), + Mob(name="Master Roshi", x=11, y=10), + Mob(name="Goku", x=10, y=11), + ] + result = render_nearby(mobs, None) + assert result == "Nearby: (3) saibaman / Master Roshi / Goku" + + +def test_render_exits_all_directions(): + """render_exits shows all passable cardinal directions.""" + # Create zone with all passable terrain (grass) + terrain = [["." for _ in range(10)] for _ in range(10)] + zone = Zone( + name="test", width=10, height=10, terrain=terrain, impassable={"^", "~"} + ) + + result = render_exits(zone, 5, 5) + assert result == "Exits: north south east west" + + +def test_render_exits_blocked_directions(): + """render_exits only shows passable directions.""" + # Create zone with mountains (^) blocking some directions + terrain = [ + [".", ".", ".", ".", "."], + [".", ".", "^", ".", "."], # mountain north + [".", "^", ".", "^", "."], # mountains west and east + [".", ".", "^", ".", "."], # mountain south + [".", ".", ".", ".", "."], + ] + zone = Zone(name="test", width=5, height=5, terrain=terrain, impassable={"^", "~"}) + + # Position at center (2, 2) — surrounded by mountains + result = render_exits(zone, 2, 2) + assert result == "Exits:" # no passable exits + + +def test_render_exits_partial(): + """render_exits shows only available exits.""" + terrain = [ + [".", ".", "."], + ["^", ".", "."], # mountain to west + [".", "^", "."], # mountain to south + ] + zone = Zone(name="test", width=3, height=3, terrain=terrain, impassable={"^"}) + + # Position at (1, 1) — north and east are passable + result = render_exits(zone, 1, 1) + assert result == "Exits: north east" + + +def test_render_entity_lines_empty(): + """render_entity_lines returns empty string when no entities.""" + assert render_entity_lines([], None) == "" + + +def test_render_entity_lines_single(): + """render_entity_lines shows single entity with posture.""" + mob = Mob(name="saibaman", x=10, y=10) + # Default posture is "standing" (fallback) + result = render_entity_lines([mob], None) + assert result == "saibaman is standing here." + + +def test_render_entity_lines_multiple(): + """render_entity_lines shows multiple entities, each on own line.""" + # Create entities with different states that affect posture + mob1 = Mob(name="saibaman", x=10, y=10) # standing by default + mob2 = Mob(name="Master Roshi", x=10, y=10, resting=True) # resting + + result = render_entity_lines([mob1, mob2], None) + lines = result.split("\n") + assert len(lines) == 2 + assert lines[0] == "saibaman is standing here." + assert lines[1] == "Master Roshi is resting here." + + +def test_render_entity_lines_postures(): + """render_entity_lines handles different posture types based on entity state.""" + # Standing (default state) + mob = Mob(name="Goku", x=10, y=10) + assert render_entity_lines([mob], None) == "Goku is standing here." + + # Resting (resting=True) + mob_resting = Mob(name="Goku", x=10, y=10, resting=True) + assert render_entity_lines([mob_resting], None) == "Goku is resting here." + + # Unconscious (pl <= 0) + mob_unconscious = Mob(name="Goku", x=10, y=10, pl=0) + assert render_entity_lines([mob_unconscious], None) == "Goku is unconscious." + + # Unconscious (stamina <= 0) + mob_exhausted = Mob(name="Goku", x=10, y=10, stamina=0) + assert render_entity_lines([mob_exhausted], None) == "Goku is unconscious."