Compare commits
10 commits
e724abb926
...
058ba1b7de
| Author | SHA1 | Date | |
|---|---|---|---|
| 058ba1b7de | |||
| 3a756cc589 | |||
| 889a0d7bcf | |||
| c3884e236b | |||
| b123d55fbd | |||
| 56c82700b0 | |||
| 7154dd86d3 | |||
| d6920834c8 | |||
| b3801f780f | |||
| cb3ad6a547 |
19 changed files with 1778 additions and 3 deletions
27
content/zones/flower.toml
Normal file
27
content/zones/flower.toml
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
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"
|
||||
47
content/zones/hub.toml
Normal file
47
content/zones/hub.toml
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
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"
|
||||
|
|
@ -3,6 +3,8 @@ description = "a cozy tavern with a crackling fireplace"
|
|||
width = 8
|
||||
height = 6
|
||||
toroidal = false
|
||||
spawn_x = 1
|
||||
spawn_y = 1
|
||||
|
||||
[terrain]
|
||||
# rows as strings, one per line
|
||||
|
|
@ -17,3 +19,18 @@ rows = [
|
|||
|
||||
[terrain.impassable]
|
||||
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",
|
||||
]
|
||||
|
|
|
|||
51
content/zones/treehouse.toml
Normal file
51
content/zones/treehouse.toml
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
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
|
||||
|
|
@ -3,7 +3,9 @@
|
|||
from mudlib.commands import CommandDefinition, register
|
||||
from mudlib.entity import Entity
|
||||
from mudlib.player import Player
|
||||
from mudlib.portal import Portal
|
||||
from mudlib.zone import Zone
|
||||
from mudlib.zones import get_zone
|
||||
|
||||
# Direction mappings: command -> (dx, dy)
|
||||
DIRECTIONS: dict[str, tuple[int, int]] = {
|
||||
|
|
@ -66,11 +68,15 @@ 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"
|
||||
target_x, target_y = zone.wrap(player.x + dx, player.y + dy)
|
||||
|
||||
# Check if the target is passable
|
||||
if not zone.is_passable(target_x, target_y):
|
||||
# Check if the target is passable (skip check in paint mode)
|
||||
if not player.paint_mode and not zone.is_passable(target_x, target_y):
|
||||
await player.send("You can't go that way.\r\n")
|
||||
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
|
||||
opposite = OPPOSITE_DIRECTIONS[direction_name]
|
||||
await send_nearby_message(
|
||||
|
|
@ -81,6 +87,34 @@ async def move_player(player: Player, dx: int, dy: int, direction_name: str) ->
|
|||
player.x = target_x
|
||||
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
|
||||
await send_nearby_message(
|
||||
player, player.x, player.y, f"{player.name} arrives from the {opposite}.\r\n"
|
||||
|
|
|
|||
58
src/mudlib/commands/paint.py
Normal file
58
src/mudlib/commands/paint.py
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
"""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))
|
||||
96
src/mudlib/export.py
Normal file
96
src/mudlib/export.py
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
"""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)
|
||||
|
|
@ -25,6 +25,9 @@ class Player(Entity):
|
|||
caps: ClientCaps = field(default_factory=lambda: ClientCaps(ansi=True))
|
||||
editor: Editor | None = None
|
||||
if_session: IFSession | EmbeddedIFSession | None = None
|
||||
paint_mode: bool = False
|
||||
painting: bool = False
|
||||
paint_brush: str = "."
|
||||
|
||||
@property
|
||||
def mode(self) -> str:
|
||||
|
|
|
|||
|
|
@ -2,11 +2,25 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
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
|
||||
class Zone(Object):
|
||||
"""A spatial area with a grid of terrain tiles.
|
||||
|
|
@ -16,11 +30,17 @@ class Zone(Object):
|
|||
a zone. A tavern interior is a zone. A pocket dimension is a zone.
|
||||
"""
|
||||
|
||||
description: str = ""
|
||||
width: int = 0
|
||||
height: int = 0
|
||||
toroidal: bool = True
|
||||
terrain: list[list[str]] = field(default_factory=list)
|
||||
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:
|
||||
"""Zones accept everything."""
|
||||
|
|
@ -101,3 +121,18 @@ class Zone(Object):
|
|||
nearby.append(obj)
|
||||
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -6,7 +6,8 @@ import logging
|
|||
import tomllib
|
||||
from pathlib import Path
|
||||
|
||||
from mudlib.zone import Zone
|
||||
from mudlib.portal import Portal
|
||||
from mudlib.zone import SpawnRule, Zone
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -50,9 +51,12 @@ def load_zone(path: Path) -> Zone:
|
|||
|
||||
# Extract basic properties
|
||||
name = data["name"]
|
||||
description = data.get("description", "")
|
||||
width = data["width"]
|
||||
height = data["height"]
|
||||
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
|
||||
terrain_rows = data.get("terrain", {}).get("rows", [])
|
||||
|
|
@ -64,15 +68,67 @@ def load_zone(path: Path) -> Zone:
|
|||
impassable_list = data.get("terrain", {}).get("impassable", {}).get("tiles", [])
|
||||
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(
|
||||
name=name,
|
||||
description=description,
|
||||
width=width,
|
||||
height=height,
|
||||
toroidal=toroidal,
|
||||
terrain=terrain,
|
||||
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
|
||||
|
||||
|
||||
|
|
|
|||
90
tests/test_ambient_messages.py
Normal file
90
tests/test_ambient_messages.py
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
"""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
|
||||
67
tests/test_hub_zone.py
Normal file
67
tests/test_hub_zone.py
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
"""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
|
||||
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
|
||||
175
tests/test_portal_autotrigger.py
Normal file
175
tests/test_portal_autotrigger.py
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
"""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()
|
||||
125
tests/test_tutorial_zones.py
Normal file
125
tests/test_tutorial_zones.py
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
"""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()
|
||||
|
|
@ -32,6 +32,20 @@ def test_zone_default_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 ---
|
||||
|
||||
|
||||
|
|
|
|||
324
tests/test_zone_export.py
Normal file
324
tests/test_zone_export.py
Normal file
|
|
@ -0,0 +1,324 @@
|
|||
"""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
|
||||
|
|
@ -134,6 +134,8 @@ def test_load_tavern_zone():
|
|||
assert zone.width == 8
|
||||
assert zone.height == 6
|
||||
assert zone.toroidal is False
|
||||
assert zone.spawn_x == 1
|
||||
assert zone.spawn_y == 1
|
||||
assert len(zone.terrain) == 6
|
||||
assert zone.terrain[0] == ["#", "#", "#", "#", "#", "#", "#", "#"]
|
||||
assert zone.terrain[5] == ["#", "#", "#", "#", ".", "#", "#", "#"]
|
||||
|
|
@ -144,3 +146,233 @@ def test_load_tavern_zone():
|
|||
# Check that walls are impassable
|
||||
assert not zone.is_passable(0, 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
|
||||
|
|
|
|||
Loading…
Reference in a new issue