Compare commits

..

No commits in common. "058ba1b7de6538cf4ea70d112a23b339af7df6f1" and "e724abb92662f03420c36415ebf361b7f9bcb1ce" have entirely different histories.

19 changed files with 3 additions and 1778 deletions

View file

@ -1,27 +0,0 @@
name = "flower"
description = "you lie in the heart of a giant flower, warm light filtering through translucent petals"
width = 7
height = 7
toroidal = false
spawn_x = 3
spawn_y = 3
[terrain]
rows = [
"ooooooo",
"ooo.ooo",
"oo...oo",
"o.....o",
"oo...oo",
"ooo.ooo",
"ooo.ooo",
]
[terrain.impassable]
tiles = ["o"]
[[portals]]
x = 3
y = 6
target = "treehouse:10,7"
label = "an opening in the petals"

View file

@ -1,47 +0,0 @@
name = "hub"
description = "a crossroads where paths converge beneath a weathered signpost"
width = 15
height = 15
toroidal = false
spawn_x = 7
spawn_y = 7
[terrain]
rows = [
"#######.#######",
"#######.#######",
"#######.#######",
"#######.#######",
"#......+......#",
"#......+......#",
"#......+......#",
"..............+",
"#......+......#",
"#......+......#",
"#......+......#",
"#######.#######",
"#######.#######",
"#######.#######",
"#######.#######",
]
[terrain.impassable]
tiles = ["#"]
[[portals]]
x = 7
y = 0
target = "overworld:500,500"
label = "a wide dirt path"
[[portals]]
x = 14
y = 7
target = "tavern:4,5"
label = "a weathered oak door"
[[portals]]
x = 7
y = 14
target = "treehouse:0,7"
label = "a mossy forest trail"

View file

@ -3,8 +3,6 @@ description = "a cozy tavern with a crackling fireplace"
width = 8 width = 8
height = 6 height = 6
toroidal = false toroidal = false
spawn_x = 1
spawn_y = 1
[terrain] [terrain]
# rows as strings, one per line # rows as strings, one per line
@ -19,18 +17,3 @@ rows = [
[terrain.impassable] [terrain.impassable]
tiles = ["#"] tiles = ["#"]
[[portals]]
x = 4
y = 5
target = "hub:14,7"
label = "the tavern door"
[ambient]
interval = 60
messages = [
"the fire crackles and pops in the hearth",
"a draft of cold air blows through the room",
"you hear muffled conversation from patrons in the corner",
"the smell of roasting meat and ale fills the air",
]

View file

@ -1,51 +0,0 @@
name = "treehouse"
description = "a sprawling treehouse platform high in an ancient oak, branches creaking in the wind"
width = 20
height = 15
toroidal = false
spawn_x = 10
spawn_y = 7
[terrain]
rows = [
"~TTTTTTTTTTTTTTTTTT~",
"~TToooooooooooooTTT~",
"~TToo..........ooTT~",
"~TTo............oT~~",
"~TTo............oT~~",
"~TTo............oT~~",
"~TTo............oT~~",
"...o............oT~~",
"~TTo............oT~~",
"~TTo............oT~~",
"~TTo............oT~~",
"~TTo............oT~~",
"~TToo..........ooT~~",
"~TTToooooooooooTTT~~",
"~TTT...oooo...TTTT~~",
]
[terrain.impassable]
tiles = ["o", "~"]
[[portals]]
x = 10
y = 14
target = "overworld:500,500"
label = "a rope ladder dangles into the mist below"
[[portals]]
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

View file

@ -3,9 +3,7 @@
from mudlib.commands import CommandDefinition, register from mudlib.commands import CommandDefinition, register
from mudlib.entity import Entity from mudlib.entity import Entity
from mudlib.player import Player from mudlib.player import Player
from mudlib.portal import Portal
from mudlib.zone import Zone from mudlib.zone import Zone
from mudlib.zones import get_zone
# Direction mappings: command -> (dx, dy) # Direction mappings: command -> (dx, dy)
DIRECTIONS: dict[str, tuple[int, int]] = { DIRECTIONS: dict[str, tuple[int, int]] = {
@ -68,15 +66,11 @@ async def move_player(player: Player, dx: int, dy: int, direction_name: str) ->
assert isinstance(zone, Zone), "Player must be in a zone to move" assert isinstance(zone, Zone), "Player must be in a zone to move"
target_x, target_y = zone.wrap(player.x + dx, player.y + dy) target_x, target_y = zone.wrap(player.x + dx, player.y + dy)
# Check if the target is passable (skip check in paint mode) # Check if the target is passable
if not player.paint_mode and not zone.is_passable(target_x, target_y): if not zone.is_passable(target_x, target_y):
await player.send("You can't go that way.\r\n") await player.send("You can't go that way.\r\n")
return return
# If painting, place the brush tile at the current position before moving
if player.paint_mode and player.painting:
zone.terrain[player.y][player.x] = player.paint_brush
# Send departure message to players in the old area # Send departure message to players in the old area
opposite = OPPOSITE_DIRECTIONS[direction_name] opposite = OPPOSITE_DIRECTIONS[direction_name]
await send_nearby_message( await send_nearby_message(
@ -87,34 +81,6 @@ async def move_player(player: Player, dx: int, dy: int, direction_name: str) ->
player.x = target_x player.x = target_x
player.y = target_y player.y = target_y
# Check for auto-trigger portals at new position
portals_here = [
obj for obj in zone.contents_at(target_x, target_y) if isinstance(obj, Portal)
]
if portals_here:
portal = portals_here[0] # Take first portal
target_zone = get_zone(portal.target_zone)
if target_zone:
await player.send(f"You enter {portal.name}.\r\n")
await send_nearby_message(
player, player.x, player.y, f"{player.name} enters {portal.name}.\r\n"
)
player.move_to(target_zone, x=portal.target_x, y=portal.target_y)
await send_nearby_message(
player, player.x, player.y, f"{player.name} arrives.\r\n"
)
from mudlib.commands.look import cmd_look
await cmd_look(player, "")
return # Don't do normal arrival+look
else:
await player.send("The portal doesn't lead anywhere.\r\n")
# Stay at portal tile but show normal look
from mudlib.commands.look import cmd_look
await cmd_look(player, "")
return
# Send arrival message to players in the new area # Send arrival message to players in the new area
await send_nearby_message( await send_nearby_message(
player, player.x, player.y, f"{player.name} arrives from the {opposite}.\r\n" player, player.x, player.y, f"{player.name} arrives from the {opposite}.\r\n"

View file

@ -1,58 +0,0 @@
"""Paint mode commands for terrain editing."""
from mudlib.commands import CommandDefinition, register
from mudlib.player import Player
async def cmd_paint(player: Player, args: str) -> None:
"""Toggle paint mode on/off.
Paint mode allows admin to edit terrain tile-by-tile.
"""
if player.paint_mode:
# Exit paint mode
player.paint_mode = False
player.painting = False
await player.send("Paint mode off.\r\n")
else:
# Enter paint mode
player.paint_mode = True
player.painting = False
await player.send(
"Paint mode on. Use 'p' to toggle painting, "
"type a character to set brush.\r\n"
)
async def cmd_toggle_painting(player: Player, args: str) -> None:
"""Toggle between survey and painting states within paint mode."""
if not player.paint_mode:
await player.send("You must be in paint mode to do that.\r\n")
return
player.painting = not player.painting
if player.painting:
await player.send(f"Painting with '{player.paint_brush}'.\r\n")
else:
await player.send("Survey mode.\r\n")
async def cmd_set_brush(player: Player, args: str) -> None:
"""Set the brush character for painting."""
if not player.paint_mode:
await player.send("You must be in paint mode to do that.\r\n")
return
char = args.strip()
if len(char) != 1:
await player.send("Brush must be a single character.\r\n")
return
player.paint_brush = char
await player.send(f"Brush set to '{char}'.\r\n")
# Register paint mode commands
register(CommandDefinition("@paint", cmd_paint))
register(CommandDefinition("p", cmd_toggle_painting))
register(CommandDefinition("brush", cmd_set_brush))

View file

@ -1,96 +0,0 @@
"""Export zone data to TOML files."""
from __future__ import annotations
from pathlib import Path
from mudlib.portal import Portal
from mudlib.zone import Zone
def export_zone(zone: Zone) -> str:
"""Export a Zone to TOML string.
Args:
zone: Zone instance to export
Returns:
TOML-formatted string representation of the zone
"""
lines = []
# Basic fields
lines.append(f'name = "{zone.name}"')
if zone.description:
lines.append(f'description = "{zone.description}"')
lines.append(f"width = {zone.width}")
lines.append(f"height = {zone.height}")
lines.append(f"toroidal = {str(zone.toroidal).lower()}")
lines.append(f"spawn_x = {zone.spawn_x}")
lines.append(f"spawn_y = {zone.spawn_y}")
lines.append("")
# Terrain section
lines.append("[terrain]")
lines.append("rows = [")
for row in zone.terrain:
row_str = "".join(row)
lines.append(f' "{row_str}",')
lines.append("]")
lines.append("")
# Impassable tiles
lines.append("[terrain.impassable]")
impassable_list = sorted(zone.impassable)
tiles_str = ", ".join(f'"{tile}"' for tile in impassable_list)
lines.append(f"tiles = [{tiles_str}]")
# Ambient messages (if present)
if zone.ambient_messages:
lines.append("")
lines.append("[ambient]")
lines.append(f"interval = {zone.ambient_interval}")
lines.append("messages = [")
for msg in zone.ambient_messages:
# Escape quotes in messages
escaped_msg = msg.replace('"', '\\"')
lines.append(f' "{escaped_msg}",')
lines.append("]")
# Portals (if any)
portals = [obj for obj in zone._contents if isinstance(obj, Portal)]
if portals:
lines.append("")
for portal in portals:
lines.append("[[portals]]")
lines.append(f"x = {portal.x}")
lines.append(f"y = {portal.y}")
target = f"{portal.target_zone}:{portal.target_x},{portal.target_y}"
lines.append(f'target = "{target}"')
lines.append(f'label = "{portal.name}"')
if portal.aliases:
aliases_str = ", ".join(f'"{alias}"' for alias in portal.aliases)
lines.append(f"aliases = [{aliases_str}]")
lines.append("")
# Spawn rules (if any)
if zone.spawn_rules:
for spawn_rule in zone.spawn_rules:
lines.append("[[spawns]]")
lines.append(f'mob = "{spawn_rule.mob}"')
lines.append(f"max_count = {spawn_rule.max_count}")
lines.append(f"respawn_seconds = {spawn_rule.respawn_seconds}")
lines.append("")
return "\n".join(lines)
def export_zone_to_file(zone: Zone, path: Path) -> None:
"""Export a Zone to a TOML file.
Args:
zone: Zone instance to export
path: Path where the TOML file should be written
"""
toml_str = export_zone(zone)
path.write_text(toml_str)

View file

@ -25,9 +25,6 @@ class Player(Entity):
caps: ClientCaps = field(default_factory=lambda: ClientCaps(ansi=True)) caps: ClientCaps = field(default_factory=lambda: ClientCaps(ansi=True))
editor: Editor | None = None editor: Editor | None = None
if_session: IFSession | EmbeddedIFSession | None = None if_session: IFSession | EmbeddedIFSession | None = None
paint_mode: bool = False
painting: bool = False
paint_brush: str = "."
@property @property
def mode(self) -> str: def mode(self) -> str:

View file

@ -2,25 +2,11 @@
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.
@ -30,17 +16,11 @@ class Zone(Object):
a zone. A tavern interior is a zone. A pocket dimension is a zone. a zone. A tavern interior is a zone. A pocket dimension is a zone.
""" """
description: str = ""
width: int = 0 width: int = 0
height: int = 0 height: int = 0
toroidal: bool = True toroidal: bool = True
terrain: list[list[str]] = field(default_factory=list) terrain: list[list[str]] = field(default_factory=list)
impassable: set[str] = field(default_factory=lambda: {"^", "~"}) 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: def can_accept(self, obj: Object) -> bool:
"""Zones accept everything.""" """Zones accept everything."""
@ -121,18 +101,3 @@ 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)

View file

@ -6,8 +6,7 @@ import logging
import tomllib import tomllib
from pathlib import Path from pathlib import Path
from mudlib.portal import Portal from mudlib.zone import Zone
from mudlib.zone import SpawnRule, Zone
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@ -51,12 +50,9 @@ def load_zone(path: Path) -> Zone:
# Extract basic properties # Extract basic properties
name = data["name"] name = data["name"]
description = data.get("description", "")
width = data["width"] width = data["width"]
height = data["height"] height = data["height"]
toroidal = data.get("toroidal", True) toroidal = data.get("toroidal", True)
spawn_x = data.get("spawn_x", 0)
spawn_y = data.get("spawn_y", 0)
# 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", [])
@ -68,67 +64,15 @@ 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,
description=description,
width=width, width=width,
height=height, height=height,
toroidal=toroidal, toroidal=toroidal,
terrain=terrain, terrain=terrain,
impassable=impassable, impassable=impassable,
spawn_x=spawn_x,
spawn_y=spawn_y,
ambient_messages=ambient_messages,
ambient_interval=ambient_interval,
spawn_rules=spawn_rules,
) )
# Load portals
portals_data = data.get("portals", [])
for portal_dict in portals_data:
# Parse target string "zone_name:x,y"
target = portal_dict["target"]
try:
target_zone, coords = target.split(":")
target_x, target_y = map(int, coords.split(","))
except ValueError:
log.warning(
"skipping portal '%s' at (%d, %d): malformed target '%s'",
portal_dict["label"],
portal_dict["x"],
portal_dict["y"],
target,
)
continue
# Create portal (automatically added to zone._contents via Object.__post_init__)
Portal(
name=portal_dict["label"],
aliases=portal_dict.get("aliases", []),
target_zone=target_zone,
target_x=target_x,
target_y=target_y,
location=zone,
x=portal_dict["x"],
y=portal_dict["y"],
)
return zone return zone

View file

@ -1,90 +0,0 @@
"""Tests for zone ambient messages."""
import tempfile
from pathlib import Path
from mudlib.zone import Zone, get_ambient_message
from mudlib.zones import load_zone
def test_zone_default_no_ambient():
"""Zone() has empty ambient messages and default interval."""
zone = Zone(name="test")
assert zone.ambient_messages == []
assert zone.ambient_interval == 120
def test_zone_with_ambient_messages():
"""Zone created with ambient_messages and ambient_interval stores them."""
messages = ["message one", "message two"]
zone = Zone(name="test", ambient_messages=messages, ambient_interval=60)
assert zone.ambient_messages == messages
assert zone.ambient_interval == 60
def test_load_zone_with_ambient():
"""TOML with [ambient] section loads messages and interval."""
toml_content = """
name = "test_zone"
width = 10
height = 10
[ambient]
interval = 45
messages = [
"a draft of cold air blows through the room",
"the fire crackles and pops",
"you hear muffled conversation from the next room",
]
"""
with tempfile.NamedTemporaryFile(mode="w", suffix=".toml", delete=False) as f:
f.write(toml_content)
temp_path = Path(f.name)
try:
zone = load_zone(temp_path)
assert zone.ambient_interval == 45
assert len(zone.ambient_messages) == 3
assert "a draft of cold air blows through the room" in zone.ambient_messages
assert "the fire crackles and pops" in zone.ambient_messages
assert (
"you hear muffled conversation from the next room" in zone.ambient_messages
)
finally:
temp_path.unlink()
def test_load_zone_without_ambient():
"""TOML without [ambient] defaults to empty messages."""
toml_content = """
name = "simple_zone"
width = 5
height = 5
"""
with tempfile.NamedTemporaryFile(mode="w", suffix=".toml", delete=False) as f:
f.write(toml_content)
temp_path = Path(f.name)
try:
zone = load_zone(temp_path)
assert zone.ambient_messages == []
assert zone.ambient_interval == 120 # default
finally:
temp_path.unlink()
def test_get_ambient_message_returns_from_list():
"""get_ambient_message returns a message from the zone's list."""
messages = ["message one", "message two", "message three"]
zone = Zone(name="test", ambient_messages=messages)
# Run multiple times to ensure we get valid messages
for _ in range(10):
msg = get_ambient_message(zone)
assert msg in messages
def test_get_ambient_message_empty_list_returns_none():
"""get_ambient_message returns None when zone has no messages."""
zone = Zone(name="test")
assert get_ambient_message(zone) is None

View file

@ -1,67 +0,0 @@
"""Tests for the hub zone."""
import pathlib
from mudlib.zones import load_zone
def test_load_hub_zone():
"""Load the hub zone from TOML file."""
project_root = pathlib.Path(__file__).resolve().parents[1]
hub_path = project_root / "content" / "zones" / "hub.toml"
zone = load_zone(hub_path)
assert zone.name == "hub"
assert zone.width == 15
assert zone.height == 15
assert zone.toroidal is False
assert zone.spawn_x == 7
assert zone.spawn_y == 7
def test_hub_zone_has_portals():
"""Hub zone has portals to overworld, tavern, and treehouse."""
project_root = pathlib.Path(__file__).resolve().parents[1]
hub_path = project_root / "content" / "zones" / "hub.toml"
zone = load_zone(hub_path)
# Find portals in zone contents
portals = [obj for obj in zone._contents if obj.__class__.__name__ == "Portal"]
assert len(portals) == 3
# Check that we have portals to each target zone
target_zones = {p.target_zone for p in portals}
assert target_zones == {"overworld", "tavern", "treehouse"}
def test_hub_zone_portals_at_correct_positions():
"""Portals are at expected coordinates on the map."""
project_root = pathlib.Path(__file__).resolve().parents[1]
hub_path = project_root / "content" / "zones" / "hub.toml"
zone = load_zone(hub_path)
portals = [obj for obj in zone._contents if obj.__class__.__name__ == "Portal"]
# North portal to overworld at (7, 0)
overworld_portal = [p for p in portals if p.target_zone == "overworld"][0]
assert overworld_portal.x == 7
assert overworld_portal.y == 0
assert overworld_portal.target_x == 500
assert overworld_portal.target_y == 500
# East portal to tavern at (14, 7)
tavern_portal = [p for p in portals if p.target_zone == "tavern"][0]
assert tavern_portal.x == 14
assert tavern_portal.y == 7
assert tavern_portal.target_x == 4
assert tavern_portal.target_y == 5
# South portal to treehouse at (7, 14)
treehouse_portal = [p for p in portals if p.target_zone == "treehouse"][0]
assert treehouse_portal.x == 7
assert treehouse_portal.y == 14
assert treehouse_portal.target_x == 0
assert treehouse_portal.target_y == 7

View file

@ -1,101 +0,0 @@
"""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

View file

@ -1,223 +0,0 @@
"""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

View file

@ -1,175 +0,0 @@
"""Tests for auto-triggering portals on movement."""
from unittest.mock import AsyncMock, MagicMock
import pytest
from mudlib.player import Player
from mudlib.portal import Portal
from mudlib.zone import Zone
from mudlib.zones import register_zone, zone_registry
@pytest.fixture
def mock_writer():
writer = MagicMock()
writer.write = MagicMock()
writer.drain = AsyncMock()
return writer
@pytest.fixture
def mock_reader():
return MagicMock()
@pytest.fixture
def test_zone():
terrain = [["." for _ in range(10)] for _ in range(10)]
return Zone(
name="testzone",
width=10,
height=10,
toroidal=True,
terrain=terrain,
)
@pytest.fixture
def target_zone():
terrain = [["." for _ in range(10)] for _ in range(10)]
return Zone(
name="targetzone",
width=10,
height=10,
toroidal=True,
terrain=terrain,
)
@pytest.fixture
def player(mock_reader, mock_writer, test_zone):
p = Player(
name="TestPlayer",
x=5,
y=5,
reader=mock_reader,
writer=mock_writer,
location=test_zone,
)
return p
@pytest.fixture(autouse=True)
def clear_zones():
"""Clear zone registry before and after each test."""
zone_registry.clear()
yield
zone_registry.clear()
@pytest.mark.asyncio
async def test_move_onto_portal_triggers_zone_transition(
player, test_zone, target_zone
):
"""Walking onto a portal tile auto-triggers zone transition."""
from mudlib.commands.movement import move_player
register_zone("targetzone", target_zone)
# Place portal at (6, 5), one tile east of player
Portal(
name="shimmering doorway",
location=test_zone,
x=6,
y=5,
target_zone="targetzone",
target_x=3,
target_y=7,
)
# Move east onto the portal
await move_player(player, 1, 0, "east")
# Player should end up in target zone at portal's target coords
assert player.location is target_zone
assert player.x == 3
assert player.y == 7
assert player in target_zone.contents
assert player not in test_zone.contents
@pytest.mark.asyncio
async def test_move_onto_portal_sends_transition_message(
player, test_zone, target_zone, mock_writer
):
"""Auto-triggered portal shows transition message to player."""
from mudlib.commands.movement import move_player
register_zone("targetzone", target_zone)
Portal(
name="mystic portal",
location=test_zone,
x=6,
y=5,
target_zone="targetzone",
target_x=2,
target_y=3,
)
await move_player(player, 1, 0, "east")
# Check that player got a transition message
output = "".join([call[0][0] for call in mock_writer.write.call_args_list])
assert "enter" in output.lower() or "portal" in output.lower()
@pytest.mark.asyncio
async def test_move_onto_tile_without_portal_normal_movement(
player, test_zone, mock_writer
):
"""Normal movement still works when no portal present."""
from mudlib.commands.movement import move_player
initial_x = player.x
initial_y = player.y
# Move east to empty tile
await move_player(player, 1, 0, "east")
# Player should still be in test zone with updated position
assert player.location is test_zone
assert player.x == initial_x + 1
assert player.y == initial_y
assert player in test_zone.contents
# Should see look output (viewport with @ symbol)
output = "".join([call[0][0] for call in mock_writer.write.call_args_list])
assert "@" in output # Player's position marker in viewport
@pytest.mark.asyncio
async def test_portal_autotrigger_target_zone_not_found(player, test_zone, mock_writer):
"""If target zone not registered, player stays and gets error."""
from mudlib.commands.movement import move_player
# Create portal with invalid target zone (not registered)
Portal(
name="broken portal",
location=test_zone,
x=6,
y=5,
target_zone="nonexistent",
target_x=3,
target_y=7,
)
await move_player(player, 1, 0, "east")
# Player should stay in original zone at portal tile
assert player.location is test_zone
assert player.x == 6
assert player.y == 5
# Should see error message
output = "".join([call[0][0] for call in mock_writer.write.call_args_list])
assert "doesn't lead anywhere" in output.lower() or "nowhere" in output.lower()

View file

@ -1,125 +0,0 @@
"""Tests for tutorial zones (flower and treehouse)."""
import pathlib
from mudlib.zones import load_zone
def test_load_flower_zone():
"""Load the flower zone and verify basic properties."""
project_root = pathlib.Path(__file__).resolve().parents[1]
flower_path = project_root / "content" / "zones" / "flower.toml"
zone = load_zone(flower_path)
assert zone.name == "flower"
assert zone.width == 7
assert zone.height == 7
assert zone.toroidal is False
assert zone.spawn_x == 3
assert zone.spawn_y == 3
assert len(zone.terrain) == 7
def test_flower_zone_mostly_sealed():
"""Verify flower zone has mostly sealed borders with impassable petals."""
project_root = pathlib.Path(__file__).resolve().parents[1]
flower_path = project_root / "content" / "zones" / "flower.toml"
zone = load_zone(flower_path)
# Verify 'o' is impassable (petals)
assert "o" in zone.impassable
# Count impassable border tiles
border_tiles = []
# Top and bottom rows
for x in range(zone.width):
border_tiles.append((x, 0))
border_tiles.append((x, zone.height - 1))
# Left and right columns (excluding corners already counted)
for y in range(1, zone.height - 1):
border_tiles.append((0, y))
border_tiles.append((zone.width - 1, y))
impassable_border_count = sum(
1 for x, y in border_tiles if not zone.is_passable(x, y)
)
# Most of the border should be impassable (allow for 1-2 openings)
total_border = len(border_tiles)
assert impassable_border_count >= total_border - 2
def test_flower_has_portal_to_treehouse():
"""Verify flower zone has a portal targeting treehouse."""
project_root = pathlib.Path(__file__).resolve().parents[1]
flower_path = project_root / "content" / "zones" / "flower.toml"
zone = load_zone(flower_path)
# Find portals in zone contents
portals = [obj for obj in zone._contents if obj.__class__.__name__ == "Portal"]
assert len(portals) == 1
portal = portals[0]
assert portal.target_zone == "treehouse"
assert portal.target_x == 10
assert portal.target_y == 7
assert "petal" in portal.name.lower() or "opening" in portal.name.lower()
def test_load_treehouse_zone():
"""Load the treehouse zone and verify basic properties."""
project_root = pathlib.Path(__file__).resolve().parents[1]
treehouse_path = project_root / "content" / "zones" / "treehouse.toml"
zone = load_zone(treehouse_path)
assert zone.name == "treehouse"
assert zone.width == 20
assert zone.height == 15
assert zone.toroidal is False
assert zone.spawn_x == 10
assert zone.spawn_y == 7
assert len(zone.terrain) == 15
def test_treehouse_has_portal_to_overworld():
"""Verify treehouse has a portal to the overworld."""
project_root = pathlib.Path(__file__).resolve().parents[1]
treehouse_path = project_root / "content" / "zones" / "treehouse.toml"
zone = load_zone(treehouse_path)
# Find portals
portals = [obj for obj in zone._contents if obj.__class__.__name__ == "Portal"]
# Find the overworld portal
overworld_portals = [p for p in portals if p.target_zone == "overworld"]
assert len(overworld_portals) == 1
portal = overworld_portals[0]
assert portal.target_x == 500
assert portal.target_y == 500
assert "ladder" in portal.name.lower() or "mist" in portal.name.lower()
def test_treehouse_has_portal_to_hub():
"""Verify treehouse has a portal to the hub."""
project_root = pathlib.Path(__file__).resolve().parents[1]
treehouse_path = project_root / "content" / "zones" / "treehouse.toml"
zone = load_zone(treehouse_path)
# Find portals
portals = [obj for obj in zone._contents if obj.__class__.__name__ == "Portal"]
# Find the hub portal
hub_portals = [p for p in portals if p.target_zone == "hub"]
assert len(hub_portals) == 1
portal = hub_portals[0]
assert portal.target_x == 7
assert portal.target_y == 14
assert "branch" in portal.name.lower() or "platform" in portal.name.lower()

View file

@ -32,20 +32,6 @@ def test_zone_default_impassable():
assert "~" in zone.impassable assert "~" in zone.impassable
def test_zone_default_spawn_point():
"""Zone has spawn_x=0, spawn_y=0 by default."""
zone = Zone(name="test")
assert zone.spawn_x == 0
assert zone.spawn_y == 0
def test_zone_custom_spawn_point():
"""Zone can be created with custom spawn point."""
zone = Zone(name="test", spawn_x=3, spawn_y=4)
assert zone.spawn_x == 3
assert zone.spawn_y == 4
# --- can_accept --- # --- can_accept ---

View file

@ -1,324 +0,0 @@
"""Tests for zone export to TOML files."""
import pathlib
import tempfile
from mudlib.export import export_zone, export_zone_to_file
from mudlib.portal import Portal
from mudlib.zone import SpawnRule, Zone
from mudlib.zones import load_zone
def test_export_basic_zone():
"""Export a simple zone and verify TOML output has correct fields."""
zone = Zone(
name="test_zone",
width=4,
height=3,
toroidal=False,
terrain=[
["#", "#", "#", "#"],
["#", ".", ".", "#"],
["#", "#", "#", "#"],
],
impassable={"#"},
spawn_x=0,
spawn_y=0,
)
# Set description as an attribute (zones loaded from TOML have this)
zone.description = "a test zone"
toml_str = export_zone(zone)
# Verify basic fields are present
assert 'name = "test_zone"' in toml_str
assert 'description = "a test zone"' in toml_str
assert "width = 4" in toml_str
assert "height = 3" in toml_str
assert "toroidal = false" in toml_str
assert "spawn_x = 0" in toml_str
assert "spawn_y = 0" in toml_str
# Verify terrain section
assert "[terrain]" in toml_str
assert '"####"' in toml_str
assert '"#..#"' in toml_str
# Verify impassable tiles
assert "[terrain.impassable]" in toml_str
assert '"#"' in toml_str
def test_export_zone_round_trip():
"""Export a zone, load it back, verify it matches the original."""
original = Zone(
name="round_trip",
width=5,
height=4,
toroidal=True,
terrain=[
[".", ".", ".", ".", "."],
[".", "#", "#", "#", "."],
[".", "#", ".", "#", "."],
[".", ".", ".", ".", "."],
],
impassable={"#"},
spawn_x=2,
spawn_y=1,
)
original.description = "round trip test"
toml_str = export_zone(original)
# Write to temp file and load back
with tempfile.NamedTemporaryFile(mode="w", suffix=".toml", delete=False) as f:
f.write(toml_str)
temp_path = pathlib.Path(f.name)
try:
loaded = load_zone(temp_path)
# Verify all fields match
assert loaded.name == original.name
assert loaded.description == original.description
assert loaded.width == original.width
assert loaded.height == original.height
assert loaded.toroidal == original.toroidal
assert loaded.spawn_x == original.spawn_x
assert loaded.spawn_y == original.spawn_y
assert loaded.terrain == original.terrain
assert loaded.impassable == original.impassable
finally:
temp_path.unlink()
def test_export_zone_with_portals():
"""Zone with Portal objects exports [[portals]] sections."""
zone = Zone(
name="portal_zone",
width=10,
height=10,
terrain=[["." for _ in range(10)] for _ in range(10)],
impassable=set(),
)
zone.description = "a zone with portals"
# Add portals
Portal(
name="tavern door",
location=zone,
x=5,
y=3,
target_zone="tavern",
target_x=1,
target_y=1,
)
Portal(
name="forest path",
aliases=["path", "entrance"],
location=zone,
x=2,
y=7,
target_zone="forest",
target_x=10,
target_y=5,
)
toml_str = export_zone(zone)
# Verify portals section exists
assert "[[portals]]" in toml_str
# Verify first portal
assert "x = 5" in toml_str
assert "y = 3" in toml_str
assert 'target = "tavern:1,1"' in toml_str
assert 'label = "tavern door"' in toml_str
# Verify second portal
assert "x = 2" in toml_str
assert "y = 7" in toml_str
assert 'target = "forest:10,5"' in toml_str
assert 'label = "forest path"' in toml_str
assert 'aliases = ["path", "entrance"]' in toml_str
def test_export_zone_with_portals_round_trip():
"""Export a zone with portals, load it back, verify portals match."""
zone = Zone(
name="portal_round_trip",
width=8,
height=6,
toroidal=False,
terrain=[["." for _ in range(8)] for _ in range(6)],
impassable=set(),
spawn_x=0,
spawn_y=0,
)
zone.description = "portal round trip test"
# Add portals
Portal(
name="tavern door",
location=zone,
x=5,
y=3,
target_zone="tavern",
target_x=1,
target_y=2,
)
Portal(
name="forest path",
aliases=["path"],
location=zone,
x=2,
y=4,
target_zone="forest",
target_x=10,
target_y=5,
)
toml_str = export_zone(zone)
# Write to temp file and load back
with tempfile.NamedTemporaryFile(mode="w", suffix=".toml", delete=False) as f:
f.write(toml_str)
temp_path = pathlib.Path(f.name)
try:
loaded = load_zone(temp_path)
# Verify basic zone fields
assert loaded.name == zone.name
assert loaded.description == zone.description
assert loaded.width == zone.width
assert loaded.height == zone.height
# Verify portals were loaded correctly
portals = [
obj for obj in loaded._contents if obj.__class__.__name__ == "Portal"
]
assert len(portals) == 2
# Sort by y coordinate for consistent ordering
portals.sort(key=lambda p: (p.y, p.x))
# Verify first portal (tavern door at 5,3)
assert portals[0].name == "tavern door"
assert portals[0].x == 5
assert portals[0].y == 3
assert portals[0].target_zone == "tavern"
assert portals[0].target_x == 1
assert portals[0].target_y == 2
# Verify second portal (forest path at 2,4)
assert portals[1].name == "forest path"
assert portals[1].x == 2
assert portals[1].y == 4
assert portals[1].target_zone == "forest"
assert portals[1].target_x == 10
assert portals[1].target_y == 5
assert "path" in portals[1].aliases
finally:
temp_path.unlink()
def test_export_zone_with_spawn_point():
"""spawn_x/spawn_y are in the output."""
zone = Zone(
name="spawn_zone",
width=10,
height=10,
terrain=[["." for _ in range(10)] for _ in range(10)],
spawn_x=5,
spawn_y=7,
)
zone.description = "a zone with spawn point"
toml_str = export_zone(zone)
assert "spawn_x = 5" in toml_str
assert "spawn_y = 7" in toml_str
def test_export_zone_to_file():
"""Write TOML to a file, load it back."""
zone = Zone(
name="file_zone",
width=3,
height=3,
terrain=[
["#", "#", "#"],
["#", ".", "#"],
["#", "#", "#"],
],
impassable={"#"},
)
zone.description = "exported to file"
with tempfile.TemporaryDirectory() as tmpdir:
output_path = pathlib.Path(tmpdir) / "test_zone.toml"
export_zone_to_file(zone, output_path)
# Verify file exists
assert output_path.exists()
# Load it back
loaded = load_zone(output_path)
assert loaded.name == zone.name
assert loaded.width == zone.width
assert loaded.height == zone.height
assert loaded.terrain == zone.terrain
def test_export_zone_with_ambient_messages():
"""Zone with ambient messages exports [ambient] section."""
zone = Zone(
name="ambient_zone",
width=5,
height=5,
terrain=[["." for _ in range(5)] for _ in range(5)],
ambient_messages=[
"Birds chirp overhead.",
"A cool breeze passes by.",
"Leaves rustle in the distance.",
],
ambient_interval=90,
)
zone.description = "a zone with ambient messages"
toml_str = export_zone(zone)
# Verify ambient section
assert "[ambient]" in toml_str
assert "interval = 90" in toml_str
assert "Birds chirp overhead." in toml_str
assert "A cool breeze passes by." in toml_str
assert "Leaves rustle in the distance." in toml_str
def test_export_zone_with_spawn_rules():
"""Zone with spawn rules exports [[spawns]] sections."""
zone = Zone(
name="spawn_zone",
width=10,
height=10,
terrain=[["." for _ in range(10)] for _ in range(10)],
spawn_rules=[
SpawnRule(mob="squirrel", max_count=2, respawn_seconds=180),
SpawnRule(mob="crow", max_count=1, respawn_seconds=300),
],
)
toml_str = export_zone(zone)
# Verify spawns sections
assert "[[spawns]]" in toml_str
assert 'mob = "squirrel"' in toml_str
assert "max_count = 2" in toml_str
assert "respawn_seconds = 180" in toml_str
assert 'mob = "crow"' in toml_str
assert "max_count = 1" in toml_str
assert "respawn_seconds = 300" in toml_str

View file

@ -134,8 +134,6 @@ def test_load_tavern_zone():
assert zone.width == 8 assert zone.width == 8
assert zone.height == 6 assert zone.height == 6
assert zone.toroidal is False assert zone.toroidal is False
assert zone.spawn_x == 1
assert zone.spawn_y == 1
assert len(zone.terrain) == 6 assert len(zone.terrain) == 6
assert zone.terrain[0] == ["#", "#", "#", "#", "#", "#", "#", "#"] assert zone.terrain[0] == ["#", "#", "#", "#", "#", "#", "#", "#"]
assert zone.terrain[5] == ["#", "#", "#", "#", ".", "#", "#", "#"] assert zone.terrain[5] == ["#", "#", "#", "#", ".", "#", "#", "#"]
@ -146,233 +144,3 @@ def test_load_tavern_zone():
# Check that walls are impassable # Check that walls are impassable
assert not zone.is_passable(0, 0) assert not zone.is_passable(0, 0)
assert not zone.is_passable(7, 0) assert not zone.is_passable(7, 0)
def test_load_zone_with_spawn_point():
"""Load a zone with spawn_x and spawn_y defined."""
with tempfile.NamedTemporaryFile(mode="w", suffix=".toml", delete=False) as f:
f.write("""
name = "spawn_zone"
description = "a zone with spawn point"
width = 10
height = 10
spawn_x = 5
spawn_y = 7
[terrain]
rows = [
"..........",
"..........",
"..........",
"..........",
"..........",
"..........",
"..........",
"..........",
"..........",
"..........",
]
""")
temp_path = pathlib.Path(f.name)
try:
zone = load_zone(temp_path)
assert zone.spawn_x == 5
assert zone.spawn_y == 7
finally:
temp_path.unlink()
def test_load_zone_default_spawn_point():
"""Load a zone without spawn point defined defaults to (0, 0)."""
with tempfile.NamedTemporaryFile(mode="w", suffix=".toml", delete=False) as f:
f.write("""
name = "default_spawn"
description = "a zone without spawn point"
width = 5
height = 5
[terrain]
rows = [
".....",
".....",
".....",
".....",
".....",
]
""")
temp_path = pathlib.Path(f.name)
try:
zone = load_zone(temp_path)
assert zone.spawn_x == 0
assert zone.spawn_y == 0
finally:
temp_path.unlink()
def test_load_zone_with_portals():
"""Load a zone with portals defined."""
with tempfile.NamedTemporaryFile(mode="w", suffix=".toml", delete=False) as f:
f.write("""
name = "portal_zone"
description = "a zone with portals"
width = 10
height = 10
[terrain]
rows = [
"..........",
"..........",
"..........",
"..........",
"..........",
"..........",
"..........",
"..........",
"..........",
"..........",
]
[[portals]]
x = 5
y = 3
target = "tavern:1,1"
label = "tavern door"
[[portals]]
x = 2
y = 7
target = "forest:10,5"
label = "forest path"
""")
temp_path = pathlib.Path(f.name)
try:
zone = load_zone(temp_path)
# Find portals in zone contents
portals = [obj for obj in zone._contents if obj.__class__.__name__ == "Portal"]
assert len(portals) == 2
# Check first portal (tavern door at 5,3)
tavern_portal = [p for p in portals if p.name == "tavern door"][0]
assert tavern_portal.x == 5
assert tavern_portal.y == 3
assert tavern_portal.target_zone == "tavern"
assert tavern_portal.target_x == 1
assert tavern_portal.target_y == 1
assert tavern_portal.location == zone
# Check second portal (forest path at 2,7)
forest_portal = [p for p in portals if p.name == "forest path"][0]
assert forest_portal.x == 2
assert forest_portal.y == 7
assert forest_portal.target_zone == "forest"
assert forest_portal.target_x == 10
assert forest_portal.target_y == 5
assert forest_portal.location == zone
# Verify portals are at correct coordinates
contents_at_5_3 = zone.contents_at(5, 3)
assert tavern_portal in contents_at_5_3
contents_at_2_7 = zone.contents_at(2, 7)
assert forest_portal in contents_at_2_7
finally:
temp_path.unlink()
def test_load_zone_without_portals():
"""Load a zone without portals section works fine."""
with tempfile.NamedTemporaryFile(mode="w", suffix=".toml", delete=False) as f:
f.write("""
name = "no_portals"
description = "a zone without portals"
width = 3
height = 3
[terrain]
rows = [
"...",
"...",
"...",
]
""")
temp_path = pathlib.Path(f.name)
try:
zone = load_zone(temp_path)
# Should have no portals
portals = [obj for obj in zone._contents if obj.__class__.__name__ == "Portal"]
assert len(portals) == 0
finally:
temp_path.unlink()
def test_load_zone_portal_with_aliases():
"""Portal can have optional aliases field in TOML."""
with tempfile.NamedTemporaryFile(mode="w", suffix=".toml", delete=False) as f:
f.write("""
name = "alias_zone"
description = "a zone with aliased portal"
width = 5
height = 5
[terrain]
rows = [
".....",
".....",
".....",
".....",
".....",
]
[[portals]]
x = 2
y = 2
target = "elsewhere:0,0"
label = "mysterious gateway"
aliases = ["gateway", "gate", "portal"]
""")
temp_path = pathlib.Path(f.name)
try:
zone = load_zone(temp_path)
portals = [obj for obj in zone._contents if obj.__class__.__name__ == "Portal"]
assert len(portals) == 1
portal = portals[0]
assert portal.name == "mysterious gateway"
assert portal.aliases == ["gateway", "gate", "portal"]
finally:
temp_path.unlink()
def test_tavern_has_portal_to_hub():
"""Tavern zone has a portal at the door leading to hub zone."""
project_root = pathlib.Path(__file__).resolve().parents[1]
tavern_path = project_root / "content" / "zones" / "tavern.toml"
zone = load_zone(tavern_path)
# Find portals in zone
portals = [obj for obj in zone._contents if obj.__class__.__name__ == "Portal"]
assert len(portals) == 1
# Check portal is at the door (4, 5) and leads to hub
portal = portals[0]
assert portal.name == "the tavern door"
assert portal.x == 4
assert portal.y == 5
assert portal.target_zone == "hub"
assert portal.target_x == 14
assert portal.target_y == 7
# Verify portal is accessible at door coordinates
contents_at_door = zone.contents_at(4, 5)
assert portal in contents_at_door