Compare commits
10 commits
05a739da74
...
7d4a75f973
| Author | SHA1 | Date | |
|---|---|---|---|
| 7d4a75f973 | |||
| aa720edae5 | |||
| 557fffe5fa | |||
| 68161fd025 | |||
| 3be4370b2f | |||
| d18f21a031 | |||
| 5b9a43617f | |||
| b3471a8b94 | |||
| 303ce2c89e | |||
| 621c42b833 |
23 changed files with 2410 additions and 11 deletions
7
content/things/chest.toml
Normal file
7
content/things/chest.toml
Normal 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
7
content/things/sack.toml
Normal 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
19
content/zones/tavern.toml
Normal 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 = ["#"]
|
||||
148
src/mudlib/commands/containers.py
Normal file
148
src/mudlib/commands/containers.py
Normal 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))
|
||||
|
|
@ -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()
|
||||
|
||||
|
||||
|
|
|
|||
76
src/mudlib/commands/portals.py
Normal file
76
src/mudlib/commands/portals.py
Normal 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))
|
||||
|
|
@ -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
29
src/mudlib/container.py
Normal 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
25
src/mudlib/portal.py
Normal 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__()
|
||||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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
102
src/mudlib/zones.py
Normal 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
134
tests/test_container.py
Normal 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
|
||||
150
tests/test_container_display.py
Normal file
150
tests/test_container_display.py
Normal 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
|
||||
149
tests/test_container_templates.py
Normal file
149
tests/test_container_templates.py
Normal 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
284
tests/test_enter_portal.py
Normal 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
226
tests/test_open_close.py
Normal 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
96
tests/test_portal.py
Normal 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
|
||||
118
tests/test_portal_display.py
Normal file
118
tests/test_portal_display.py
Normal 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
381
tests/test_put_take.py
Normal 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
|
||||
117
tests/test_two_way_portals.py
Normal file
117
tests/test_two_way_portals.py
Normal 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
146
tests/test_zone_loading.py
Normal 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)
|
||||
62
tests/test_zone_registry.py
Normal file
62
tests/test_zone_registry.py
Normal 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
|
||||
Loading…
Reference in a new issue