From f5646589b5740d64e76f48de7980794f754450e0 Mon Sep 17 00:00:00 2001 From: Jared Miller Date: Wed, 11 Feb 2026 19:35:58 -0500 Subject: [PATCH] Migrate look to use player.location (Zone) - Removed world module-level variable from look.py - look.cmd_look() now uses player.location.get_viewport() instead of world.get_viewport() - look.cmd_look() uses zone.contents_near() to find nearby entities instead of iterating global players/mobs lists - Wrapping calculations use zone.width/height/toroidal instead of world properties - Added type check for player.location being a Zone instance - Removed look.world injection from server.py - Updated all tests to remove look.world injection - spawn_mob() and combat commands also migrated to use Zone (player.location) - Removed orphaned code from test_mob_ai.py and test_variant_prefix.py --- src/mudlib/combat/commands.py | 12 +-- src/mudlib/commands/look.py | 89 +++++++++----------- src/mudlib/commands/spawn.py | 7 +- src/mudlib/mobs.py | 59 +++++++++---- tests/test_commands.py | 18 ---- tests/test_mob_ai.py | 80 +++++++----------- tests/test_mobs.py | 151 ++++++++++++++-------------------- tests/test_server.py | 78 +++++++----------- tests/test_spawn_command.py | 20 ++++- tests/test_variant_prefix.py | 9 -- 10 files changed, 235 insertions(+), 288 deletions(-) diff --git a/src/mudlib/combat/commands.py b/src/mudlib/combat/commands.py index f469d68..0da8b82 100644 --- a/src/mudlib/combat/commands.py +++ b/src/mudlib/combat/commands.py @@ -3,7 +3,6 @@ import asyncio from collections import defaultdict from pathlib import Path -from typing import Any from mudlib.combat.encounter import CombatState from mudlib.combat.engine import get_encounter, start_encounter @@ -15,9 +14,6 @@ from mudlib.player import Player, players combat_moves: dict[str, CombatMove] = {} combat_content_dir: Path | None = None -# World instance will be injected by the server -world: Any = None - async def do_attack(player: Player, target_args: str, move: CombatMove) -> None: """Core attack logic with a resolved move. @@ -34,10 +30,14 @@ async def do_attack(player: Player, target_args: str, move: CombatMove) -> None: target_name = target_args.strip() if encounter is None and target_name: target = players.get(target_name) - if target is None and world is not None: + if target is None and player.location is not None: from mudlib.mobs import get_nearby_mob + from mudlib.zone import Zone - target = get_nearby_mob(target_name, player.x, player.y, world) + if isinstance(player.location, Zone): + target = get_nearby_mob( + target_name, player.x, player.y, player.location + ) # Check stamina if player.stamina < move.stamina_cost: diff --git a/src/mudlib/commands/look.py b/src/mudlib/commands/look.py index b008406..521e7e6 100644 --- a/src/mudlib/commands/look.py +++ b/src/mudlib/commands/look.py @@ -1,15 +1,11 @@ """Look command for viewing the world.""" -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.entity import Entity +from mudlib.player import Player from mudlib.render.ansi import RESET, colorize_terrain - -# World instance will be injected by the server -world: Any = None +from mudlib.zone import Zone # Viewport dimensions VIEWPORT_WIDTH = 21 @@ -23,58 +19,53 @@ async def cmd_look(player: Player, args: str) -> None: player: The player executing the command args: Command arguments (unused for now) """ - # Get the viewport from the world - viewport = world.get_viewport(player.x, player.y, VIEWPORT_WIDTH, VIEWPORT_HEIGHT) + zone = player.location + if zone is None or not isinstance(zone, Zone): + player.writer.write("You are nowhere.\r\n") + await player.writer.drain() + return + + # Get the viewport from the zone + viewport = zone.get_viewport(player.x, player.y, VIEWPORT_WIDTH, VIEWPORT_HEIGHT) # Calculate center position center_x = VIEWPORT_WIDTH // 2 center_y = VIEWPORT_HEIGHT // 2 - # Build a list of (relative_x, relative_y) for other players - other_player_positions = [] - for other in players.values(): - if other.name == player.name: + # Get nearby entities (players and mobs) from the zone + # Viewport half-diagonal distance for range + viewport_range = VIEWPORT_WIDTH // 2 + VIEWPORT_HEIGHT // 2 + nearby = zone.contents_near(player.x, player.y, viewport_range) + + # Build a list of (relative_x, relative_y) for other entities + entity_positions = [] + for obj in nearby: + # Only show entities (players/mobs), not the current player + if not isinstance(obj, Entity) or obj is player: + continue + + # Skip dead mobs + if hasattr(obj, "alive") and not obj.alive: continue # Calculate relative position (shortest path wrapping) - dx = other.x - player.x - dy = other.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 + dx = obj.x - player.x + dy = obj.y - player.y + if zone.toroidal: + if dx > zone.width // 2: + dx -= zone.width + elif dx < -(zone.width // 2): + dx += zone.width + if dy > zone.height // 2: + dy -= zone.height + elif dy < -(zone.height // 2): + dy += zone.height rel_x = dx + center_x rel_y = dy + center_y # Check if within viewport bounds 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)) + entity_positions.append((rel_x, rel_y)) # Build the output with ANSI coloring # priority: player @ > other players * > mobs * > effects > terrain @@ -88,12 +79,12 @@ async def cmd_look(player: Player, args: str) -> None: # Check if this is the player's position if x == center_x and y == center_y: line.append(colorize_terrain("@", player.color_depth)) - # Check if this is another player's position - elif (x, y) in other_player_positions or (x, y) in mob_positions: + # Check if this is another entity's position + elif (x, y) in entity_positions: line.append(colorize_terrain("*", player.color_depth)) else: # Check for active effects at this world position - world_x, world_y = world.wrap( + world_x, world_y = zone.wrap( player.x - half_width + x, player.y - half_height + y, ) diff --git a/src/mudlib/commands/spawn.py b/src/mudlib/commands/spawn.py index 970d1c5..fd41105 100644 --- a/src/mudlib/commands/spawn.py +++ b/src/mudlib/commands/spawn.py @@ -3,6 +3,7 @@ from mudlib.commands import CommandDefinition, register from mudlib.mobs import mob_templates, spawn_mob from mudlib.player import Player +from mudlib.zone import Zone async def cmd_spawn(player: Player, args: str) -> None: @@ -17,7 +18,11 @@ async def cmd_spawn(player: Player, args: str) -> None: await player.send(f"Unknown mob type: {name}\r\nAvailable: {available}\r\n") return - mob = spawn_mob(mob_templates[name], player.x, player.y) + if player.location is None or not isinstance(player.location, Zone): + await player.send("Cannot spawn mob: you are not in a zone.\r\n") + return + + mob = spawn_mob(mob_templates[name], player.x, player.y, player.location) await player.send(f"A {mob.name} appears!\r\n") diff --git a/src/mudlib/mobs.py b/src/mudlib/mobs.py index 7f135ea..5ead000 100644 --- a/src/mudlib/mobs.py +++ b/src/mudlib/mobs.py @@ -3,9 +3,9 @@ import tomllib from dataclasses import dataclass, field from pathlib import Path -from typing import Any from mudlib.entity import Mob +from mudlib.zone import Zone @dataclass @@ -48,10 +48,21 @@ def load_mob_templates(directory: Path) -> dict[str, MobTemplate]: return templates -def spawn_mob(template: MobTemplate, x: int, y: int) -> Mob: - """Create a Mob instance from a template at the given position.""" +def spawn_mob(template: MobTemplate, x: int, y: int, zone: Zone) -> Mob: + """Create a Mob instance from a template at the given position. + + Args: + template: The mob template to spawn from + x: X coordinate in the zone + y: Y coordinate in the zone + zone: The zone where the mob will be spawned + + Returns: + The spawned Mob instance + """ mob = Mob( name=template.name, + location=zone, x=x, y=y, pl=template.pl, @@ -72,28 +83,44 @@ def despawn_mob(mob: Mob) -> None: def get_nearby_mob( - name: str, x: int, y: int, world: Any, range_: int = 10 + name: str, x: int, y: int, zone: Zone, range_: int = 10 ) -> Mob | None: """Find the closest alive mob matching name within range. - Uses wrapping-aware distance (same pattern as send_nearby_message). + Uses zone.contents_near() to find all nearby objects, then filters + for alive mobs matching the name and picks the closest. + + Args: + name: Name of the mob to find + x: X coordinate of the search center + y: Y coordinate of the search center + zone: The zone to search in + range_: Maximum Manhattan distance (default 10) + + Returns: + The closest matching mob, or None if none found """ best: Mob | None = None best_dist = float("inf") - for mob in mobs: - if not mob.alive or mob.name != name: + # Get all nearby objects from the zone + nearby = zone.contents_near(x, y, range_) + + for obj in nearby: + # Filter for alive mobs matching the name + if not isinstance(obj, Mob) or not obj.alive or obj.name != name: continue - dx = abs(mob.x - x) - dy = abs(mob.y - y) - dx = min(dx, world.width - dx) - dy = min(dy, world.height - dy) + # Calculate wrapping-aware distance to find closest + dx = abs(obj.x - x) + dy = abs(obj.y - y) + if zone.toroidal: + dx = min(dx, zone.width - dx) + dy = min(dy, zone.height - dy) - if dx <= range_ and dy <= range_: - dist = dx + dy - if dist < best_dist: - best = mob - best_dist = dist + dist = dx + dy + if dist < best_dist: + best = obj + best_dist = dist return best diff --git a/tests/test_commands.py b/tests/test_commands.py index 61df505..e97ac4d 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -140,9 +140,6 @@ def test_direction_deltas(direction, expected_delta): @pytest.mark.asyncio async def test_movement_updates_position(player, test_zone): """Test that movement updates player position when passable.""" - # Inject test_zone into look command (still uses module-level world) - look.world = test_zone - # Clear players registry to avoid test pollution from mudlib.player import players @@ -179,8 +176,6 @@ async def test_movement_blocked_by_impassable_terrain(player, test_zone, mock_wr @pytest.mark.asyncio async def test_movement_sends_departure_message(player, test_zone): """Test that movement sends departure message to nearby players.""" - look.world = test_zone - # Create another player in the area other_writer = MagicMock() other_writer.write = MagicMock() @@ -208,8 +203,6 @@ async def test_movement_sends_departure_message(player, test_zone): @pytest.mark.asyncio async def test_arrival_message_uses_opposite_direction(player, test_zone): """Test that arrival messages use the opposite direction.""" - look.world = test_zone - # Create another player at the destination other_writer = MagicMock() other_writer.write = MagicMock() @@ -238,9 +231,6 @@ async def test_arrival_message_uses_opposite_direction(player, test_zone): @pytest.mark.asyncio async def test_look_command_sends_viewport(player, test_zone): """Test that look command sends the viewport to the player.""" - # look.py still uses module-level world, so inject test_zone - look.world = test_zone - await look.cmd_look(player, "") assert player.writer.write.called @@ -249,8 +239,6 @@ async def test_look_command_sends_viewport(player, test_zone): @pytest.mark.asyncio async def test_look_command_shows_player_at_center(player, test_zone): """Test that look command shows player @ at center.""" - look.world = test_zone - await look.cmd_look(player, "") # Check that the output contains the @ symbol for the player @@ -261,8 +249,6 @@ async def test_look_command_shows_player_at_center(player, test_zone): @pytest.mark.asyncio async def test_look_command_shows_other_players(player, test_zone): """Test that look command shows other players as *.""" - look.world = test_zone - # Create another player in the viewport other_player = Player( name="OtherPlayer", @@ -290,8 +276,6 @@ async def test_look_command_shows_other_players(player, test_zone): @pytest.mark.asyncio async def test_look_shows_effects_on_viewport(player, test_zone): """Test that active effects overlay on the viewport.""" - look.world = test_zone - from mudlib.player import players players.clear() @@ -315,8 +299,6 @@ async def test_look_shows_effects_on_viewport(player, test_zone): @pytest.mark.asyncio async def test_effects_dont_override_player_marker(player, test_zone): """Effects at the player's position should not hide the @ marker.""" - look.world = test_zone - from mudlib.player import players players.clear() diff --git a/tests/test_mob_ai.py b/tests/test_mob_ai.py index 1fab528..e80816d 100644 --- a/tests/test_mob_ai.py +++ b/tests/test_mob_ai.py @@ -51,34 +51,6 @@ def test_zone(): return zone -@pytest.fixture(autouse=True) -def inject_world_for_combat(test_zone): - """Inject test_zone into combat commands (still uses module-level world).""" - old_combat = combat_commands.world - combat_commands.world = test_zone - yield test_zone - combat_commands.world = old_combat - - -@pytest.fixture(autouse=True) -def auto_zone_mobs(test_zone, monkeypatch): - """Monkeypatch spawn_mob to automatically add mobs to test_zone.""" - original_spawn = spawn_mob - - def spawn_and_zone(template, x, y): - mob = original_spawn(template, x, y) - mob.location = test_zone - test_zone._contents.append(mob) - return mob - - monkeypatch.setattr("mudlib.mobs.spawn_mob", spawn_and_zone) - # Also patch it in the test module's import - import mudlib.mob_ai as mob_ai_mod - - if hasattr(mob_ai_mod, "spawn_mob"): - monkeypatch.setattr(mob_ai_mod, "spawn_mob", spawn_and_zone) - - @pytest.fixture def mock_writer(): writer = MagicMock() @@ -147,11 +119,11 @@ def dummy_toml(tmp_path): class TestMobAttackAI: @pytest.mark.asyncio async def test_mob_attacks_when_idle_and_cooldown_expired( - self, player, goblin_toml, moves + self, player, goblin_toml, moves, test_zone ): """Mob attacks when encounter is IDLE and cooldown has expired.""" template = load_mob_template(goblin_toml) - mob = spawn_mob(template, 0, 0) + mob = spawn_mob(template, 0, 0, test_zone) mob.next_action_at = 0.0 # cooldown expired encounter = start_encounter(player, mob) @@ -164,10 +136,12 @@ class TestMobAttackAI: assert encounter.current_move is not None @pytest.mark.asyncio - async def test_mob_picks_from_its_own_moves(self, player, goblin_toml, moves): + async def test_mob_picks_from_its_own_moves( + self, player, goblin_toml, moves, test_zone + ): """Mob only picks moves from its moves list.""" template = load_mob_template(goblin_toml) - mob = spawn_mob(template, 0, 0) + mob = spawn_mob(template, 0, 0, test_zone) mob.next_action_at = 0.0 encounter = start_encounter(player, mob) @@ -179,10 +153,12 @@ class TestMobAttackAI: assert encounter.current_move.name in mob.moves @pytest.mark.asyncio - async def test_mob_skips_when_stamina_too_low(self, player, goblin_toml, moves): + async def test_mob_skips_when_stamina_too_low( + self, player, goblin_toml, moves, test_zone + ): """Mob skips attack when stamina is too low for any move.""" template = load_mob_template(goblin_toml) - mob = spawn_mob(template, 0, 0) + mob = spawn_mob(template, 0, 0, test_zone) mob.stamina = 0.0 mob.next_action_at = 0.0 @@ -195,10 +171,10 @@ class TestMobAttackAI: assert encounter.state == CombatState.IDLE @pytest.mark.asyncio - async def test_mob_respects_cooldown(self, player, goblin_toml, moves): + async def test_mob_respects_cooldown(self, player, goblin_toml, moves, test_zone): """Mob doesn't act when cooldown hasn't expired.""" template = load_mob_template(goblin_toml) - mob = spawn_mob(template, 0, 0) + mob = spawn_mob(template, 0, 0, test_zone) mob.next_action_at = time.monotonic() + 100.0 # far in the future encounter = start_encounter(player, mob) @@ -210,10 +186,12 @@ class TestMobAttackAI: assert encounter.state == CombatState.IDLE @pytest.mark.asyncio - async def test_mob_swaps_roles_when_defending(self, player, goblin_toml, moves): + async def test_mob_swaps_roles_when_defending( + self, player, goblin_toml, moves, test_zone + ): """Mob swaps attacker/defender roles when it attacks as defender.""" template = load_mob_template(goblin_toml) - mob = spawn_mob(template, 0, 0) + mob = spawn_mob(template, 0, 0, test_zone) mob.next_action_at = 0.0 # Player is attacker, mob is defender @@ -227,10 +205,10 @@ class TestMobAttackAI: assert encounter.defender is player @pytest.mark.asyncio - async def test_mob_doesnt_act_outside_combat(self, goblin_toml, moves): + async def test_mob_doesnt_act_outside_combat(self, goblin_toml, moves, test_zone): """Mob not in combat does nothing.""" template = load_mob_template(goblin_toml) - mob = spawn_mob(template, 0, 0) + mob = spawn_mob(template, 0, 0, test_zone) mob.next_action_at = 0.0 await process_mobs(moves) @@ -239,10 +217,12 @@ class TestMobAttackAI: assert get_encounter(mob) is None @pytest.mark.asyncio - async def test_mob_sets_cooldown_after_attack(self, player, goblin_toml, moves): + async def test_mob_sets_cooldown_after_attack( + self, player, goblin_toml, moves, test_zone + ): """Mob sets next_action_at after attacking.""" template = load_mob_template(goblin_toml) - mob = spawn_mob(template, 0, 0) + mob = spawn_mob(template, 0, 0, test_zone) mob.next_action_at = 0.0 start_encounter(player, mob) @@ -262,12 +242,12 @@ class TestMobDefenseAI: @pytest.mark.asyncio async def test_mob_defends_during_telegraph( - self, player, goblin_toml, moves, punch_right + self, player, goblin_toml, moves, punch_right, test_zone ): """Mob attempts defense during TELEGRAPH phase.""" template = load_mob_template(goblin_toml) # Give the mob defense moves - mob = spawn_mob(template, 0, 0) + mob = spawn_mob(template, 0, 0, test_zone) mob.moves = ["punch left", "dodge left", "dodge right"] mob.next_action_at = 0.0 @@ -285,11 +265,11 @@ class TestMobDefenseAI: @pytest.mark.asyncio async def test_mob_skips_defense_when_already_defending( - self, player, goblin_toml, moves, punch_right + self, player, goblin_toml, moves, punch_right, test_zone ): """Mob doesn't double-defend if already has pending_defense.""" template = load_mob_template(goblin_toml) - mob = spawn_mob(template, 0, 0) + mob = spawn_mob(template, 0, 0, test_zone) mob.moves = ["dodge left", "dodge right"] mob.next_action_at = 0.0 @@ -308,11 +288,11 @@ class TestMobDefenseAI: @pytest.mark.asyncio async def test_mob_no_defense_without_defense_moves( - self, player, goblin_toml, moves, punch_right + self, player, goblin_toml, moves, punch_right, test_zone ): """Mob with no defense moves in its list can't defend.""" template = load_mob_template(goblin_toml) - mob = spawn_mob(template, 0, 0) + mob = spawn_mob(template, 0, 0, test_zone) # Only attack moves mob.moves = ["punch left", "punch right", "sweep"] mob.next_action_at = time.monotonic() + 100.0 # prevent attacking @@ -328,11 +308,11 @@ class TestMobDefenseAI: @pytest.mark.asyncio async def test_dummy_never_fights_back( - self, player, dummy_toml, moves, punch_right + self, player, dummy_toml, moves, punch_right, test_zone ): """Training dummy with empty moves never attacks or defends.""" template = load_mob_template(dummy_toml) - mob = spawn_mob(template, 0, 0) + mob = spawn_mob(template, 0, 0, test_zone) mob.next_action_at = 0.0 encounter = start_encounter(player, mob) diff --git a/tests/test_mobs.py b/tests/test_mobs.py index aaf52b9..7a06db7 100644 --- a/tests/test_mobs.py +++ b/tests/test_mobs.py @@ -49,29 +49,6 @@ def test_zone(): return zone -@pytest.fixture(autouse=True) -def inject_world_for_combat(test_zone): - """Inject test_zone into combat commands (still uses module-level world).""" - old_combat = combat_commands.world - combat_commands.world = test_zone - yield test_zone - combat_commands.world = old_combat - - -@pytest.fixture(autouse=True) -def auto_zone_mobs(test_zone, monkeypatch): - """Monkeypatch spawn_mob to automatically add mobs to test_zone.""" - original_spawn = spawn_mob - - def spawn_and_zone(template, x, y): - mob = original_spawn(template, x, y) - mob.location = test_zone - test_zone._contents.append(mob) - return mob - - monkeypatch.setattr("mudlib.mobs.spawn_mob", spawn_and_zone) - - @pytest.fixture def goblin_toml(tmp_path): """Create a goblin TOML file.""" @@ -125,9 +102,9 @@ class TestLoadTemplate: class TestSpawnDespawn: - def test_spawn_creates_mob(self, goblin_toml): + def test_spawn_creates_mob(self, goblin_toml, test_zone): template = load_mob_template(goblin_toml) - mob = spawn_mob(template, 10, 20) + mob = spawn_mob(template, 10, 20, test_zone) assert isinstance(mob, Mob) assert mob.name == "goblin" assert mob.x == 10 @@ -138,72 +115,67 @@ class TestSpawnDespawn: assert mob.moves == ["punch left", "punch right", "sweep"] assert mob.alive is True assert mob in mobs + assert mob.location is test_zone + assert mob in test_zone._contents - def test_spawn_adds_to_registry(self, goblin_toml): + def test_spawn_adds_to_registry(self, goblin_toml, test_zone): template = load_mob_template(goblin_toml) - spawn_mob(template, 0, 0) - spawn_mob(template, 5, 5) + spawn_mob(template, 0, 0, test_zone) + spawn_mob(template, 5, 5, test_zone) assert len(mobs) == 2 - def test_despawn_removes_from_list(self, goblin_toml): + def test_despawn_removes_from_list(self, goblin_toml, test_zone): template = load_mob_template(goblin_toml) - mob = spawn_mob(template, 0, 0) + mob = spawn_mob(template, 0, 0, test_zone) despawn_mob(mob) assert mob not in mobs assert mob.alive is False - def test_despawn_sets_alive_false(self, goblin_toml): + def test_despawn_sets_alive_false(self, goblin_toml, test_zone): template = load_mob_template(goblin_toml) - mob = spawn_mob(template, 0, 0) + mob = spawn_mob(template, 0, 0, test_zone) despawn_mob(mob) assert mob.alive is False class TestGetNearbyMob: - @pytest.fixture - def mock_world(self): - w = MagicMock() - w.width = 256 - w.height = 256 - return w - - def test_finds_by_name_within_range(self, goblin_toml, mock_world): + def test_finds_by_name_within_range(self, goblin_toml, test_zone): template = load_mob_template(goblin_toml) - mob = spawn_mob(template, 5, 5) - found = get_nearby_mob("goblin", 3, 3, mock_world) + mob = spawn_mob(template, 5, 5, test_zone) + found = get_nearby_mob("goblin", 3, 3, test_zone) assert found is mob - def test_returns_none_when_out_of_range(self, goblin_toml, mock_world): + def test_returns_none_when_out_of_range(self, goblin_toml, test_zone): template = load_mob_template(goblin_toml) - spawn_mob(template, 100, 100) - found = get_nearby_mob("goblin", 0, 0, mock_world) + spawn_mob(template, 100, 100, test_zone) + found = get_nearby_mob("goblin", 0, 0, test_zone) assert found is None - def test_returns_none_for_wrong_name(self, goblin_toml, mock_world): + def test_returns_none_for_wrong_name(self, goblin_toml, test_zone): template = load_mob_template(goblin_toml) - spawn_mob(template, 5, 5) - found = get_nearby_mob("dragon", 3, 3, mock_world) + spawn_mob(template, 5, 5, test_zone) + found = get_nearby_mob("dragon", 3, 3, test_zone) assert found is None - def test_picks_closest_when_multiple(self, goblin_toml, mock_world): + def test_picks_closest_when_multiple(self, goblin_toml, test_zone): template = load_mob_template(goblin_toml) - spawn_mob(template, 8, 8) - close_mob = spawn_mob(template, 1, 1) - found = get_nearby_mob("goblin", 0, 0, mock_world) + spawn_mob(template, 8, 8, test_zone) + close_mob = spawn_mob(template, 1, 1, test_zone) + found = get_nearby_mob("goblin", 0, 0, test_zone) assert found is close_mob - def test_skips_dead_mobs(self, goblin_toml, mock_world): + def test_skips_dead_mobs(self, goblin_toml, test_zone): template = load_mob_template(goblin_toml) - mob = spawn_mob(template, 5, 5) + mob = spawn_mob(template, 5, 5, test_zone) mob.alive = False - found = get_nearby_mob("goblin", 3, 3, mock_world) + found = get_nearby_mob("goblin", 3, 3, test_zone) assert found is None - def test_wrapping_distance(self, goblin_toml, mock_world): + def test_wrapping_distance(self, goblin_toml, test_zone): """Mob near world edge is close to player at opposite edge.""" template = load_mob_template(goblin_toml) - mob = spawn_mob(template, 254, 254) - found = get_nearby_mob("goblin", 2, 2, mock_world, range_=10) + mob = spawn_mob(template, 254, 254, test_zone) + found = get_nearby_mob("goblin", 2, 2, test_zone, range_=10) assert found is mob @@ -224,8 +196,10 @@ def mock_reader(): @pytest.fixture -def player(mock_reader, mock_writer): +def player(mock_reader, mock_writer, test_zone): p = Player(name="Goku", x=0, y=0, reader=mock_reader, writer=mock_writer) + p.location = test_zone + test_zone._contents.append(p) players[p.name] = p return p @@ -252,10 +226,12 @@ def punch_right(moves): class TestTargetResolution: @pytest.mark.asyncio - async def test_attack_mob_by_name(self, player, punch_right, goblin_toml): + async def test_attack_mob_by_name( + self, player, punch_right, goblin_toml, test_zone + ): """do_attack with mob name finds and engages the mob.""" template = load_mob_template(goblin_toml) - mob = spawn_mob(template, 0, 0) + mob = spawn_mob(template, 0, 0, test_zone) await combat_commands.do_attack(player, "goblin", punch_right) @@ -266,11 +242,11 @@ class TestTargetResolution: @pytest.mark.asyncio async def test_attack_prefers_player_over_mob( - self, player, punch_right, goblin_toml, mock_reader, mock_writer + self, player, punch_right, goblin_toml, mock_reader, mock_writer, test_zone ): """When a player and mob share a name, player takes priority.""" template = load_mob_template(goblin_toml) - spawn_mob(template, 0, 0) + spawn_mob(template, 0, 0, test_zone) # Create a player named "goblin" goblin_player = Player( @@ -280,6 +256,8 @@ class TestTargetResolution: reader=mock_reader, writer=mock_writer, ) + goblin_player.location = test_zone + test_zone._contents.append(goblin_player) players["goblin"] = goblin_player await combat_commands.do_attack(player, "goblin", punch_right) @@ -289,10 +267,12 @@ class TestTargetResolution: assert encounter.defender is goblin_player @pytest.mark.asyncio - async def test_attack_mob_out_of_range(self, player, punch_right, goblin_toml): + async def test_attack_mob_out_of_range( + self, player, punch_right, goblin_toml, test_zone + ): """Mob outside viewport range is not found as target.""" template = load_mob_template(goblin_toml) - spawn_mob(template, 100, 100) + spawn_mob(template, 100, 100, test_zone) await combat_commands.do_attack(player, "goblin", punch_right) @@ -302,10 +282,12 @@ class TestTargetResolution: assert any("need a target" in msg.lower() for msg in messages) @pytest.mark.asyncio - async def test_encounter_mob_no_mode_push(self, player, punch_right, goblin_toml): + async def test_encounter_mob_no_mode_push( + self, player, punch_right, goblin_toml, test_zone + ): """Mob doesn't get mode_stack push (it has no mode_stack).""" template = load_mob_template(goblin_toml) - mob = spawn_mob(template, 0, 0) + mob = spawn_mob(template, 0, 0, test_zone) await combat_commands.do_attack(player, "goblin", punch_right) @@ -337,16 +319,15 @@ class TestViewportRendering: return w @pytest.mark.asyncio - async def test_mob_renders_as_star(self, player, goblin_toml, look_world): + async def test_mob_renders_as_star( + self, player, goblin_toml, look_world, test_zone + ): """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) + spawn_mob(template, 2, 0, test_zone) await look_mod.cmd_look(player, "") @@ -355,21 +336,16 @@ class TestViewportRendering: # 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 + self, player, goblin_toml, look_world, test_zone ): """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) + spawn_mob(template, 100, 100, test_zone) await look_mod.cmd_look(player, "") @@ -382,18 +358,15 @@ class TestViewportRendering: 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): + async def test_dead_mob_not_rendered( + self, player, goblin_toml, look_world, test_zone + ): """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 = spawn_mob(template, 2, 0, test_zone) mob.alive = False await look_mod.cmd_look(player, "") @@ -404,17 +377,15 @@ class TestViewportRendering: stripped = re.sub(r"\033\[[0-9;]*m", "", output).replace("\r\n", "") assert "*" not in stripped - look_mod.world = old - # --- Phase 4: mob defeat tests --- class TestMobDefeat: @pytest.fixture - def goblin_mob(self, goblin_toml): + def goblin_mob(self, goblin_toml, test_zone): template = load_mob_template(goblin_toml) - return spawn_mob(template, 0, 0) + return spawn_mob(template, 0, 0, test_zone) @pytest.mark.asyncio async def test_mob_despawned_on_pl_zero(self, player, goblin_mob, punch_right): diff --git a/tests/test_server.py b/tests/test_server.py index 29aa9b0..6b9571f 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -69,32 +69,23 @@ async def test_shell_greets_and_accepts_commands(temp_db): writer.drain = AsyncMock() writer.close = MagicMock() - # Need to mock the look command's world reference too - import mudlib.commands.look + readline = "mudlib.server.readline2" + with patch(readline, new_callable=AsyncMock) as mock_readline: + # Simulate: name, create account (y), password, confirm password, look, quit + mock_readline.side_effect = [ + "TestPlayer", + "y", + "password", + "password", + "look", + "quit", + ] + await server.shell(reader, writer) - original_world = mudlib.commands.look.world - mudlib.commands.look.world = world - - try: - readline = "mudlib.server.readline2" - with patch(readline, new_callable=AsyncMock) as mock_readline: - # Simulate: name, create account (y), password, confirm password, look, quit - mock_readline.side_effect = [ - "TestPlayer", - "y", - "password", - "password", - "look", - "quit", - ] - await server.shell(reader, writer) - - calls = [str(call) for call in writer.write.call_args_list] - assert any("Welcome" in call for call in calls) - assert any("TestPlayer" in call for call in calls) - writer.close.assert_called() - finally: - mudlib.commands.look.world = original_world + calls = [str(call) for call in writer.write.call_args_list] + assert any("Welcome" in call for call in calls) + assert any("TestPlayer" in call for call in calls) + writer.close.assert_called() @pytest.mark.asyncio @@ -135,30 +126,21 @@ async def test_shell_handles_quit(temp_db): writer.drain = AsyncMock() writer.close = MagicMock() - # Need to mock the look command's world reference too - import mudlib.commands.look + readline = "mudlib.server.readline2" + with patch(readline, new_callable=AsyncMock) as mock_readline: + # Simulate: name, create account (y), password, confirm password, quit + mock_readline.side_effect = [ + "TestPlayer", + "y", + "password", + "password", + "quit", + ] + await server.shell(reader, writer) - original_world = mudlib.commands.look.world - mudlib.commands.look.world = world - - try: - readline = "mudlib.server.readline2" - with patch(readline, new_callable=AsyncMock) as mock_readline: - # Simulate: name, create account (y), password, confirm password, quit - mock_readline.side_effect = [ - "TestPlayer", - "y", - "password", - "password", - "quit", - ] - await server.shell(reader, writer) - - calls = [str(call) for call in writer.write.call_args_list] - assert any("Goodbye" in call for call in calls) - writer.close.assert_called() - finally: - mudlib.commands.look.world = original_world + calls = [str(call) for call in writer.write.call_args_list] + assert any("Goodbye" in call for call in calls) + writer.close.assert_called() def test_load_world_config(): diff --git a/tests/test_spawn_command.py b/tests/test_spawn_command.py index a124293..3733a0f 100644 --- a/tests/test_spawn_command.py +++ b/tests/test_spawn_command.py @@ -7,6 +7,7 @@ import pytest from mudlib.commands.spawn import cmd_spawn from mudlib.mobs import MobTemplate, mob_templates, mobs from mudlib.player import Player, players +from mudlib.zone import Zone @pytest.fixture(autouse=True) @@ -35,8 +36,25 @@ def mock_reader(): @pytest.fixture -def player(mock_reader, mock_writer): +def test_zone(): + """Create a test zone for spawning.""" + terrain = [["." for _ in range(256)] for _ in range(256)] + zone = Zone( + name="testzone", + width=256, + height=256, + toroidal=True, + terrain=terrain, + impassable=set(), + ) + return zone + + +@pytest.fixture +def player(mock_reader, mock_writer, test_zone): p = Player(name="Goku", x=10, y=20, reader=mock_reader, writer=mock_writer) + p.location = test_zone + test_zone._contents.append(p) players[p.name] = p return p diff --git a/tests/test_variant_prefix.py b/tests/test_variant_prefix.py index 775430f..c84fa7e 100644 --- a/tests/test_variant_prefix.py +++ b/tests/test_variant_prefix.py @@ -37,15 +37,6 @@ def test_zone(): return zone -@pytest.fixture(autouse=True) -def inject_world_for_combat(test_zone): - """Inject test_zone into combat commands (still uses module-level world).""" - old_combat = combat_commands.world - combat_commands.world = test_zone - yield test_zone - combat_commands.world = old_combat - - @pytest.fixture def mock_writer(): writer = MagicMock()