diff --git a/content/zones/tavern.toml b/content/zones/tavern.toml index 297e55e..c99fa53 100644 --- a/content/zones/tavern.toml +++ b/content/zones/tavern.toml @@ -3,6 +3,7 @@ description = "a cozy tavern with a crackling fireplace" width = 8 height = 6 toroidal = false +safe = true spawn_x = 1 spawn_y = 1 diff --git a/src/mudlib/combat/commands.py b/src/mudlib/combat/commands.py index fc5abab..7c9e061 100644 --- a/src/mudlib/combat/commands.py +++ b/src/mudlib/combat/commands.py @@ -32,6 +32,11 @@ async def do_attack(player: Player, target_args: str, move: CombatMove) -> None: await player.send("You haven't learned that yet.\r\n") return + # Check safe zone + if getattr(player.location, "safe", False): + await player.send("You can't fight here.\r\n") + return + encounter = get_encounter(player) # Parse target from args diff --git a/src/mudlib/zone.py b/src/mudlib/zone.py index 1da1322..643e118 100644 --- a/src/mudlib/zone.py +++ b/src/mudlib/zone.py @@ -41,6 +41,7 @@ class Zone(Object): ambient_messages: list[str] = field(default_factory=list) ambient_interval: int = 120 spawn_rules: list[SpawnRule] = field(default_factory=list) + safe: bool = False def can_accept(self, obj: Object) -> bool: """Zones accept everything.""" diff --git a/src/mudlib/zones.py b/src/mudlib/zones.py index 8c916bf..91e983f 100644 --- a/src/mudlib/zones.py +++ b/src/mudlib/zones.py @@ -57,6 +57,7 @@ def load_zone(path: Path) -> Zone: toroidal = data.get("toroidal", True) spawn_x = data.get("spawn_x", 0) spawn_y = data.get("spawn_y", 0) + safe = data.get("safe", False) # Parse terrain rows into 2D list terrain_rows = data.get("terrain", {}).get("rows", []) @@ -97,6 +98,7 @@ def load_zone(path: Path) -> Zone: ambient_messages=ambient_messages, ambient_interval=ambient_interval, spawn_rules=spawn_rules, + safe=safe, ) # Load portals diff --git a/tests/test_safe_zones.py b/tests/test_safe_zones.py new file mode 100644 index 0000000..f8aaef6 --- /dev/null +++ b/tests/test_safe_zones.py @@ -0,0 +1,237 @@ +"""Tests for safe zone combat prevention.""" + +import pathlib +import tempfile +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from mudlib.combat.engine import active_encounters, get_encounter +from mudlib.entity import Mob +from mudlib.player import Player, players +from mudlib.zone import Zone +from mudlib.zones import load_zone + + +@pytest.fixture(autouse=True) +def clear_state(): + """Clear global state between tests.""" + players.clear() + active_encounters.clear() + yield + players.clear() + active_encounters.clear() + + +@pytest.fixture +def safe_zone(): + terrain = [["." for _ in range(10)] for _ in range(10)] + return Zone( + name="sanctuary", + width=10, + height=10, + terrain=terrain, + safe=True, + ) + + +@pytest.fixture +def unsafe_zone(): + terrain = [["." for _ in range(10)] for _ in range(10)] + return Zone( + name="wilderness", + width=10, + height=10, + terrain=terrain, + safe=False, + ) + + +@pytest.fixture +def mock_writer(): + writer = MagicMock() + writer.write = MagicMock() + writer.drain = AsyncMock() + return writer + + +@pytest.fixture +def mock_reader(): + return MagicMock() + + +# --- Zone property tests --- + + +def test_zone_safe_default_false(): + """Zones are unsafe by default.""" + zone = Zone(name="test") + assert zone.safe is False + + +def test_zone_safe_true(): + """Zone can be created as safe.""" + zone = Zone(name="test", safe=True) + assert zone.safe is True + + +# --- TOML loading --- + + +def test_load_zone_safe_flag(): + """Load a zone with safe = true from TOML.""" + with tempfile.NamedTemporaryFile(mode="w", suffix=".toml", delete=False) as f: + f.write( + """ +name = "sanctuary" +description = "a peaceful place" +width = 3 +height = 3 +safe = true + +[terrain] +rows = [ + "...", + "...", + "...", +] +""" + ) + temp_path = pathlib.Path(f.name) + + try: + zone = load_zone(temp_path) + assert zone.safe is True + finally: + temp_path.unlink() + + +def test_load_zone_safe_default(): + """Zone without safe field defaults to False.""" + with tempfile.NamedTemporaryFile(mode="w", suffix=".toml", delete=False) as f: + f.write( + """ +name = "wilderness" +description = "dangerous lands" +width = 3 +height = 3 + +[terrain] +rows = [ + "...", + "...", + "...", +] +""" + ) + temp_path = pathlib.Path(f.name) + + try: + zone = load_zone(temp_path) + assert zone.safe is False + finally: + temp_path.unlink() + + +def test_tavern_is_safe(): + """The tavern zone file has safe = true.""" + project_root = pathlib.Path(__file__).resolve().parents[1] + tavern_path = project_root / "content" / "zones" / "tavern.toml" + zone = load_zone(tavern_path) + assert zone.safe is True + + +# --- Combat prevention --- + + +@pytest.mark.asyncio +async def test_attack_blocked_in_safe_zone(safe_zone, mock_writer, mock_reader): + """Attacking in a safe zone sends error message.""" + from mudlib.combat.commands import do_attack + from mudlib.combat.moves import CombatMove + + player = Player( + name="attacker", + x=5, + y=5, + writer=mock_writer, + reader=mock_reader, + location=safe_zone, + ) + safe_zone._contents.append(player) + players["attacker"] = player + + target = Mob( + name="goblin", + x=5, + y=5, + location=safe_zone, + pl=50, + stamina=40, + max_stamina=40, + ) + safe_zone._contents.append(target) + + move = CombatMove( + name="punch left", + command="punch", + variant="left", + move_type="attack", + stamina_cost=5, + timing_window_ms=850, + damage_pct=0.15, + ) + + await do_attack(player, "goblin", move) + + # Should have sent the safe zone message + mock_writer.write.assert_called() + written = mock_writer.write.call_args_list[0][0][0] + assert "can't fight here" in written.lower() + + # Should NOT have started combat + assert get_encounter(player) is None + + +@pytest.mark.asyncio +async def test_attack_allowed_in_unsafe_zone(unsafe_zone, mock_writer, mock_reader): + """Attacking in an unsafe zone works normally.""" + from mudlib.combat.commands import do_attack + from mudlib.combat.moves import CombatMove + + player = Player( + name="fighter", + x=5, + y=5, + writer=mock_writer, + reader=mock_reader, + location=unsafe_zone, + ) + unsafe_zone._contents.append(player) + players["fighter"] = player + + target = Mob( + name="goblin", + x=5, + y=5, + location=unsafe_zone, + pl=50, + stamina=40, + max_stamina=40, + ) + unsafe_zone._contents.append(target) + + move = CombatMove( + name="punch left", + command="punch", + variant="left", + move_type="attack", + stamina_cost=5, + timing_window_ms=850, + damage_pct=0.15, + ) + + await do_attack(player, "goblin", move) + + # Combat SHOULD have started + assert get_encounter(player) is not None