From c3884e236bf5594870c0b0940895c392029a96c4 Mon Sep 17 00:00:00 2001 From: Jared Miller Date: Wed, 11 Feb 2026 22:13:29 -0500 Subject: [PATCH] Add per-zone mob spawn rules Zones can now define spawn rules in TOML: - [[spawns]] sections specify mob type, max count, and respawn timer - SpawnRule dataclass stores configuration - load_zone() parses spawn rules from TOML - Added example spawn rules to treehouse zone (squirrel, crow) This is configuration infrastructure only - actual spawning logic will be handled by the game loop in a future phase. --- content/zones/treehouse.toml | 10 ++ src/mudlib/zone.py | 32 +++++ src/mudlib/zones.py | 21 +++- tests/test_mob_spawns.py | 101 ++++++++++++++++ tests/test_paint_mode.py | 223 +++++++++++++++++++++++++++++++++++ 5 files changed, 386 insertions(+), 1 deletion(-) create mode 100644 tests/test_mob_spawns.py create mode 100644 tests/test_paint_mode.py diff --git a/content/zones/treehouse.toml b/content/zones/treehouse.toml index 0ac1cf2..c85a5fe 100644 --- a/content/zones/treehouse.toml +++ b/content/zones/treehouse.toml @@ -39,3 +39,13 @@ x = 0 y = 7 target = "hub:7,14" label = "a narrow branch leads to a distant platform" + +[[spawns]] +mob = "squirrel" +max_count = 2 +respawn_seconds = 180 + +[[spawns]] +mob = "crow" +max_count = 1 +respawn_seconds = 300 diff --git a/src/mudlib/zone.py b/src/mudlib/zone.py index 3bc875f..c211d64 100644 --- a/src/mudlib/zone.py +++ b/src/mudlib/zone.py @@ -2,11 +2,25 @@ from __future__ import annotations +import random from dataclasses import dataclass, field from mudlib.object import Object +@dataclass +class SpawnRule: + """Configuration for spawning mobs in a zone. + + Defines what mob should spawn, how many can exist at once, + and how long to wait before respawning after one dies. + """ + + mob: str + max_count: int = 1 + respawn_seconds: int = 300 + + @dataclass class Zone(Object): """A spatial area with a grid of terrain tiles. @@ -23,6 +37,9 @@ class Zone(Object): impassable: set[str] = field(default_factory=lambda: {"^", "~"}) spawn_x: int = 0 spawn_y: int = 0 + ambient_messages: list[str] = field(default_factory=list) + ambient_interval: int = 120 + spawn_rules: list[SpawnRule] = field(default_factory=list) def can_accept(self, obj: Object) -> bool: """Zones accept everything.""" @@ -103,3 +120,18 @@ class Zone(Object): nearby.append(obj) return nearby + + +def get_ambient_message(zone: Zone) -> str | None: + """Return a random ambient message from the zone's list. + + Args: + zone: The zone to get an ambient message from + + Returns: + A random message from the zone's ambient_messages list, + or None if the list is empty + """ + if not zone.ambient_messages: + return None + return random.choice(zone.ambient_messages) diff --git a/src/mudlib/zones.py b/src/mudlib/zones.py index b501332..46f52e4 100644 --- a/src/mudlib/zones.py +++ b/src/mudlib/zones.py @@ -7,7 +7,7 @@ import tomllib from pathlib import Path from mudlib.portal import Portal -from mudlib.zone import Zone +from mudlib.zone import SpawnRule, Zone log = logging.getLogger(__name__) @@ -67,6 +67,22 @@ def load_zone(path: Path) -> Zone: impassable_list = data.get("terrain", {}).get("impassable", {}).get("tiles", []) impassable = set(impassable_list) if impassable_list else {"^", "~"} + # Parse ambient messages + ambient_data = data.get("ambient", {}) + ambient_messages = ambient_data.get("messages", []) + ambient_interval = ambient_data.get("interval", 120) + + # Parse spawn rules + spawns_data = data.get("spawns", []) + spawn_rules = [ + SpawnRule( + mob=spawn["mob"], + max_count=spawn.get("max_count", 1), + respawn_seconds=spawn.get("respawn_seconds", 300), + ) + for spawn in spawns_data + ] + zone = Zone( name=name, width=width, @@ -76,6 +92,9 @@ def load_zone(path: Path) -> Zone: impassable=impassable, spawn_x=spawn_x, spawn_y=spawn_y, + ambient_messages=ambient_messages, + ambient_interval=ambient_interval, + spawn_rules=spawn_rules, ) # Load portals diff --git a/tests/test_mob_spawns.py b/tests/test_mob_spawns.py new file mode 100644 index 0000000..f9b9636 --- /dev/null +++ b/tests/test_mob_spawns.py @@ -0,0 +1,101 @@ +"""Tests for per-zone mob spawn rules.""" + +import tempfile +from pathlib import Path + +from mudlib.zone import SpawnRule, Zone +from mudlib.zones import load_zone + + +def test_zone_default_no_spawn_rules(): + """Zone() has empty spawn_rules list by default.""" + zone = Zone(name="test", width=10, height=10) + assert zone.spawn_rules == [] + + +def test_zone_with_spawn_rules(): + """Zone with spawn rules stores them.""" + rule1 = SpawnRule(mob="rat", max_count=3, respawn_seconds=300) + rule2 = SpawnRule(mob="goblin", max_count=1, respawn_seconds=600) + zone = Zone(name="test", width=10, height=10, spawn_rules=[rule1, rule2]) + + assert len(zone.spawn_rules) == 2 + assert zone.spawn_rules[0].mob == "rat" + assert zone.spawn_rules[0].max_count == 3 + assert zone.spawn_rules[0].respawn_seconds == 300 + assert zone.spawn_rules[1].mob == "goblin" + assert zone.spawn_rules[1].max_count == 1 + assert zone.spawn_rules[1].respawn_seconds == 600 + + +def test_load_zone_with_spawn_rules(): + """TOML with [[spawns]] loads spawn rules.""" + toml_content = """ +name = "testzone" +width = 10 +height = 10 + +[[spawns]] +mob = "rat" +max_count = 3 +respawn_seconds = 300 + +[[spawns]] +mob = "goblin" +max_count = 1 +respawn_seconds = 600 +""" + with tempfile.NamedTemporaryFile(mode="w", suffix=".toml", delete=False) as f: + f.write(toml_content) + f.flush() + temp_path = Path(f.name) + + try: + zone = load_zone(temp_path) + assert len(zone.spawn_rules) == 2 + assert zone.spawn_rules[0].mob == "rat" + assert zone.spawn_rules[0].max_count == 3 + assert zone.spawn_rules[0].respawn_seconds == 300 + assert zone.spawn_rules[1].mob == "goblin" + assert zone.spawn_rules[1].max_count == 1 + assert zone.spawn_rules[1].respawn_seconds == 600 + finally: + temp_path.unlink() + + +def test_load_zone_without_spawn_rules(): + """TOML without spawns defaults to empty list.""" + toml_content = """ +name = "testzone" +width = 10 +height = 10 + +[terrain] +rows = [".........."] +""" + with tempfile.NamedTemporaryFile(mode="w", suffix=".toml", delete=False) as f: + f.write(toml_content) + f.flush() + temp_path = Path(f.name) + + try: + zone = load_zone(temp_path) + assert zone.spawn_rules == [] + finally: + temp_path.unlink() + + +def test_spawn_rule_fields(): + """SpawnRule has mob, max_count, respawn_seconds fields.""" + rule = SpawnRule(mob="rat", max_count=5, respawn_seconds=120) + assert rule.mob == "rat" + assert rule.max_count == 5 + assert rule.respawn_seconds == 120 + + +def test_spawn_rule_defaults(): + """SpawnRule has sensible defaults for max_count and respawn_seconds.""" + rule = SpawnRule(mob="rat") + assert rule.mob == "rat" + assert rule.max_count == 1 + assert rule.respawn_seconds == 300 diff --git a/tests/test_paint_mode.py b/tests/test_paint_mode.py new file mode 100644 index 0000000..714416f --- /dev/null +++ b/tests/test_paint_mode.py @@ -0,0 +1,223 @@ +"""Tests for paint mode - terrain editing admin tool.""" + +import pytest + +from mudlib.player import Player +from mudlib.zone import Zone + + +@pytest.fixture +def player(): + """Create a test player with a mock writer.""" + mock_writer = MockWriter() + player = Player( + name="TestPlayer", + x=5, + y=5, + writer=mock_writer, + ) + # Create a simple zone for testing + zone = Zone( + "test_zone", + width=20, + height=20, + terrain=[["." for _ in range(20)] for _ in range(20)], + impassable={"^", "~", "#"}, # Add # as impassable + ) + # Add a wall for passability testing + zone.terrain[5][6] = "#" + player.location = zone + return player + + +class MockWriter: + """Mock writer that captures output.""" + + def __init__(self): + self.messages = [] + + def write(self, message: str): + self.messages.append(message) + + async def drain(self): + pass + + def get_output(self) -> str: + return "".join(self.messages) + + def clear(self): + self.messages = [] + + +@pytest.mark.asyncio +async def test_enter_paint_mode(player): + """@paint command sets player.paint_mode = True and sends confirmation.""" + from mudlib.commands.paint import cmd_paint + + await cmd_paint(player, "") + assert player.paint_mode is True + output = player.writer.get_output() + assert "paint mode" in output.lower() + + +@pytest.mark.asyncio +async def test_exit_paint_mode(player): + """@paint when already in paint mode exits it.""" + from mudlib.commands.paint import cmd_paint + + # Enter paint mode first + await cmd_paint(player, "") + player.writer.clear() + + # Exit paint mode + await cmd_paint(player, "") + assert player.paint_mode is False + output = player.writer.get_output() + assert "exit" in output.lower() or "off" in output.lower() + + +@pytest.mark.asyncio +async def test_paint_mode_default_survey(player): + """Entering paint mode starts in survey state (player.painting = False).""" + from mudlib.commands.paint import cmd_paint + + await cmd_paint(player, "") + assert player.paint_mode is True + assert player.painting is False + + +@pytest.mark.asyncio +async def test_toggle_painting(player): + """p command toggles player.painting between True and False.""" + from mudlib.commands.paint import cmd_toggle_painting + + # Must be in paint mode first + player.paint_mode = True + + # Toggle to painting + await cmd_toggle_painting(player, "") + assert player.painting is True + player.writer.clear() + + # Toggle back to survey + await cmd_toggle_painting(player, "") + assert player.painting is False + + +@pytest.mark.asyncio +async def test_toggle_painting_requires_paint_mode(player): + """p command only works in paint mode.""" + from mudlib.commands.paint import cmd_toggle_painting + + player.paint_mode = False + await cmd_toggle_painting(player, "") + output = player.writer.get_output() + assert "paint mode" in output.lower() + + +@pytest.mark.asyncio +async def test_set_brush(player): + """Typing a single character while in paint mode sets player.paint_brush.""" + from mudlib.commands.paint import cmd_set_brush + + player.paint_mode = True + + await cmd_set_brush(player, "#") + assert player.paint_brush == "#" + + await cmd_set_brush(player, "~") + assert player.paint_brush == "~" + + +@pytest.mark.asyncio +async def test_set_brush_requires_paint_mode(player): + """Brush command only works in paint mode.""" + from mudlib.commands.paint import cmd_set_brush + + player.paint_mode = False + await cmd_set_brush(player, "#") + output = player.writer.get_output() + assert "paint mode" in output.lower() + + +@pytest.mark.asyncio +async def test_set_brush_requires_single_char(player): + """Brush must be a single character.""" + from mudlib.commands.paint import cmd_set_brush + + player.paint_mode = True + await cmd_set_brush(player, "##") + output = player.writer.get_output() + assert "single character" in output.lower() + + +@pytest.mark.asyncio +async def test_paint_mode_movement_ignores_passability(player): + """Movement in paint mode doesn't check passability.""" + from mudlib.commands.movement import move_player + + # There's a wall at (6, 5) - normally can't move there + player.paint_mode = True + + # Should be able to move into the wall + await move_player(player, 1, 0, "east") + assert player.x == 6 + assert player.y == 5 + + +@pytest.mark.asyncio +async def test_painting_places_tile(player): + """Moving while painting sets terrain tile at old position to brush char.""" + from mudlib.commands.movement import move_player + + player.paint_mode = True + player.painting = True + player.paint_brush = "~" + + # Move from (5,5) to (6,5) + old_x, old_y = player.x, player.y + await move_player(player, 1, 0, "east") + + # Check that the old position now has the brush character + zone = player.location + assert isinstance(zone, Zone) + assert zone.terrain[old_y][old_x] == "~" + + +@pytest.mark.asyncio +async def test_painting_only_in_paint_mode(player): + """Painting flag has no effect outside paint mode.""" + from mudlib.commands.movement import move_player + + player.paint_mode = False + player.painting = True + player.paint_brush = "~" + + # Try to move into wall at (6, 5) + await move_player(player, 1, 0, "east") + + # Should have failed - player still at (5, 5) + assert player.x == 5 + assert player.y == 5 + + +@pytest.mark.asyncio +async def test_survey_mode_does_not_paint(player): + """Survey mode (painting=False) allows movement but doesn't paint.""" + from mudlib.commands.movement import move_player + + player.paint_mode = True + player.painting = False + player.paint_brush = "~" + + old_x, old_y = player.x, player.y + await move_player(player, 1, 0, "east") + + # Movement should have happened + assert player.x == 6 + assert player.y == 5 + + # But no painting should have occurred + zone = player.location + assert isinstance(zone, Zone) + assert zone.terrain[old_y][old_x] == "." # Still the original tile