Add safe zone flag to prevent combat in peaceful areas
This commit is contained in:
parent
14dc2424ef
commit
755d23aa13
5 changed files with 246 additions and 0 deletions
|
|
@ -3,6 +3,7 @@ description = "a cozy tavern with a crackling fireplace"
|
|||
width = 8
|
||||
height = 6
|
||||
toroidal = false
|
||||
safe = true
|
||||
spawn_x = 1
|
||||
spawn_y = 1
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
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)
|
||||
|
||||
# Parse target from args
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ class Zone(Object):
|
|||
ambient_messages: list[str] = field(default_factory=list)
|
||||
ambient_interval: int = 120
|
||||
spawn_rules: list[SpawnRule] = field(default_factory=list)
|
||||
safe: bool = False
|
||||
|
||||
def can_accept(self, obj: Object) -> bool:
|
||||
"""Zones accept everything."""
|
||||
|
|
|
|||
|
|
@ -57,6 +57,7 @@ def load_zone(path: Path) -> Zone:
|
|||
toroidal = data.get("toroidal", True)
|
||||
spawn_x = data.get("spawn_x", 0)
|
||||
spawn_y = data.get("spawn_y", 0)
|
||||
safe = data.get("safe", False)
|
||||
|
||||
# Parse terrain rows into 2D list
|
||||
terrain_rows = data.get("terrain", {}).get("rows", [])
|
||||
|
|
@ -97,6 +98,7 @@ def load_zone(path: Path) -> Zone:
|
|||
ambient_messages=ambient_messages,
|
||||
ambient_interval=ambient_interval,
|
||||
spawn_rules=spawn_rules,
|
||||
safe=safe,
|
||||
)
|
||||
|
||||
# Load portals
|
||||
|
|
|
|||
237
tests/test_safe_zones.py
Normal file
237
tests/test_safe_zones.py
Normal 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
|
||||
Loading…
Reference in a new issue