Compare commits

..

10 commits

Author SHA1 Message Date
7d4a75f973
Show portals in look output
Look command now displays portals separately from ground items.
Portals at the player's position are shown after ground items with
the format "Portals: name1, name2". This separates portals from
regular items since they serve a different purpose in gameplay.
2026-02-11 20:58:55 -05:00
aa720edae5
Add enter command for portal zone transitions
Implements portal-based zone transitions with the enter command.
Players can enter portals at their position to move to target zones
with specified coordinates. Includes departure/arrival messaging to
nearby players and automatic look output in the destination zone.
Portals are matched by partial name or exact alias match.
2026-02-11 20:58:55 -05:00
557fffe5fa
Add put and take-from commands for containers 2026-02-11 20:58:55 -05:00
68161fd025
Add container support to thing template loader
Extended ThingTemplate with optional container fields (capacity, closed, locked).
When a template includes capacity, spawn_thing now creates a Container instead
of a regular Thing.

Added two example container templates:
- chest.toml: non-portable, capacity 5, starts closed
- sack.toml: portable, capacity 3, starts open
2026-02-11 20:58:55 -05:00
3be4370b2f
Show container state in look and inventory display
Containers now display their state when viewed:
- Closed containers show "(closed)"
- Open empty containers show "(open, empty)"
- Open containers with items show "(open, containing: item1, item2)"

This applies to both ground items in the look command and inventory items.
Added _format_thing_name helper to both look.py and things.py to handle
the display formatting consistently.
2026-02-11 20:58:55 -05:00
d18f21a031
Add zone TOML loader and tavern interior zone
Implements load_zone() and load_zones() functions to parse zone
definitions from TOML files. Wires zone loading into server startup
to register all zones from content/zones/ directory. Updates player
zone lookup to use the registry instead of hardcoded overworld check.

Includes tavern.toml as first hand-built interior zone (8x6 bounded).
2026-02-11 20:58:55 -05:00
5b9a43617f
Add open and close commands for containers 2026-02-11 20:58:55 -05:00
b3471a8b94
Add zone registry with register and lookup
Implements a module-level zone registry for looking up zones by name.
Includes register_zone() and get_zone() functions with comprehensive
tests covering single/multiple zones, unknown lookups, and overwrites.
2026-02-11 20:40:31 -05:00
303ce2c89e
Add Portal class with target zone and coordinates
Portals are non-portable Things that exist in zones and define
transitions to other zones via target coordinates.
2026-02-11 20:38:47 -05:00
621c42b833
Add Container class with capacity and open/closed state 2026-02-11 20:38:40 -05:00
23 changed files with 2410 additions and 11 deletions

View file

@ -0,0 +1,7 @@
name = "chest"
description = "a sturdy wooden chest with iron bindings"
portable = false
capacity = 5
closed = true
locked = false
aliases = ["box"]

7
content/things/sack.toml Normal file
View file

@ -0,0 +1,7 @@
name = "sack"
description = "a rough cloth sack with drawstring closure"
portable = true
capacity = 3
closed = false
locked = false
aliases = ["bag"]

19
content/zones/tavern.toml Normal file
View file

@ -0,0 +1,19 @@
name = "tavern"
description = "a cozy tavern with a crackling fireplace"
width = 8
height = 6
toroidal = false
[terrain]
# rows as strings, one per line
rows = [
"########",
"#......#",
"#......#",
"#......#",
"#......#",
"####.###",
]
[terrain.impassable]
tiles = ["#"]

View file

@ -0,0 +1,148 @@
"""Open and close commands for containers."""
from mudlib.commands import CommandDefinition, register
from mudlib.container import Container
from mudlib.player import Player
from mudlib.thing import Thing
from mudlib.zone import Zone
def _find_container(name: str, player: Player) -> Container | Thing | None:
"""Find a thing by name in inventory first, then on ground.
Returns Thing if found (caller must check if it's a Container).
Returns None if not found.
"""
name_lower = name.lower()
# Check inventory first
for obj in player.contents:
if not isinstance(obj, Thing):
continue
if obj.name.lower() == name_lower:
return obj
if name_lower in (a.lower() for a in obj.aliases):
return obj
# Check ground at player's position
zone = player.location
if zone is None or not isinstance(zone, Zone):
return None
for obj in zone.contents_at(player.x, player.y):
if not isinstance(obj, Thing):
continue
if obj.name.lower() == name_lower:
return obj
if name_lower in (a.lower() for a in obj.aliases):
return obj
return None
async def cmd_open(player: Player, args: str) -> None:
"""Open a container."""
if not args.strip():
await player.send("Open what?\r\n")
return
thing = _find_container(args.strip(), player)
if thing is None:
await player.send("You don't see that here.\r\n")
return
if not isinstance(thing, Container):
await player.send("You can't open that.\r\n")
return
if not thing.closed:
await player.send("It's already open.\r\n")
return
if thing.locked:
await player.send("It's locked.\r\n")
return
thing.closed = False
await player.send(f"You open the {thing.name}.\r\n")
async def cmd_close(player: Player, args: str) -> None:
"""Close a container."""
if not args.strip():
await player.send("Close what?\r\n")
return
thing = _find_container(args.strip(), player)
if thing is None:
await player.send("You don't see that here.\r\n")
return
if not isinstance(thing, Container):
await player.send("You can't close that.\r\n")
return
if thing.closed:
await player.send("It's already closed.\r\n")
return
thing.closed = True
await player.send(f"You close the {thing.name}.\r\n")
async def cmd_put(player: Player, args: str) -> None:
"""Put an item into a container."""
if not args.strip() or " in " not in args:
await player.send("Put what where? (put <item> in <container>)\r\n")
return
# Parse "thing in container"
parts = args.strip().split(" in ", 1)
if len(parts) != 2:
await player.send("Put what where? (put <item> in <container>)\r\n")
return
thing_name = parts[0].strip()
container_name = parts[1].strip()
if not thing_name or not container_name:
await player.send("Put what where? (put <item> in <container>)\r\n")
return
# Find thing in player's inventory
from mudlib.commands.things import _find_thing_in_inventory
thing = _find_thing_in_inventory(thing_name, player)
if thing is None:
await player.send("You're not carrying that.\r\n")
return
# Find container (on ground or in inventory)
container_obj = _find_container(container_name, player)
if container_obj is None:
await player.send("You don't see that here.\r\n")
return
if not isinstance(container_obj, Container):
await player.send("That's not a container.\r\n")
return
if thing is container_obj:
await player.send("You can't put something inside itself.\r\n")
return
if container_obj.closed:
await player.send(f"The {container_obj.name} is closed.\r\n")
return
if not container_obj.can_accept(thing):
await player.send(f"The {container_obj.name} is full.\r\n")
return
thing.move_to(container_obj)
await player.send(f"You put the {thing.name} in the {container_obj.name}.\r\n")
register(CommandDefinition("open", cmd_open))
register(CommandDefinition("close", cmd_close))
register(CommandDefinition("put", cmd_put))

View file

@ -1,6 +1,7 @@
"""Look command for viewing the world."""
from mudlib.commands import CommandDefinition, register
from mudlib.commands.things import _format_thing_name
from mudlib.effects import get_effects_at
from mudlib.entity import Entity
from mudlib.player import Player
@ -102,13 +103,24 @@ async def cmd_look(player: Player, args: str) -> None:
player.writer.write("\r\n".join(output_lines) + "\r\n")
# Show items on the ground at player's position
from mudlib.portal import Portal
contents_here = zone.contents_at(player.x, player.y)
ground_items = [
obj for obj in zone.contents_at(player.x, player.y) if isinstance(obj, Thing)
obj
for obj in contents_here
if isinstance(obj, Thing) and not isinstance(obj, Portal)
]
portals = [obj for obj in contents_here if isinstance(obj, Portal)]
if ground_items:
names = ", ".join(item.name for item in ground_items)
names = ", ".join(_format_thing_name(item) for item in ground_items)
player.writer.write(f"On the ground: {names}\r\n")
if portals:
names = ", ".join(p.name for p in portals)
player.writer.write(f"Portals: {names}\r\n")
await player.writer.drain()

View file

@ -0,0 +1,76 @@
"""Portal commands for zone transitions."""
from mudlib.commands import CommandDefinition, register
from mudlib.commands.movement import send_nearby_message
from mudlib.player import Player
from mudlib.portal import Portal
from mudlib.zone import Zone
from mudlib.zones import get_zone
async def cmd_enter(player: Player, args: str) -> None:
"""Enter a portal to transition to another zone.
Args:
player: The player executing the command
args: Portal name or alias to enter
"""
if not args.strip():
await player.send("Enter what?\r\n")
return
zone = player.location
if not isinstance(zone, Zone):
await player.send("You are nowhere.\r\n")
return
# Find portal at player's position
portals_here = [
obj for obj in zone.contents_at(player.x, player.y) if isinstance(obj, Portal)
]
# Match by name or alias (exact match only)
target_name = args.strip().lower()
portal = None
for p in portals_here:
if p.name.lower() == target_name:
portal = p
break
if target_name in (a.lower() for a in p.aliases):
portal = p
break
if not portal:
await player.send("You don't see that here.\r\n")
return
# Look up target zone
target_zone = get_zone(portal.target_zone)
if not target_zone:
await player.send("The portal doesn't lead anywhere.\r\n")
return
# Send departure message to nearby players in old zone
await send_nearby_message(
player, player.x, player.y, f"{player.name} enters {portal.name}.\r\n"
)
# Move player to target zone
player.move_to(target_zone, x=portal.target_x, y=portal.target_y)
# Send arrival message to nearby players in new zone
await send_nearby_message(
player,
player.x,
player.y,
f"{player.name} arrives from {portal.name}.\r\n",
)
# Show new zone to player
from mudlib.commands.look import cmd_look
await cmd_look(player, "")
# Register the enter command
register(CommandDefinition("enter", cmd_enter))

View file

@ -1,6 +1,7 @@
"""Get, drop, and inventory commands for items."""
from mudlib.commands import CommandDefinition, register
from mudlib.container import Container
from mudlib.player import Player
from mudlib.thing import Thing
from mudlib.zone import Zone
@ -32,12 +33,41 @@ def _find_thing_in_inventory(name: str, player: Player) -> Thing | None:
return None
def _format_thing_name(thing: Thing) -> str:
"""Format a thing's name with container state if applicable."""
if not isinstance(thing, Container):
return thing.name
if thing.closed:
return f"{thing.name} (closed)"
# Container is open
contents = [obj for obj in thing.contents if isinstance(obj, Thing)]
if not contents:
return f"{thing.name} (open, empty)"
names = ", ".join(item.name for item in contents)
return f"{thing.name} (open, containing: {names})"
async def cmd_get(player: Player, args: str) -> None:
"""Pick up an item from the ground."""
"""Pick up an item from the ground or take from a container."""
if not args.strip():
await player.send("Get what?\r\n")
return
# Check if this is "take/get X from Y" syntax
# Match " from " or "from " (at start) or " from" (at end)
args_lower = args.lower()
if (
" from " in args_lower
or args_lower.startswith("from ")
or args_lower.endswith(" from")
or args_lower == "from"
):
await _handle_take_from(player, args)
return
zone = player.location
if zone is None or not isinstance(zone, Zone):
await player.send("You are nowhere.\r\n")
@ -56,6 +86,54 @@ async def cmd_get(player: Player, args: str) -> None:
await player.send(f"You pick up {thing.name}.\r\n")
async def _handle_take_from(player: Player, args: str) -> None:
"""Handle 'take/get X from Y' to remove items from containers."""
# Parse "thing from container"
parts = args.strip().split(" from ", 1)
thing_name = parts[0].strip() if len(parts) > 0 else ""
container_name = parts[1].strip() if len(parts) > 1 else ""
if not thing_name or not container_name:
await player.send("Take what from where? (take <item> from <container>)\r\n")
return
# Find container (on ground or in inventory)
from mudlib.commands.containers import _find_container
container_obj = _find_container(container_name, player)
if container_obj is None:
await player.send("You don't see that here.\r\n")
return
if not isinstance(container_obj, Container):
await player.send("That's not a container.\r\n")
return
if container_obj.closed:
await player.send(f"The {container_obj.name} is closed.\r\n")
return
# Find thing in container
thing = None
thing_name_lower = thing_name.lower()
for obj in container_obj.contents:
if not isinstance(obj, Thing):
continue
if obj.name.lower() == thing_name_lower:
thing = obj
break
if thing_name_lower in (a.lower() for a in obj.aliases):
thing = obj
break
if thing is None:
await player.send(f"The {container_obj.name} doesn't contain that.\r\n")
return
thing.move_to(player)
await player.send(f"You take the {thing.name} from the {container_obj.name}.\r\n")
async def cmd_drop(player: Player, args: str) -> None:
"""Drop an item from inventory onto the ground."""
if not args.strip():
@ -86,7 +164,7 @@ async def cmd_inventory(player: Player, args: str) -> None:
lines = ["You are carrying:\r\n"]
for thing in things:
lines.append(f" {thing.name}\r\n")
lines.append(f" {_format_thing_name(thing)}\r\n")
await player.send("".join(lines))

29
src/mudlib/container.py Normal file
View file

@ -0,0 +1,29 @@
"""Container — a Thing that can hold other Things."""
from __future__ import annotations
from dataclasses import dataclass
from mudlib.object import Object
from mudlib.thing import Thing
@dataclass
class Container(Thing):
"""A container that can hold other items.
Containers are Things with capacity limits and open/closed state.
The locked flag is for command-layer logic (unlock/lock commands).
"""
capacity: int = 10
closed: bool = False
locked: bool = False
def can_accept(self, obj: Object) -> bool:
"""Accept Things when open and below capacity."""
if not isinstance(obj, Thing):
return False
if self.closed:
return False
return len(self._contents) < self.capacity

25
src/mudlib/portal.py Normal file
View file

@ -0,0 +1,25 @@
"""Portal — a transition point between zones."""
from __future__ import annotations
from dataclasses import dataclass
from mudlib.thing import Thing
@dataclass
class Portal(Thing):
"""A portal connecting zones.
Portals are non-portable Things that exist in zones and define
transitions to other zones via target coordinates.
"""
target_zone: str = ""
target_x: int = 0
target_y: int = 0
def __post_init__(self) -> None:
"""Force portals to be non-portable."""
self.portable = False
super().__post_init__()

View file

@ -13,12 +13,14 @@ from telnetlib3.server_shell import readline2
import mudlib.combat.commands
import mudlib.commands
import mudlib.commands.containers
import mudlib.commands.edit
import mudlib.commands.fly
import mudlib.commands.help
import mudlib.commands.look
import mudlib.commands.movement
import mudlib.commands.play
import mudlib.commands.portals
import mudlib.commands.quit
import mudlib.commands.reload
import mudlib.commands.spawn
@ -31,8 +33,6 @@ from mudlib.effects import clear_expired
from mudlib.if_session import broadcast_to_spectators
from mudlib.mob_ai import process_mobs
from mudlib.mobs import load_mob_templates, mob_templates
from mudlib.thing import Thing
from mudlib.things import load_thing_templates, spawn_thing, thing_templates
from mudlib.player import Player, players
from mudlib.resting import process_resting
from mudlib.store import (
@ -49,6 +49,7 @@ from mudlib.thing import Thing
from mudlib.things import load_thing_templates, spawn_thing, thing_templates
from mudlib.world.terrain import World
from mudlib.zone import Zone
from mudlib.zones import get_zone, load_zones, register_zone
log = logging.getLogger(__name__)
@ -270,12 +271,10 @@ async def shell(
"inventory": [],
}
# Resolve zone from zone_name (currently only overworld exists)
# Resolve zone from zone_name using zone registry
zone_name = player_data.get("zone_name", "overworld")
if zone_name == "overworld":
player_zone = _overworld
else:
# Future: lookup zone by name from a zone registry
player_zone = get_zone(zone_name)
if player_zone is None:
log.warning(
"unknown zone '%s' for player '%s', defaulting to overworld",
zone_name,
@ -460,6 +459,17 @@ async def run_server() -> None:
)
log.info("created overworld zone (%dx%d, toroidal)", world.width, world.height)
# Register overworld zone
register_zone("overworld", _overworld)
# Load and register zones from content/zones/
zones_dir = pathlib.Path(__file__).resolve().parents[2] / "content" / "zones"
if zones_dir.exists():
loaded_zones = load_zones(zones_dir)
for zone_name, zone in loaded_zones.items():
register_zone(zone_name, zone)
log.info("loaded %d zones from %s", len(loaded_zones), zones_dir)
# Load content-defined commands from TOML files
content_dir = pathlib.Path(__file__).resolve().parents[2] / "content" / "commands"
if content_dir.exists():

View file

@ -4,6 +4,7 @@ import tomllib
from dataclasses import dataclass, field
from pathlib import Path
from mudlib.container import Container
from mudlib.object import Object
from mudlib.thing import Thing
@ -16,6 +17,10 @@ class ThingTemplate:
description: str
portable: bool = True
aliases: list[str] = field(default_factory=list)
# Container fields (presence of capacity indicates a container template)
capacity: int | None = None
closed: bool = False
locked: bool = False
# Module-level registry
@ -31,6 +36,9 @@ def load_thing_template(path: Path) -> ThingTemplate:
description=data["description"],
portable=data.get("portable", True),
aliases=data.get("aliases", []),
capacity=data.get("capacity"),
closed=data.get("closed", False),
locked=data.get("locked", False),
)
@ -51,6 +59,21 @@ def spawn_thing(
y: int | None = None,
) -> Thing:
"""Create a Thing instance from a template at the given location."""
# If template has capacity, spawn a Container instead of a Thing
if template.capacity is not None:
return Container(
name=template.name,
description=template.description,
portable=template.portable,
aliases=list(template.aliases),
capacity=template.capacity,
closed=template.closed,
locked=template.locked,
location=location,
x=x,
y=y,
)
return Thing(
name=template.name,
description=template.description,

102
src/mudlib/zones.py Normal file
View file

@ -0,0 +1,102 @@
"""Zone registry and loading."""
from __future__ import annotations
import logging
import tomllib
from pathlib import Path
from mudlib.zone import Zone
log = logging.getLogger(__name__)
# Module-level zone registry
zone_registry: dict[str, Zone] = {}
def register_zone(name: str, zone: Zone) -> None:
"""Register a zone by name.
Args:
name: Unique name for the zone
zone: Zone instance to register
"""
zone_registry[name] = zone
def get_zone(name: str) -> Zone | None:
"""Look up a zone by name.
Args:
name: Zone name to look up
Returns:
Zone instance if found, None otherwise
"""
return zone_registry.get(name)
def load_zone(path: Path) -> Zone:
"""Load a zone from a TOML file.
Args:
path: Path to TOML file
Returns:
Zone instance loaded from file
"""
with open(path, "rb") as f:
data = tomllib.load(f)
# Extract basic properties
name = data["name"]
width = data["width"]
height = data["height"]
toroidal = data.get("toroidal", True)
# Parse terrain rows into 2D list
terrain_rows = data.get("terrain", {}).get("rows", [])
terrain = []
for row in terrain_rows:
terrain.append(list(row))
# Parse impassable tiles
impassable_list = data.get("terrain", {}).get("impassable", {}).get("tiles", [])
impassable = set(impassable_list) if impassable_list else {"^", "~"}
zone = Zone(
name=name,
width=width,
height=height,
toroidal=toroidal,
terrain=terrain,
impassable=impassable,
)
return zone
def load_zones(directory: Path) -> dict[str, Zone]:
"""Load all zones from a directory.
Args:
directory: Path to directory containing zone TOML files
Returns:
Dict mapping zone names to Zone instances
"""
zones = {}
if not directory.exists():
log.warning("zones directory does not exist: %s", directory)
return zones
for toml_file in directory.glob("*.toml"):
try:
zone = load_zone(toml_file)
zones[zone.name] = zone
log.debug("loaded zone '%s' from %s", zone.name, toml_file)
except Exception as e:
log.error("failed to load zone from %s: %s", toml_file, e, exc_info=True)
return zones

134
tests/test_container.py Normal file
View file

@ -0,0 +1,134 @@
"""Tests for the Container class."""
from mudlib.container import Container
from mudlib.object import Object
from mudlib.thing import Thing
from mudlib.zone import Zone
# --- construction ---
def test_container_creation_minimal():
"""Container can be created with just a name."""
c = Container(name="chest")
assert c.name == "chest"
assert c.capacity == 10
assert c.closed is False
assert c.locked is False
def test_container_creation_with_custom_capacity():
"""Container can have a custom capacity."""
c = Container(name="pouch", capacity=5)
assert c.capacity == 5
def test_container_creation_closed():
"""Container can be created in closed state."""
c = Container(name="chest", closed=True)
assert c.closed is True
def test_container_creation_locked():
"""Container can be created in locked state."""
c = Container(name="chest", locked=True)
assert c.locked is True
def test_container_is_thing_subclass():
"""Container is a Thing subclass."""
c = Container(name="chest")
assert isinstance(c, Thing)
assert isinstance(c, Object)
def test_container_inherits_thing_properties():
"""Container has all Thing properties."""
c = Container(
name="ornate chest",
description="a beautifully carved wooden chest",
portable=False,
aliases=["chest", "box"],
)
assert c.description == "a beautifully carved wooden chest"
assert c.portable is False
assert c.aliases == ["chest", "box"]
# --- can_accept ---
def test_container_accepts_thing_when_open():
"""Container accepts Things when open and has capacity."""
c = Container(name="chest")
sword = Thing(name="sword")
assert c.can_accept(sword) is True
def test_container_rejects_when_closed():
"""Container rejects Things when closed."""
c = Container(name="chest", closed=True)
sword = Thing(name="sword")
assert c.can_accept(sword) is False
def test_container_rejects_when_at_capacity():
"""Container rejects Things when at capacity."""
c = Container(name="pouch", capacity=2)
# Add two items
Thing(name="rock1", location=c)
Thing(name="rock2", location=c)
# Third should be rejected
rock3 = Thing(name="rock3")
assert c.can_accept(rock3) is False
def test_container_accepts_when_below_capacity():
"""Container accepts Things when below capacity."""
c = Container(name="pouch", capacity=2)
Thing(name="rock1", location=c)
# Second item should be accepted
rock2 = Thing(name="rock2")
assert c.can_accept(rock2) is True
def test_container_rejects_non_thing():
"""Container rejects objects that aren't Things."""
c = Container(name="chest")
other = Object(name="abstract")
assert c.can_accept(other) is False
def test_container_locked_is_just_flag():
"""Locked flag is stored but doesn't affect can_accept (used by commands)."""
c = Container(name="chest", locked=True, closed=False)
sword = Thing(name="sword")
# can_accept doesn't check locked (commands will check it separately)
# This test documents current behavior — locked is for command layer
assert c.locked is True
# can_accept only checks closed and capacity
assert c.can_accept(sword) is True
# --- integration with zones ---
def test_container_in_zone():
"""Container can be placed in a zone with coordinates."""
terrain = [["." for _ in range(10)] for _ in range(10)]
zone = Zone(name="test", width=10, height=10, terrain=terrain)
chest = Container(name="chest", location=zone, x=5, y=5)
assert chest.location is zone
assert chest.x == 5
assert chest.y == 5
assert chest in zone.contents
def test_container_with_contents():
"""Container can hold Things."""
chest = Container(name="chest")
sword = Thing(name="sword", location=chest)
gem = Thing(name="gem", location=chest)
assert sword in chest.contents
assert gem in chest.contents
assert len(chest.contents) == 2

View file

@ -0,0 +1,150 @@
"""Tests for container state display in look and inventory commands."""
from unittest.mock import AsyncMock, MagicMock
import pytest
from mudlib.container import Container
from mudlib.player import Player
from mudlib.thing import Thing
from mudlib.zone import Zone
@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 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
# --- look command container display ---
@pytest.mark.asyncio
async def test_look_shows_closed_container(player, test_zone, mock_writer):
"""look shows closed containers with (closed) suffix."""
from mudlib.commands.look import cmd_look
Container(name="chest", location=test_zone, x=5, y=5, closed=True)
await cmd_look(player, "")
output = "".join(call[0][0] for call in mock_writer.write.call_args_list)
assert "chest (closed)" in output
@pytest.mark.asyncio
async def test_look_shows_open_empty_container(player, test_zone, mock_writer):
"""look shows open empty containers with (open, empty) suffix."""
from mudlib.commands.look import cmd_look
Container(name="chest", location=test_zone, x=5, y=5, closed=False)
await cmd_look(player, "")
output = "".join(call[0][0] for call in mock_writer.write.call_args_list)
assert "chest (open, empty)" in output
@pytest.mark.asyncio
async def test_look_shows_open_container_with_contents(player, test_zone, mock_writer):
"""look shows open containers with their contents."""
from mudlib.commands.look import cmd_look
chest = Container(name="chest", location=test_zone, x=5, y=5, closed=False)
Thing(name="rock", location=chest)
Thing(name="coin", location=chest)
await cmd_look(player, "")
output = "".join(call[0][0] for call in mock_writer.write.call_args_list)
assert "chest (open, containing: rock, coin)" in output
@pytest.mark.asyncio
async def test_look_shows_regular_things_unchanged(player, test_zone, mock_writer):
"""look shows regular Things without container suffixes."""
from mudlib.commands.look import cmd_look
Thing(name="rock", location=test_zone, x=5, y=5)
await cmd_look(player, "")
output = "".join(call[0][0] for call in mock_writer.write.call_args_list)
assert "On the ground: rock" in output
assert "(closed)" not in output
assert "(open" not in output
# --- inventory command container display ---
@pytest.mark.asyncio
async def test_inventory_shows_closed_container(player, mock_writer):
"""inventory shows closed containers with (closed) suffix."""
from mudlib.commands.things import cmd_inventory
Container(name="sack", location=player, closed=True)
await cmd_inventory(player, "")
output = "".join(call[0][0] for call in mock_writer.write.call_args_list)
assert "sack (closed)" in output
@pytest.mark.asyncio
async def test_inventory_shows_open_empty_container(player, mock_writer):
"""inventory shows open empty containers with (open, empty) suffix."""
from mudlib.commands.things import cmd_inventory
Container(name="sack", location=player, closed=False)
await cmd_inventory(player, "")
output = "".join(call[0][0] for call in mock_writer.write.call_args_list)
assert "sack (open, empty)" in output
@pytest.mark.asyncio
async def test_inventory_shows_container_with_contents(player, mock_writer):
"""inventory shows open containers with their contents."""
from mudlib.commands.things import cmd_inventory
sack = Container(name="sack", location=player, closed=False)
Thing(name="rock", location=sack)
Thing(name="gem", location=sack)
await cmd_inventory(player, "")
output = "".join(call[0][0] for call in mock_writer.write.call_args_list)
assert "sack (open, containing: rock, gem)" in output
@pytest.mark.asyncio
async def test_inventory_shows_regular_things_unchanged(player, mock_writer):
"""inventory shows regular Things without container suffixes."""
from mudlib.commands.things import cmd_inventory
Thing(name="rock", location=player)
await cmd_inventory(player, "")
output = "".join(call[0][0] for call in mock_writer.write.call_args_list)
assert " rock\r\n" in output
assert "(closed)" not in output
assert "(open" not in output

View file

@ -0,0 +1,149 @@
"""Tests for container template loading and spawning."""
from pathlib import Path
from tempfile import TemporaryDirectory
from mudlib.container import Container
from mudlib.thing import Thing
from mudlib.things import load_thing_template, spawn_thing
from mudlib.zone import Zone
def test_load_container_template_with_capacity():
"""load_thing_template recognizes container templates (has capacity field)."""
with TemporaryDirectory() as tmpdir:
toml_path = Path(tmpdir) / "chest.toml"
toml_path.write_text(
"""
name = "chest"
description = "a wooden chest"
capacity = 5
"""
)
template = load_thing_template(toml_path)
assert template.name == "chest"
assert template.description == "a wooden chest"
assert hasattr(template, "capacity")
assert template.capacity == 5
def test_load_container_template_with_closed():
"""load_thing_template handles closed field."""
with TemporaryDirectory() as tmpdir:
toml_path = Path(tmpdir) / "chest.toml"
toml_path.write_text(
"""
name = "chest"
description = "a wooden chest"
capacity = 5
closed = true
"""
)
template = load_thing_template(toml_path)
assert template.closed is True
def test_load_container_template_with_locked():
"""load_thing_template handles locked field."""
with TemporaryDirectory() as tmpdir:
toml_path = Path(tmpdir) / "chest.toml"
toml_path.write_text(
"""
name = "chest"
description = "a wooden chest"
capacity = 5
closed = true
locked = true
"""
)
template = load_thing_template(toml_path)
assert template.locked is True
def test_load_container_template_defaults():
"""Container template fields have sensible defaults."""
with TemporaryDirectory() as tmpdir:
toml_path = Path(tmpdir) / "sack.toml"
toml_path.write_text(
"""
name = "sack"
description = "a cloth sack"
capacity = 3
"""
)
template = load_thing_template(toml_path)
assert template.capacity == 3
assert template.closed is False # default: open
assert template.locked is False # default: unlocked
def test_spawn_container_from_template():
"""spawn_thing creates a Container when template has capacity."""
with TemporaryDirectory() as tmpdir:
toml_path = Path(tmpdir) / "chest.toml"
toml_path.write_text(
"""
name = "chest"
description = "a wooden chest"
capacity = 5
closed = true
"""
)
template = load_thing_template(toml_path)
terrain = [["." for _ in range(10)] for _ in range(10)]
zone = Zone(name="test", width=10, height=10, terrain=terrain)
thing = spawn_thing(template, zone, x=5, y=5)
assert isinstance(thing, Container)
assert thing.name == "chest"
assert thing.description == "a wooden chest"
assert thing.capacity == 5
assert thing.closed is True
assert thing.location is zone
def test_spawn_regular_thing_from_template_without_capacity():
"""spawn_thing creates a regular Thing when template lacks capacity."""
with TemporaryDirectory() as tmpdir:
toml_path = Path(tmpdir) / "rock.toml"
toml_path.write_text(
"""
name = "rock"
description = "a smooth grey rock"
portable = true
"""
)
template = load_thing_template(toml_path)
terrain = [["." for _ in range(10)] for _ in range(10)]
zone = Zone(name="test", width=10, height=10, terrain=terrain)
thing = spawn_thing(template, zone, x=5, y=5)
assert isinstance(thing, Thing)
assert not isinstance(thing, Container)
assert thing.name == "rock"
assert thing.portable is True
def test_spawn_portable_container():
"""spawn_thing creates a portable Container (like a sack)."""
with TemporaryDirectory() as tmpdir:
toml_path = Path(tmpdir) / "sack.toml"
toml_path.write_text(
"""
name = "sack"
description = "a cloth sack"
capacity = 3
portable = true
"""
)
template = load_thing_template(toml_path)
terrain = [["." for _ in range(10)] for _ in range(10)]
zone = Zone(name="test", width=10, height=10, terrain=terrain)
thing = spawn_thing(template, zone, x=5, y=5)
assert isinstance(thing, Container)
assert thing.portable is True
assert thing.capacity == 3

284
tests/test_enter_portal.py Normal file
View file

@ -0,0 +1,284 @@
"""Tests for portal enter command."""
from unittest.mock import AsyncMock, MagicMock
import pytest
from mudlib.commands import _registry
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()
# --- cmd_enter ---
@pytest.mark.asyncio
async def test_enter_moves_to_target_zone(player, test_zone, target_zone):
"""enter portal moves player to target zone."""
from mudlib.commands.portals import cmd_enter
register_zone("targetzone", target_zone)
Portal(
name="shimmering doorway",
aliases=["doorway"],
location=test_zone,
x=5,
y=5,
target_zone="targetzone",
target_x=3,
target_y=7,
)
await cmd_enter(player, "doorway")
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_enter_sends_departure_message(
player, test_zone, target_zone, mock_writer
):
"""enter portal sends departure message to old zone."""
from mudlib.commands.portals import cmd_enter
register_zone("targetzone", target_zone)
Portal(
name="portal",
location=test_zone,
x=5,
y=5,
target_zone="targetzone",
target_x=3,
target_y=7,
)
other_player = Player(
name="OtherPlayer",
x=5,
y=5,
reader=MagicMock(),
writer=MagicMock(write=MagicMock(), drain=AsyncMock()),
location=test_zone,
)
await cmd_enter(player, "portal")
# Check that other player received departure message
other_writer = other_player.writer
calls = [call[0][0] for call in other_writer.write.call_args_list]
assert any("TestPlayer" in call and "enter" in call.lower() for call in calls)
@pytest.mark.asyncio
async def test_enter_sends_arrival_message(player, test_zone, target_zone):
"""enter portal sends arrival message to new zone."""
from mudlib.commands.portals import cmd_enter
register_zone("targetzone", target_zone)
Portal(
name="portal",
location=test_zone,
x=5,
y=5,
target_zone="targetzone",
target_x=3,
target_y=7,
)
other_player = Player(
name="OtherPlayer",
x=3,
y=7,
reader=MagicMock(),
writer=MagicMock(write=MagicMock(), drain=AsyncMock()),
location=target_zone,
)
await cmd_enter(player, "portal")
# Check that other player in target zone received arrival message
other_writer = other_player.writer
calls = [call[0][0] for call in other_writer.write.call_args_list]
assert any("TestPlayer" in call and "arrive" in call.lower() for call in calls)
@pytest.mark.asyncio
async def test_enter_shows_look_in_new_zone(
player, test_zone, target_zone, mock_writer
):
"""enter portal triggers look command in new zone."""
from mudlib.commands.portals import cmd_enter
register_zone("targetzone", target_zone)
Portal(
name="portal",
location=test_zone,
x=5,
y=5,
target_zone="targetzone",
target_x=3,
target_y=7,
)
await cmd_enter(player, "portal")
# Check that look output was sent (viewport grid should be in output)
output = "".join([call[0][0] for call in mock_writer.write.call_args_list])
# Should have the @ symbol for player position
assert "@" in output
@pytest.mark.asyncio
async def test_enter_no_args(player, mock_writer):
"""enter with no arguments gives usage hint."""
from mudlib.commands.portals import cmd_enter
await cmd_enter(player, "")
output = mock_writer.write.call_args_list[-1][0][0]
assert "enter what" in output.lower() or "usage" in output.lower()
@pytest.mark.asyncio
async def test_enter_portal_not_found(player, test_zone, mock_writer):
"""enter with portal not at position gives feedback."""
from mudlib.commands.portals import cmd_enter
await cmd_enter(player, "doorway")
output = mock_writer.write.call_args_list[-1][0][0]
assert "don't see" in output.lower()
@pytest.mark.asyncio
async def test_enter_target_zone_not_found(player, test_zone, mock_writer):
"""enter with invalid target zone gives graceful error."""
from mudlib.commands.portals import cmd_enter
Portal(
name="portal",
location=test_zone,
x=5,
y=5,
target_zone="nonexistent",
target_x=3,
target_y=7,
)
await cmd_enter(player, "portal")
output = mock_writer.write.call_args_list[-1][0][0]
assert "doesn't lead anywhere" in output.lower() or "nowhere" in output.lower()
# Player should still be in original zone
assert player.location is test_zone
@pytest.mark.asyncio
async def test_enter_matches_aliases(player, test_zone, target_zone):
"""enter matches portal aliases."""
from mudlib.commands.portals import cmd_enter
register_zone("targetzone", target_zone)
Portal(
name="shimmering doorway",
aliases=["door", "doorway"],
location=test_zone,
x=5,
y=5,
target_zone="targetzone",
target_x=3,
target_y=7,
)
await cmd_enter(player, "door")
assert player.location is target_zone
@pytest.mark.asyncio
async def test_enter_portal_elsewhere_in_zone(player, test_zone, mock_writer):
"""enter doesn't find portals not at player position."""
from mudlib.commands.portals import cmd_enter
Portal(
name="distant portal",
location=test_zone,
x=8,
y=8,
target_zone="targetzone",
target_x=3,
target_y=7,
)
await cmd_enter(player, "portal")
output = mock_writer.write.call_args_list[-1][0][0]
assert "don't see" in output.lower()
# Player should still be in original zone
assert player.location is test_zone
# --- command registration ---
def test_enter_command_registered():
"""enter command is registered."""
import mudlib.commands.portals # noqa: F401
assert "enter" in _registry

226
tests/test_open_close.py Normal file
View file

@ -0,0 +1,226 @@
"""Tests for open and close commands."""
from unittest.mock import AsyncMock, MagicMock
import pytest
from mudlib.commands import _registry
from mudlib.container import Container
from mudlib.player import Player
from mudlib.thing import Thing
from mudlib.zone import Zone
@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 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
# --- cmd_open ---
@pytest.mark.asyncio
async def test_open_container_on_ground(player, test_zone, mock_writer):
"""open finds container on ground and sets closed=False."""
from mudlib.commands.containers import cmd_open
chest = Container(name="chest", location=test_zone, x=5, y=5, closed=True)
await cmd_open(player, "chest")
assert chest.closed is False
output = mock_writer.write.call_args_list[-1][0][0]
assert "open" in output.lower() and "chest" in output.lower()
@pytest.mark.asyncio
async def test_open_container_in_inventory(player, test_zone, mock_writer):
"""open finds container in player inventory and sets closed=False."""
from mudlib.commands.containers import cmd_open
box = Container(name="box", location=player, closed=True)
await cmd_open(player, "box")
assert box.closed is False
output = mock_writer.write.call_args_list[-1][0][0]
assert "open" in output.lower() and "box" in output.lower()
@pytest.mark.asyncio
async def test_open_no_args(player, mock_writer):
"""open with no arguments gives usage hint."""
from mudlib.commands.containers import cmd_open
await cmd_open(player, "")
output = mock_writer.write.call_args_list[-1][0][0]
assert "open what" in output.lower() or "what" in output.lower()
@pytest.mark.asyncio
async def test_open_already_open(player, test_zone, mock_writer):
"""open on already-open container gives feedback."""
from mudlib.commands.containers import cmd_open
_chest = Container(name="chest", location=test_zone, x=5, y=5, closed=False)
await cmd_open(player, "chest")
output = mock_writer.write.call_args_list[-1][0][0]
assert "already open" in output.lower()
@pytest.mark.asyncio
async def test_open_locked_container(player, test_zone, mock_writer):
"""open on locked container gives feedback."""
from mudlib.commands.containers import cmd_open
chest = Container(
name="chest", location=test_zone, x=5, y=5, closed=True, locked=True
)
await cmd_open(player, "chest")
output = mock_writer.write.call_args_list[-1][0][0]
assert "locked" in output.lower()
# Container should still be closed
assert chest.closed is True
@pytest.mark.asyncio
async def test_open_not_found(player, test_zone, mock_writer):
"""open on non-existent thing gives feedback."""
from mudlib.commands.containers import cmd_open
await cmd_open(player, "chest")
output = mock_writer.write.call_args_list[-1][0][0]
assert "don't see" in output.lower()
@pytest.mark.asyncio
async def test_open_matches_aliases(player, test_zone):
"""open matches container aliases."""
from mudlib.commands.containers import cmd_open
chest = Container(
name="wooden chest",
aliases=["chest", "box"],
location=test_zone,
x=5,
y=5,
closed=True,
)
await cmd_open(player, "box")
assert chest.closed is False
@pytest.mark.asyncio
async def test_open_non_container(player, test_zone, mock_writer):
"""open on non-container thing gives feedback."""
from mudlib.commands.containers import cmd_open
_rock = Thing(name="rock", location=test_zone, x=5, y=5)
await cmd_open(player, "rock")
output = mock_writer.write.call_args_list[-1][0][0]
assert "can't open" in output.lower()
# --- cmd_close ---
@pytest.mark.asyncio
async def test_close_container_on_ground(player, test_zone, mock_writer):
"""close finds container on ground and sets closed=True."""
from mudlib.commands.containers import cmd_close
chest = Container(name="chest", location=test_zone, x=5, y=5, closed=False)
await cmd_close(player, "chest")
assert chest.closed is True
output = mock_writer.write.call_args_list[-1][0][0]
assert "close" in output.lower() and "chest" in output.lower()
@pytest.mark.asyncio
async def test_close_container_in_inventory(player, test_zone, mock_writer):
"""close finds container in inventory and sets closed=True."""
from mudlib.commands.containers import cmd_close
box = Container(name="box", location=player, closed=False)
await cmd_close(player, "box")
assert box.closed is True
output = mock_writer.write.call_args_list[-1][0][0]
assert "close" in output.lower() and "box" in output.lower()
@pytest.mark.asyncio
async def test_close_already_closed(player, test_zone, mock_writer):
"""close on already-closed container gives feedback."""
from mudlib.commands.containers import cmd_close
_chest = Container(name="chest", location=test_zone, x=5, y=5, closed=True)
await cmd_close(player, "chest")
output = mock_writer.write.call_args_list[-1][0][0]
assert "already closed" in output.lower()
@pytest.mark.asyncio
async def test_close_no_args(player, mock_writer):
"""close with no arguments gives usage hint."""
from mudlib.commands.containers import cmd_close
await cmd_close(player, "")
output = mock_writer.write.call_args_list[-1][0][0]
assert "close what" in output.lower() or "what" in output.lower()
@pytest.mark.asyncio
async def test_close_non_container(player, test_zone, mock_writer):
"""close on non-container thing gives feedback."""
from mudlib.commands.containers import cmd_close
_rock = Thing(name="rock", location=test_zone, x=5, y=5)
await cmd_close(player, "rock")
output = mock_writer.write.call_args_list[-1][0][0]
assert "can't close" in output.lower()
# --- command registration ---
def test_open_command_registered():
"""open command is registered."""
import mudlib.commands.containers # noqa: F401
assert "open" in _registry
def test_close_command_registered():
"""close command is registered."""
import mudlib.commands.containers # noqa: F401
assert "close" in _registry

96
tests/test_portal.py Normal file
View file

@ -0,0 +1,96 @@
"""Tests for the Portal class."""
from mudlib.object import Object
from mudlib.portal import Portal
from mudlib.thing import Thing
from mudlib.zone import Zone
# --- construction ---
def test_portal_creation_minimal():
"""Portal can be created with just a name."""
p = Portal(name="portal")
assert p.name == "portal"
assert p.location is None
assert p.target_zone == ""
assert p.target_x == 0
assert p.target_y == 0
def test_portal_creation_with_target():
"""Portal can be created with target zone and coordinates."""
p = Portal(name="gateway", target_zone="dungeon", target_x=5, target_y=10)
assert p.target_zone == "dungeon"
assert p.target_x == 5
assert p.target_y == 10
def test_portal_is_thing_subclass():
"""Portal inherits from Thing."""
p = Portal(name="portal")
assert isinstance(p, Thing)
def test_portal_is_object_subclass():
"""Portal inherits from Object (via Thing)."""
p = Portal(name="portal")
assert isinstance(p, Object)
def test_portal_always_non_portable():
"""Portal is always non-portable (cannot be picked up)."""
p = Portal(name="portal")
assert p.portable is False
def test_portal_forced_non_portable():
"""Portal forces portable=False even if explicitly set True."""
# Even if we try to make it portable, it should be forced to False
p = Portal(name="portal", portable=True)
assert p.portable is False
def test_portal_inherits_description():
"""Portal can have a description (from Thing)."""
p = Portal(name="gateway", description="a shimmering portal")
assert p.description == "a shimmering portal"
def test_portal_inherits_aliases():
"""Portal can have aliases (from Thing)."""
p = Portal(name="gateway", aliases=["portal", "gate"])
assert p.aliases == ["portal", "gate"]
def test_portal_in_zone():
"""Portal can exist in a zone with coordinates."""
terrain = [["." for _ in range(10)] for _ in range(10)]
zone = Zone(name="test", width=10, height=10, terrain=terrain)
p = Portal(
name="gateway",
location=zone,
x=3,
y=7,
target_zone="dungeon",
target_x=5,
target_y=5,
)
assert p.location is zone
assert p.x == 3
assert p.y == 7
assert p in zone.contents
def test_portal_rejects_contents():
"""Portal cannot accept other objects (uses Thing's default behavior)."""
p = Portal(name="portal")
obj = Object(name="rock")
assert p.can_accept(obj) is False
def test_portal_rejects_things():
"""Portal cannot accept things."""
p = Portal(name="portal")
thing = Thing(name="sword")
assert p.can_accept(thing) is False

View file

@ -0,0 +1,118 @@
"""Tests for portal display in look command."""
from unittest.mock import AsyncMock, MagicMock
import pytest
from mudlib.player import Player
from mudlib.portal import Portal
from mudlib.zone import Zone
@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 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.mark.asyncio
async def test_look_shows_portal_at_position(player, test_zone, mock_writer):
"""look command shows portals at player position."""
from mudlib.commands.look import cmd_look
Portal(
name="shimmering doorway",
location=test_zone,
x=5,
y=5,
target_zone="elsewhere",
target_x=0,
target_y=0,
)
await cmd_look(player, "")
output = "".join([call[0][0] for call in mock_writer.write.call_args_list])
assert "portal" in output.lower() and "shimmering doorway" in output.lower()
@pytest.mark.asyncio
async def test_look_shows_multiple_portals(player, test_zone, mock_writer):
"""look command shows multiple portals at player position."""
from mudlib.commands.look import cmd_look
Portal(
name="red portal",
location=test_zone,
x=5,
y=5,
target_zone="redzone",
target_x=0,
target_y=0,
)
Portal(
name="blue portal",
location=test_zone,
x=5,
y=5,
target_zone="bluezone",
target_x=0,
target_y=0,
)
await cmd_look(player, "")
output = "".join([call[0][0] for call in mock_writer.write.call_args_list])
assert "red portal" in output.lower()
assert "blue portal" in output.lower()
@pytest.mark.asyncio
async def test_look_no_portals_at_position(player, test_zone, mock_writer):
"""look command doesn't show portals when none at position."""
from mudlib.commands.look import cmd_look
Portal(
name="distant portal",
location=test_zone,
x=8,
y=8,
target_zone="elsewhere",
target_x=0,
target_y=0,
)
await cmd_look(player, "")
output = "".join([call[0][0] for call in mock_writer.write.call_args_list])
# Should not mention portals when none are at player position
assert "portal" not in output.lower() or "distant portal" not in output.lower()

381
tests/test_put_take.py Normal file
View file

@ -0,0 +1,381 @@
"""Tests for put and take-from commands."""
from unittest.mock import AsyncMock, MagicMock
import pytest
from mudlib.commands import _registry
from mudlib.container import Container
from mudlib.player import Player
from mudlib.thing import Thing
from mudlib.zone import Zone
@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 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
# --- cmd_put ---
@pytest.mark.asyncio
async def test_put_thing_in_container(player, test_zone, mock_writer):
"""put moves thing from inventory to container."""
from mudlib.commands.containers import cmd_put
chest = Container(
name="chest", location=test_zone, x=5, y=5, closed=False, capacity=10
)
rock = Thing(name="rock", location=player)
await cmd_put(player, "rock in chest")
assert rock.location == chest
assert rock in chest.contents
assert rock not in player.contents
output = mock_writer.write.call_args_list[-1][0][0]
assert "put" in output.lower() and "rock" in output.lower()
assert "chest" in output.lower()
@pytest.mark.asyncio
async def test_put_no_args(player, mock_writer):
"""put with no args gives usage message."""
from mudlib.commands.containers import cmd_put
await cmd_put(player, "")
output = mock_writer.write.call_args_list[-1][0][0]
assert "put" in output.lower() and "container" in output.lower()
@pytest.mark.asyncio
async def test_put_missing_container_arg(player, mock_writer):
"""put with missing container argument gives error."""
from mudlib.commands.containers import cmd_put
_rock = Thing(name="rock", location=player)
await cmd_put(player, "rock in")
output = mock_writer.write.call_args_list[-1][0][0]
assert "put" in output.lower() and "container" in output.lower()
@pytest.mark.asyncio
async def test_put_thing_not_in_inventory(player, test_zone, mock_writer):
"""put on thing not carried gives error."""
from mudlib.commands.containers import cmd_put
_chest = Container(
name="chest", location=test_zone, x=5, y=5, closed=False, capacity=10
)
_rock = Thing(name="rock", location=test_zone, x=5, y=5)
await cmd_put(player, "rock in chest")
output = mock_writer.write.call_args_list[-1][0][0]
assert "not carrying" in output.lower() or "aren't carrying" in output.lower()
@pytest.mark.asyncio
async def test_put_container_not_found(player, mock_writer):
"""put into non-existent container gives error."""
from mudlib.commands.containers import cmd_put
_rock = Thing(name="rock", location=player)
await cmd_put(player, "rock in chest")
output = mock_writer.write.call_args_list[-1][0][0]
assert "don't see" in output.lower()
@pytest.mark.asyncio
async def test_put_container_closed(player, test_zone, mock_writer):
"""put into closed container gives error."""
from mudlib.commands.containers import cmd_put
_chest = Container(
name="chest", location=test_zone, x=5, y=5, closed=True, capacity=10
)
rock = Thing(name="rock", location=player)
await cmd_put(player, "rock in chest")
assert rock.location == player # Should not have moved
output = mock_writer.write.call_args_list[-1][0][0]
assert "closed" in output.lower()
@pytest.mark.asyncio
async def test_put_container_full(player, test_zone, mock_writer):
"""put into full container gives error."""
from mudlib.commands.containers import cmd_put
chest = Container(
name="chest", location=test_zone, x=5, y=5, closed=False, capacity=1
)
# Fill the container
_other = Thing(name="other", location=chest)
rock = Thing(name="rock", location=player)
await cmd_put(player, "rock in chest")
assert rock.location == player # Should not have moved
output = mock_writer.write.call_args_list[-1][0][0]
assert "full" in output.lower()
@pytest.mark.asyncio
async def test_put_target_not_container(player, test_zone, mock_writer):
"""put into non-container thing gives error."""
from mudlib.commands.containers import cmd_put
_stick = Thing(name="stick", location=test_zone, x=5, y=5)
rock = Thing(name="rock", location=player)
await cmd_put(player, "rock in stick")
assert rock.location == player # Should not have moved
output = mock_writer.write.call_args_list[-1][0][0]
assert "not a container" in output.lower() or "can't" in output.lower()
@pytest.mark.asyncio
async def test_put_matches_thing_alias(player, test_zone):
"""put matches thing by alias."""
from mudlib.commands.containers import cmd_put
chest = Container(
name="chest", location=test_zone, x=5, y=5, closed=False, capacity=10
)
rock = Thing(name="small rock", aliases=["rock", "pebble"], location=player)
await cmd_put(player, "pebble in chest")
assert rock.location == chest
@pytest.mark.asyncio
async def test_put_matches_container_alias(player, test_zone):
"""put matches container by alias."""
from mudlib.commands.containers import cmd_put
chest = Container(
name="wooden chest",
aliases=["chest", "box"],
location=test_zone,
x=5,
y=5,
closed=False,
capacity=10,
)
rock = Thing(name="rock", location=player)
await cmd_put(player, "rock in box")
assert rock.location == chest
@pytest.mark.asyncio
async def test_put_container_in_inventory(player, test_zone):
"""put works with container in player inventory."""
from mudlib.commands.containers import cmd_put
box = Container(name="box", location=player, closed=False, capacity=10)
rock = Thing(name="rock", location=player)
await cmd_put(player, "rock in box")
assert rock.location == box
@pytest.mark.asyncio
async def test_put_container_in_itself(player, test_zone, mock_writer):
"""put container in itself gives error."""
from mudlib.commands.containers import cmd_put
box = Container(name="box", location=player, closed=False, capacity=10)
await cmd_put(player, "box in box")
assert box.location == player # Should not have moved
output = mock_writer.write.call_args_list[-1][0][0]
assert "can't put" in output.lower() and "itself" in output.lower()
# --- cmd_get with "from" (take-from) ---
@pytest.mark.asyncio
async def test_take_from_container(player, test_zone, mock_writer):
"""take from container moves thing to inventory."""
from mudlib.commands.things import cmd_get
chest = Container(
name="chest", location=test_zone, x=5, y=5, closed=False, capacity=10
)
rock = Thing(name="rock", location=chest)
await cmd_get(player, "rock from chest")
assert rock.location == player
assert rock in player.contents
assert rock not in chest.contents
output = mock_writer.write.call_args_list[-1][0][0]
assert "take" in output.lower() and "rock" in output.lower()
assert "chest" in output.lower()
@pytest.mark.asyncio
async def test_take_from_no_args(player, mock_writer):
"""take from with missing args gives usage message."""
from mudlib.commands.things import cmd_get
await cmd_get(player, "from")
output = mock_writer.write.call_args_list[-1][0][0]
assert "take" in output.lower() and "container" in output.lower()
@pytest.mark.asyncio
async def test_take_from_missing_container(player, test_zone, mock_writer):
"""take from with missing container gives error."""
from mudlib.commands.things import cmd_get
_chest = Container(
name="chest", location=test_zone, x=5, y=5, closed=False, capacity=10
)
await cmd_get(player, "rock from")
output = mock_writer.write.call_args_list[-1][0][0]
assert "take" in output.lower() and "container" in output.lower()
@pytest.mark.asyncio
async def test_take_from_container_not_found(player, test_zone, mock_writer):
"""take from non-existent container gives error."""
from mudlib.commands.things import cmd_get
await cmd_get(player, "rock from chest")
output = mock_writer.write.call_args_list[-1][0][0]
assert "don't see" in output.lower()
@pytest.mark.asyncio
async def test_take_from_closed_container(player, test_zone, mock_writer):
"""take from closed container gives error."""
from mudlib.commands.things import cmd_get
chest = Container(
name="chest", location=test_zone, x=5, y=5, closed=True, capacity=10
)
_rock = Thing(name="rock", location=chest)
await cmd_get(player, "rock from chest")
output = mock_writer.write.call_args_list[-1][0][0]
assert "closed" in output.lower()
@pytest.mark.asyncio
async def test_take_from_thing_not_in_container(player, test_zone, mock_writer):
"""take from container when thing not inside gives error."""
from mudlib.commands.things import cmd_get
_chest = Container(
name="chest", location=test_zone, x=5, y=5, closed=False, capacity=10
)
await cmd_get(player, "rock from chest")
output = mock_writer.write.call_args_list[-1][0][0]
assert "doesn't contain" in output.lower() or "not in" in output.lower()
@pytest.mark.asyncio
async def test_take_from_non_container(player, test_zone, mock_writer):
"""take from non-container thing gives error."""
from mudlib.commands.things import cmd_get
_stick = Thing(name="stick", location=test_zone, x=5, y=5)
await cmd_get(player, "rock from stick")
output = mock_writer.write.call_args_list[-1][0][0]
assert "not a container" in output.lower() or "can't" in output.lower()
@pytest.mark.asyncio
async def test_take_from_matches_thing_alias(player, test_zone):
"""take from matches thing by alias."""
from mudlib.commands.things import cmd_get
chest = Container(
name="chest", location=test_zone, x=5, y=5, closed=False, capacity=10
)
rock = Thing(name="small rock", aliases=["rock", "pebble"], location=chest)
await cmd_get(player, "pebble from chest")
assert rock.location == player
@pytest.mark.asyncio
async def test_take_from_matches_container_alias(player, test_zone):
"""take from matches container by alias."""
from mudlib.commands.things import cmd_get
chest = Container(
name="wooden chest",
aliases=["chest", "box"],
location=test_zone,
x=5,
y=5,
closed=False,
capacity=10,
)
rock = Thing(name="rock", location=chest)
await cmd_get(player, "rock from box")
assert rock.location == player
@pytest.mark.asyncio
async def test_take_from_container_in_inventory(player, test_zone):
"""take from works with container in player inventory."""
from mudlib.commands.things import cmd_get
box = Container(name="box", location=player, closed=False, capacity=10)
rock = Thing(name="rock", location=box)
await cmd_get(player, "rock from box")
assert rock.location == player
# --- command registration ---
def test_put_command_registered():
"""put command is registered."""
import mudlib.commands.containers # noqa: F401
assert "put" in _registry

View file

@ -0,0 +1,117 @@
"""Tests for two-way portal transitions."""
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 zone_a():
terrain = [["." for _ in range(10)] for _ in range(10)]
return Zone(
name="zone_a",
width=10,
height=10,
toroidal=True,
terrain=terrain,
)
@pytest.fixture
def zone_b():
terrain = [["." for _ in range(10)] for _ in range(10)]
return Zone(
name="zone_b",
width=10,
height=10,
toroidal=True,
terrain=terrain,
)
@pytest.fixture
def player(mock_reader, mock_writer, zone_a):
p = Player(
name="TestPlayer",
x=2,
y=2,
reader=mock_reader,
writer=mock_writer,
location=zone_a,
)
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_two_way_portal_transitions(player, zone_a, zone_b):
"""Portals work bidirectionally between zones."""
from mudlib.commands.portals import cmd_enter
# Register zones
register_zone("zone_a", zone_a)
register_zone("zone_b", zone_b)
# Create portal in zone A pointing to zone B
Portal(
name="doorway to B",
location=zone_a,
x=2,
y=2,
target_zone="zone_b",
target_x=7,
target_y=7,
)
# Create portal in zone B pointing to zone A
Portal(
name="doorway to A",
location=zone_b,
x=7,
y=7,
target_zone="zone_a",
target_x=2,
target_y=2,
)
# Player starts in zone A at (2, 2)
assert player.location is zone_a
assert player.x == 2
assert player.y == 2
# Enter portal to zone B
await cmd_enter(player, "doorway to B")
assert player.location is zone_b
assert player.x == 7
assert player.y == 7
# Enter portal back to zone A
await cmd_enter(player, "doorway to A")
assert player.location is zone_a
assert player.x == 2
assert player.y == 2

146
tests/test_zone_loading.py Normal file
View file

@ -0,0 +1,146 @@
"""Tests for zone loading from TOML files."""
import pathlib
import tempfile
from mudlib.zones import load_zone, load_zones
def test_load_zone():
"""Load a zone from TOML file."""
# Create a temporary TOML file
with tempfile.NamedTemporaryFile(mode="w", suffix=".toml", delete=False) as f:
f.write("""
name = "test_zone"
description = "a test zone"
width = 4
height = 3
toroidal = false
[terrain]
rows = [
"####",
"#..#",
"####",
]
[terrain.impassable]
tiles = ["#"]
""")
temp_path = pathlib.Path(f.name)
try:
zone = load_zone(temp_path)
assert zone.name == "test_zone"
assert zone.width == 4
assert zone.height == 3
assert zone.toroidal is False
assert len(zone.terrain) == 3
assert zone.terrain[0] == ["#", "#", "#", "#"]
assert zone.terrain[1] == ["#", ".", ".", "#"]
assert zone.terrain[2] == ["#", "#", "#", "#"]
assert zone.impassable == {"#"}
finally:
temp_path.unlink()
def test_load_zone_toroidal():
"""Load a toroidal zone."""
with tempfile.NamedTemporaryFile(mode="w", suffix=".toml", delete=False) as f:
f.write("""
name = "toroidal_zone"
description = "a toroidal test zone"
width = 3
height = 2
toroidal = true
[terrain]
rows = [
"...",
"...",
]
""")
temp_path = pathlib.Path(f.name)
try:
zone = load_zone(temp_path)
assert zone.toroidal is True
# Default impassable set from Zone class
assert zone.impassable == {"^", "~"}
finally:
temp_path.unlink()
def test_load_zones_from_directory():
"""Load all zones from a directory."""
with tempfile.TemporaryDirectory() as tmpdir:
tmpdir_path = pathlib.Path(tmpdir)
# Create two zone files
zone1_path = tmpdir_path / "zone1.toml"
zone1_path.write_text("""
name = "zone1"
description = "first zone"
width = 2
height = 2
toroidal = false
[terrain]
rows = [
"..",
"..",
]
""")
zone2_path = tmpdir_path / "zone2.toml"
zone2_path.write_text("""
name = "zone2"
description = "second zone"
width = 3
height = 3
toroidal = true
[terrain]
rows = [
"###",
"#.#",
"###",
]
""")
# Create a non-TOML file that should be ignored
(tmpdir_path / "readme.txt").write_text("not a zone file")
zones = load_zones(tmpdir_path)
assert len(zones) == 2
assert "zone1" in zones
assert "zone2" in zones
assert zones["zone1"].name == "zone1"
assert zones["zone2"].name == "zone2"
def test_load_tavern_zone():
"""Load the actual tavern zone file."""
# This tests the real tavern.toml file in content/zones/
project_root = pathlib.Path(__file__).resolve().parents[1]
tavern_path = project_root / "content" / "zones" / "tavern.toml"
zone = load_zone(tavern_path)
assert zone.name == "tavern"
assert zone.width == 8
assert zone.height == 6
assert zone.toroidal is False
assert len(zone.terrain) == 6
assert zone.terrain[0] == ["#", "#", "#", "#", "#", "#", "#", "#"]
assert zone.terrain[5] == ["#", "#", "#", "#", ".", "#", "#", "#"]
assert zone.impassable == {"#"}
# Check that interior is passable
assert zone.is_passable(1, 1)
assert zone.is_passable(4, 3)
# Check that walls are impassable
assert not zone.is_passable(0, 0)
assert not zone.is_passable(7, 0)

View file

@ -0,0 +1,62 @@
"""Tests for zone registry."""
import pytest
from mudlib.zone import Zone
from mudlib.zones import get_zone, register_zone, zone_registry
@pytest.fixture(autouse=True)
def clear_registry():
"""Clear zone registry before each test."""
zone_registry.clear()
yield
zone_registry.clear()
def test_register_zone():
"""Register a zone by name."""
zone = Zone(name="test_zone", width=10, height=10, terrain=[], toroidal=False)
register_zone("test_zone", zone)
assert "test_zone" in zone_registry
assert zone_registry["test_zone"] is zone
def test_get_zone():
"""Look up a zone by name."""
zone = Zone(name="test_zone", width=10, height=10, terrain=[], toroidal=False)
register_zone("test_zone", zone)
retrieved = get_zone("test_zone")
assert retrieved is zone
def test_get_zone_unknown():
"""Get None for unknown zone name."""
result = get_zone("nonexistent")
assert result is None
def test_register_multiple_zones():
"""Register multiple zones."""
zone1 = Zone(name="zone1", width=10, height=10, terrain=[], toroidal=False)
zone2 = Zone(name="zone2", width=20, height=15, terrain=[], toroidal=True)
register_zone("zone1", zone1)
register_zone("zone2", zone2)
assert len(zone_registry) == 2
assert get_zone("zone1") is zone1
assert get_zone("zone2") is zone2
def test_overwrite_zone():
"""Registering same name twice overwrites."""
zone1 = Zone(name="zone", width=10, height=10, terrain=[], toroidal=False)
zone2 = Zone(name="zone", width=20, height=20, terrain=[], toroidal=False)
register_zone("zone", zone1)
register_zone("zone", zone2)
assert get_zone("zone") is zone2