diff --git a/src/mudlib/commands/look.py b/src/mudlib/commands/look.py index 5dd8613..1fe90a8 100644 --- a/src/mudlib/commands/look.py +++ b/src/mudlib/commands/look.py @@ -4,6 +4,7 @@ from typing import Any from mudlib.commands import CommandDefinition, register from mudlib.effects import get_effects_at +from mudlib.mobs import mobs from mudlib.player import Player, players from mudlib.render.ansi import RESET, colorize_terrain @@ -53,8 +54,30 @@ async def cmd_look(player: Player, args: str) -> None: if 0 <= rel_x < VIEWPORT_WIDTH and 0 <= rel_y < VIEWPORT_HEIGHT: other_player_positions.append((rel_x, rel_y)) + # Build a list of (relative_x, relative_y) for alive mobs + mob_positions = [] + for mob in mobs: + if not mob.alive: + continue + + dx = mob.x - player.x + dy = mob.y - player.y + if dx > world.width // 2: + dx -= world.width + elif dx < -(world.width // 2): + dx += world.width + if dy > world.height // 2: + dy -= world.height + elif dy < -(world.height // 2): + dy += world.height + rel_x = dx + center_x + rel_y = dy + center_y + + if 0 <= rel_x < VIEWPORT_WIDTH and 0 <= rel_y < VIEWPORT_HEIGHT: + mob_positions.append((rel_x, rel_y)) + # Build the output with ANSI coloring - # priority: player @ > other players * > effects > terrain + # priority: player @ > other players * > mobs * > effects > terrain half_width = VIEWPORT_WIDTH // 2 half_height = VIEWPORT_HEIGHT // 2 @@ -68,6 +91,9 @@ async def cmd_look(player: Player, args: str) -> None: # Check if this is another player's position elif (x, y) in other_player_positions: line.append(colorize_terrain("*", player.color_depth)) + # Check if this is a mob's position + elif (x, y) in mob_positions: + line.append(colorize_terrain("*", player.color_depth)) else: # Check for active effects at this world position world_x, world_y = world.wrap( diff --git a/tests/test_mobs.py b/tests/test_mobs.py index a4f5602..12f9903 100644 --- a/tests/test_mobs.py +++ b/tests/test_mobs.py @@ -301,3 +301,108 @@ class TestTargetResolution: assert player.mode == "combat" # Mob has no mode_stack attribute assert not hasattr(mob, "mode_stack") + + +# --- Phase 3: viewport rendering tests --- + + +class TestViewportRendering: + @pytest.fixture + def look_world(self): + """A mock world that returns a flat viewport of '.' tiles.""" + from mudlib.commands.look import VIEWPORT_HEIGHT, VIEWPORT_WIDTH + + w = MagicMock() + w.width = 256 + w.height = 256 + w.get_viewport = MagicMock( + return_value=[ + ["." for _ in range(VIEWPORT_WIDTH)] + for _ in range(VIEWPORT_HEIGHT) + ] + ) + w.wrap = lambda x, y: (x % 256, y % 256) + w.is_passable = MagicMock(return_value=True) + return w + + @pytest.mark.asyncio + async def test_mob_renders_as_star( + self, player, goblin_toml, look_world + ): + """Mob within viewport renders as * in look output.""" + import mudlib.commands.look as look_mod + + old = look_mod.world + look_mod.world = look_world + + template = load_mob_template(goblin_toml) + # Place mob 2 tiles to the right of the player + spawn_mob(template, 2, 0) + + await look_mod.cmd_look(player, "") + + output = "".join( + call[0][0] for call in player.writer.write.call_args_list + ) + # The center is at (10, 5), mob at relative (12, 5) + # Output should contain a * character + assert "*" in output + + look_mod.world = old + + @pytest.mark.asyncio + async def test_mob_outside_viewport_not_rendered( + self, player, goblin_toml, look_world + ): + """Mob outside viewport bounds is not rendered.""" + import mudlib.commands.look as look_mod + + old = look_mod.world + look_mod.world = look_world + + template = load_mob_template(goblin_toml) + # Place mob far away + spawn_mob(template, 100, 100) + + await look_mod.cmd_look(player, "") + + output = "".join( + call[0][0] for call in player.writer.write.call_args_list + ) + # Should only have @ (player) and . (terrain), no * + stripped = output.replace("\033[0m", "").replace("\r\n", "") + # Remove ANSI codes for terrain colors + import re + + stripped = re.sub(r"\033\[[0-9;]*m", "", stripped) + assert "*" not in stripped + + look_mod.world = old + + @pytest.mark.asyncio + async def test_dead_mob_not_rendered( + self, player, goblin_toml, look_world + ): + """Dead mob (alive=False) not rendered in viewport.""" + import mudlib.commands.look as look_mod + + old = look_mod.world + look_mod.world = look_world + + template = load_mob_template(goblin_toml) + mob = spawn_mob(template, 2, 0) + mob.alive = False + + await look_mod.cmd_look(player, "") + + output = "".join( + call[0][0] for call in player.writer.write.call_args_list + ) + import re + + stripped = re.sub(r"\033\[[0-9;]*m", "", output).replace( + "\r\n", "" + ) + assert "*" not in stripped + + look_mod.world = old