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