Add safe zone flag to prevent combat in peaceful areas

This commit is contained in:
Jared Miller 2026-02-14 11:50:49 -05:00
parent 14dc2424ef
commit 755d23aa13
Signed by: shmup
GPG key ID: 22B5C6D66A38B06C
5 changed files with 246 additions and 0 deletions

View file

@ -3,6 +3,7 @@ description = "a cozy tavern with a crackling fireplace"
width = 8 width = 8
height = 6 height = 6
toroidal = false toroidal = false
safe = true
spawn_x = 1 spawn_x = 1
spawn_y = 1 spawn_y = 1

View file

@ -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") await player.send("You haven't learned that yet.\r\n")
return 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) encounter = get_encounter(player)
# Parse target from args # Parse target from args

View file

@ -41,6 +41,7 @@ class Zone(Object):
ambient_messages: list[str] = field(default_factory=list) ambient_messages: list[str] = field(default_factory=list)
ambient_interval: int = 120 ambient_interval: int = 120
spawn_rules: list[SpawnRule] = field(default_factory=list) spawn_rules: list[SpawnRule] = field(default_factory=list)
safe: bool = False
def can_accept(self, obj: Object) -> bool: def can_accept(self, obj: Object) -> bool:
"""Zones accept everything.""" """Zones accept everything."""

View file

@ -57,6 +57,7 @@ def load_zone(path: Path) -> Zone:
toroidal = data.get("toroidal", True) toroidal = data.get("toroidal", True)
spawn_x = data.get("spawn_x", 0) spawn_x = data.get("spawn_x", 0)
spawn_y = data.get("spawn_y", 0) spawn_y = data.get("spawn_y", 0)
safe = data.get("safe", False)
# Parse terrain rows into 2D list # Parse terrain rows into 2D list
terrain_rows = data.get("terrain", {}).get("rows", []) terrain_rows = data.get("terrain", {}).get("rows", [])
@ -97,6 +98,7 @@ def load_zone(path: Path) -> Zone:
ambient_messages=ambient_messages, ambient_messages=ambient_messages,
ambient_interval=ambient_interval, ambient_interval=ambient_interval,
spawn_rules=spawn_rules, spawn_rules=spawn_rules,
safe=safe,
) )
# Load portals # Load portals

237
tests/test_safe_zones.py Normal file
View file

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