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.
This commit is contained in:
Jared Miller 2026-02-11 22:13:29 -05:00
parent b123d55fbd
commit c3884e236b
Signed by: shmup
GPG key ID: 22B5C6D66A38B06C
5 changed files with 386 additions and 1 deletions

View file

@ -39,3 +39,13 @@ x = 0
y = 7 y = 7
target = "hub:7,14" target = "hub:7,14"
label = "a narrow branch leads to a distant platform" 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

View file

@ -2,11 +2,25 @@
from __future__ import annotations from __future__ import annotations
import random
from dataclasses import dataclass, field from dataclasses import dataclass, field
from mudlib.object import Object 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 @dataclass
class Zone(Object): class Zone(Object):
"""A spatial area with a grid of terrain tiles. """A spatial area with a grid of terrain tiles.
@ -23,6 +37,9 @@ class Zone(Object):
impassable: set[str] = field(default_factory=lambda: {"^", "~"}) impassable: set[str] = field(default_factory=lambda: {"^", "~"})
spawn_x: int = 0 spawn_x: int = 0
spawn_y: 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: def can_accept(self, obj: Object) -> bool:
"""Zones accept everything.""" """Zones accept everything."""
@ -103,3 +120,18 @@ class Zone(Object):
nearby.append(obj) nearby.append(obj)
return nearby 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)

View file

@ -7,7 +7,7 @@ import tomllib
from pathlib import Path from pathlib import Path
from mudlib.portal import Portal from mudlib.portal import Portal
from mudlib.zone import Zone from mudlib.zone import SpawnRule, Zone
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@ -67,6 +67,22 @@ def load_zone(path: Path) -> Zone:
impassable_list = data.get("terrain", {}).get("impassable", {}).get("tiles", []) impassable_list = data.get("terrain", {}).get("impassable", {}).get("tiles", [])
impassable = set(impassable_list) if impassable_list else {"^", "~"} 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( zone = Zone(
name=name, name=name,
width=width, width=width,
@ -76,6 +92,9 @@ def load_zone(path: Path) -> Zone:
impassable=impassable, impassable=impassable,
spawn_x=spawn_x, spawn_x=spawn_x,
spawn_y=spawn_y, spawn_y=spawn_y,
ambient_messages=ambient_messages,
ambient_interval=ambient_interval,
spawn_rules=spawn_rules,
) )
# Load portals # Load portals

101
tests/test_mob_spawns.py Normal file
View file

@ -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

223
tests/test_paint_mode.py Normal file
View file

@ -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