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:
parent
b123d55fbd
commit
c3884e236b
5 changed files with 386 additions and 1 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
101
tests/test_mob_spawns.py
Normal file
101
tests/test_mob_spawns.py
Normal 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
223
tests/test_paint_mode.py
Normal 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
|
||||
Loading…
Reference in a new issue