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
|
width = 8
|
||||||
height = 6
|
height = 6
|
||||||
toroidal = false
|
toroidal = false
|
||||||
|
safe = true
|
||||||
spawn_x = 1
|
spawn_x = 1
|
||||||
spawn_y = 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")
|
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
|
||||||
|
|
|
||||||
|
|
@ -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."""
|
||||||
|
|
|
||||||
|
|
@ -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
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