Compare commits

..

8 commits

Author SHA1 Message Date
05a739da74
Add test for duplicate item persistence 2026-02-11 20:29:59 -05:00
8acfa5ea22
Wire thing templates and inventory into server startup
Loads thing templates from content/things/ at startup. Registers
get/drop/inventory commands via things module import. Reconstructs
player inventory from saved template names on login, with graceful
fallback for unknown templates.
2026-02-11 20:29:59 -05:00
6081c90ad1
Add inventory persistence to player saves
Inventory saved as JSON list of thing template names in an inventory
column. Migration adds column to existing databases. load_player_data
returns inventory list, save_player serializes Thing names from contents.
2026-02-11 20:29:58 -05:00
c43b3346ae
Add Thing templates, TOML loading, and spawning
ThingTemplate dataclass mirrors MobTemplate pattern. load_thing_template
and load_thing_templates parse TOML files from content/things/. spawn_thing
creates Thing instances from templates. Includes rock and fountain examples.
2026-02-11 20:01:15 -05:00
2e79255aec
Show ground items in look command
After the viewport, look lists Things at the player's position
in a "On the ground: item1, item2" line. No output when empty.
2026-02-11 20:01:10 -05:00
e96fd50de5
Add inventory command with alias "i"
Lists Thing objects in player.contents with 2-space indented format.
Shows "You aren't carrying anything." when inventory is empty.
2026-02-11 20:01:05 -05:00
7c12bf3318
Add Object.move_to(), get and drop commands
Object.move_to() handles containment transfer: removes from old location's
contents, updates location pointer and coordinates, adds to new location.
get/drop commands use move_to to transfer Things between zone and inventory.
Supports name and alias matching for item lookup.
2026-02-11 19:57:38 -05:00
9437728435
Add Thing class and Entity.can_accept() for inventory
Thing is an Object subclass with description, portable flag, and aliases.
Entity.can_accept() returns True for portable Things, enabling the
containment model where entities carry items in their contents.
2026-02-11 19:55:58 -05:00
16 changed files with 1199 additions and 24 deletions

View file

@ -0,0 +1,3 @@
name = "fountain"
description = "a weathered stone fountain, water trickling into a mossy basin"
portable = false

4
content/things/rock.toml Normal file
View file

@ -0,0 +1,4 @@
name = "rock"
description = "a smooth grey rock, worn by wind and rain"
portable = true
aliases = ["stone"]

View file

@ -5,6 +5,7 @@ from mudlib.effects import get_effects_at
from mudlib.entity import Entity from mudlib.entity import Entity
from mudlib.player import Player from mudlib.player import Player
from mudlib.render.ansi import RESET, colorize_terrain from mudlib.render.ansi import RESET, colorize_terrain
from mudlib.thing import Thing
from mudlib.zone import Zone from mudlib.zone import Zone
# Viewport dimensions # Viewport dimensions
@ -99,6 +100,15 @@ async def cmd_look(player: Player, args: str) -> None:
# Send to player # Send to player
player.writer.write("\r\n".join(output_lines) + "\r\n") player.writer.write("\r\n".join(output_lines) + "\r\n")
# Show items on the ground at player's position
ground_items = [
obj for obj in zone.contents_at(player.x, player.y) if isinstance(obj, Thing)
]
if ground_items:
names = ", ".join(item.name for item in ground_items)
player.writer.write(f"On the ground: {names}\r\n")
await player.writer.drain() await player.writer.drain()

View file

@ -0,0 +1,96 @@
"""Get, drop, and inventory commands for items."""
from mudlib.commands import CommandDefinition, register
from mudlib.player import Player
from mudlib.thing import Thing
from mudlib.zone import Zone
def _find_thing_at(name: str, zone: Zone, x: int, y: int) -> Thing | None:
"""Find a thing on the ground matching name or alias."""
name_lower = name.lower()
for obj in zone.contents_at(x, 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
def _find_thing_in_inventory(name: str, player: Player) -> Thing | None:
"""Find a thing in the player's inventory matching name or alias."""
name_lower = name.lower()
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
return None
async def cmd_get(player: Player, args: str) -> None:
"""Pick up an item from the ground."""
if not args.strip():
await player.send("Get what?\r\n")
return
zone = player.location
if zone is None or not isinstance(zone, Zone):
await player.send("You are nowhere.\r\n")
return
thing = _find_thing_at(args.strip(), zone, player.x, player.y)
if thing is None:
await player.send(f"You don't see '{args.strip()}' here.\r\n")
return
if not player.can_accept(thing):
await player.send("You can't pick that up.\r\n")
return
thing.move_to(player)
await player.send(f"You pick up {thing.name}.\r\n")
async def cmd_drop(player: Player, args: str) -> None:
"""Drop an item from inventory onto the ground."""
if not args.strip():
await player.send("Drop what?\r\n")
return
zone = player.location
if zone is None or not isinstance(zone, Zone):
await player.send("You can't drop things here.\r\n")
return
thing = _find_thing_in_inventory(args.strip(), player)
if thing is None:
await player.send(f"You're not carrying '{args.strip()}'.\r\n")
return
thing.move_to(zone, x=player.x, y=player.y)
await player.send(f"You drop {thing.name}.\r\n")
async def cmd_inventory(player: Player, args: str) -> None:
"""List items in the player's inventory."""
things = [obj for obj in player.contents if isinstance(obj, Thing)]
if not things:
await player.send("You aren't carrying anything.\r\n")
return
lines = ["You are carrying:\r\n"]
for thing in things:
lines.append(f" {thing.name}\r\n")
await player.send("".join(lines))
register(CommandDefinition("get", cmd_get, aliases=["take"]))
register(CommandDefinition("drop", cmd_drop))
register(CommandDefinition("inventory", cmd_inventory, aliases=["i"]))

View file

@ -1,5 +1,7 @@
"""Base entity class for characters in the world.""" """Base entity class for characters in the world."""
from __future__ import annotations
from dataclasses import dataclass, field from dataclasses import dataclass, field
from mudlib.object import Object from mudlib.object import Object
@ -23,6 +25,12 @@ class Entity(Object):
defense_locked_until: float = 0.0 # monotonic time when defense recovery ends defense_locked_until: float = 0.0 # monotonic time when defense recovery ends
resting: bool = False # whether this entity is currently resting resting: bool = False # whether this entity is currently resting
def can_accept(self, obj: Object) -> bool:
"""Entities accept portable Things (inventory)."""
from mudlib.thing import Thing
return isinstance(obj, Thing) and obj.portable
async def send(self, message: str) -> None: async def send(self, message: str) -> None:
"""Send a message to this entity. Base implementation is a no-op.""" """Send a message to this entity. Base implementation is a no-op."""
pass pass

View file

@ -32,6 +32,32 @@ class Object:
"""Everything whose location is this object.""" """Everything whose location is this object."""
return list(self._contents) return list(self._contents)
def move_to(
self,
destination: Object | None,
*,
x: int | None = None,
y: int | None = None,
) -> None:
"""Move this object to a new location.
Removes from old location's contents, updates the location pointer,
and adds to new location's contents. Coordinates are set from the
keyword arguments (cleared to None if not provided).
"""
# Remove from old location
if self.location is not None and self in self.location._contents:
self.location._contents.remove(self)
# Update location and coordinates
self.location = destination
self.x = x
self.y = y
# Add to new location
if destination is not None:
destination._contents.append(self)
def can_accept(self, obj: Object) -> bool: def can_accept(self, obj: Object) -> bool:
"""Whether this object accepts obj as contents. Default: no.""" """Whether this object accepts obj as contents. Default: no."""
return False return False

View file

@ -22,6 +22,7 @@ import mudlib.commands.play
import mudlib.commands.quit import mudlib.commands.quit
import mudlib.commands.reload import mudlib.commands.reload
import mudlib.commands.spawn import mudlib.commands.spawn
import mudlib.commands.things
from mudlib.caps import parse_mtts from mudlib.caps import parse_mtts
from mudlib.combat.commands import register_combat_commands from mudlib.combat.commands import register_combat_commands
from mudlib.combat.engine import process_combat from mudlib.combat.engine import process_combat
@ -30,6 +31,8 @@ from mudlib.effects import clear_expired
from mudlib.if_session import broadcast_to_spectators from mudlib.if_session import broadcast_to_spectators
from mudlib.mob_ai import process_mobs from mudlib.mob_ai import process_mobs
from mudlib.mobs import load_mob_templates, mob_templates 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.player import Player, players
from mudlib.resting import process_resting from mudlib.resting import process_resting
from mudlib.store import ( from mudlib.store import (
@ -42,6 +45,8 @@ from mudlib.store import (
save_player, save_player,
update_last_login, update_last_login,
) )
from mudlib.thing import Thing
from mudlib.things import load_thing_templates, spawn_thing, thing_templates
from mudlib.world.terrain import World from mudlib.world.terrain import World
from mudlib.zone import Zone from mudlib.zone import Zone
@ -262,6 +267,7 @@ async def shell(
"max_stamina": 100.0, "max_stamina": 100.0,
"flying": False, "flying": False,
"zone_name": "overworld", "zone_name": "overworld",
"inventory": [],
} }
# Resolve zone from zone_name (currently only overworld exists) # Resolve zone from zone_name (currently only overworld exists)
@ -300,6 +306,20 @@ async def shell(
reader=_reader, reader=_reader,
) )
# Reconstruct inventory from saved data
for item_name in player_data.get("inventory", []):
template = thing_templates.get(item_name)
if template:
spawn_thing(template, player)
else:
# Template not found — create a bare Thing so it's not lost
log.warning(
"unknown thing template '%s' for player '%s'",
item_name,
player_name,
)
Thing(name=item_name, location=player)
# Parse and store client capabilities from MTTS # Parse and store client capabilities from MTTS
ttype3 = _writer.get_extra_info("ttype3") ttype3 = _writer.get_extra_info("ttype3")
player.caps = parse_mtts(ttype3) player.caps = parse_mtts(ttype3)
@ -463,6 +483,13 @@ async def run_server() -> None:
mob_templates.update(loaded) mob_templates.update(loaded)
log.info("loaded %d mob templates from %s", len(loaded), mobs_dir) log.info("loaded %d mob templates from %s", len(loaded), mobs_dir)
# Load thing templates
things_dir = pathlib.Path(__file__).resolve().parents[2] / "content" / "things"
if things_dir.exists():
loaded_things = load_thing_templates(things_dir)
thing_templates.update(loaded_things)
log.info("loaded %d thing templates from %s", len(loaded_things), things_dir)
# connect_maxwait: how long to wait for telnet option negotiation (CHARSET # connect_maxwait: how long to wait for telnet option negotiation (CHARSET
# etc) before starting the shell. default is 4.0s which is painful. # etc) before starting the shell. default is 4.0s which is painful.
# MUD clients like tintin++ reject CHARSET immediately via MTTS, but # MUD clients like tintin++ reject CHARSET immediately via MTTS, but

View file

@ -2,12 +2,14 @@
import hashlib import hashlib
import hmac import hmac
import json
import os import os
import sqlite3 import sqlite3
from pathlib import Path from pathlib import Path
from typing import TypedDict from typing import TypedDict
from mudlib.player import Player from mudlib.player import Player
from mudlib.thing import Thing
class PlayerData(TypedDict): class PlayerData(TypedDict):
@ -20,6 +22,7 @@ class PlayerData(TypedDict):
max_stamina: float max_stamina: float
flying: bool flying: bool
zone_name: str zone_name: str
inventory: list[str]
# Module-level database path # Module-level database path
@ -53,12 +56,13 @@ def init_db(db_path: str | Path) -> None:
max_stamina REAL NOT NULL DEFAULT 100.0, max_stamina REAL NOT NULL DEFAULT 100.0,
flying INTEGER NOT NULL DEFAULT 0, flying INTEGER NOT NULL DEFAULT 0,
zone_name TEXT NOT NULL DEFAULT 'overworld', zone_name TEXT NOT NULL DEFAULT 'overworld',
inventory TEXT NOT NULL DEFAULT '[]',
created_at TEXT NOT NULL DEFAULT (datetime('now')), created_at TEXT NOT NULL DEFAULT (datetime('now')),
last_login TEXT last_login TEXT
) )
""") """)
# Migration: add zone_name column if it doesn't exist # Migrations: add columns if they don't exist (old schemas)
cursor.execute("PRAGMA table_info(accounts)") cursor.execute("PRAGMA table_info(accounts)")
columns = [row[1] for row in cursor.fetchall()] columns = [row[1] for row in cursor.fetchall()]
if "zone_name" not in columns: if "zone_name" not in columns:
@ -66,6 +70,10 @@ def init_db(db_path: str | Path) -> None:
"ALTER TABLE accounts " "ALTER TABLE accounts "
"ADD COLUMN zone_name TEXT NOT NULL DEFAULT 'overworld'" "ADD COLUMN zone_name TEXT NOT NULL DEFAULT 'overworld'"
) )
if "inventory" not in columns:
cursor.execute(
"ALTER TABLE accounts ADD COLUMN inventory TEXT NOT NULL DEFAULT '[]'"
)
conn.commit() conn.commit()
conn.close() conn.close()
@ -188,6 +196,10 @@ def save_player(player: Player) -> None:
Args: Args:
player: Player instance to save player: Player instance to save
""" """
# Serialize inventory as JSON list of thing names
inventory_names = [obj.name for obj in player.contents if isinstance(obj, Thing)]
inventory_json = json.dumps(inventory_names)
conn = _get_connection() conn = _get_connection()
cursor = conn.cursor() cursor = conn.cursor()
@ -195,7 +207,7 @@ def save_player(player: Player) -> None:
""" """
UPDATE accounts UPDATE accounts
SET x = ?, y = ?, pl = ?, stamina = ?, max_stamina = ?, flying = ?, SET x = ?, y = ?, pl = ?, stamina = ?, max_stamina = ?, flying = ?,
zone_name = ? zone_name = ?, inventory = ?
WHERE name = ? WHERE name = ?
""", """,
( (
@ -206,6 +218,7 @@ def save_player(player: Player) -> None:
player.max_stamina, player.max_stamina,
1 if player.flying else 0, 1 if player.flying else 0,
player.location.name if player.location else "overworld", player.location.name if player.location else "overworld",
inventory_json,
player.name, player.name,
), ),
) )
@ -226,29 +239,23 @@ def load_player_data(name: str) -> PlayerData | None:
conn = _get_connection() conn = _get_connection()
cursor = conn.cursor() cursor = conn.cursor()
# Check if zone_name column exists (for migration) # Check which columns exist (for migration compatibility)
cursor.execute("PRAGMA table_info(accounts)") cursor.execute("PRAGMA table_info(accounts)")
columns = [row[1] for row in cursor.fetchall()] columns = [row[1] for row in cursor.fetchall()]
has_zone_name = "zone_name" in columns has_zone_name = "zone_name" in columns
has_inventory = "inventory" in columns
# Build SELECT based on available columns
select_cols = "x, y, pl, stamina, max_stamina, flying"
if has_zone_name: if has_zone_name:
cursor.execute( select_cols += ", zone_name"
""" if has_inventory:
SELECT x, y, pl, stamina, max_stamina, flying, zone_name select_cols += ", inventory"
FROM accounts
WHERE name = ? cursor.execute(
""", f"SELECT {select_cols} FROM accounts WHERE name = ?",
(name,), (name,),
) )
else:
cursor.execute(
"""
SELECT x, y, pl, stamina, max_stamina, flying
FROM accounts
WHERE name = ?
""",
(name,),
)
result = cursor.fetchone() result = cursor.fetchone()
conn.close() conn.close()
@ -256,11 +263,18 @@ def load_player_data(name: str) -> PlayerData | None:
if result is None: if result is None:
return None return None
# Unpack base fields
x, y, pl, stamina, max_stamina, flying_int = result[:6]
idx = 6
zone_name = "overworld"
if has_zone_name: if has_zone_name:
x, y, pl, stamina, max_stamina, flying_int, zone_name = result zone_name = result[idx]
else: idx += 1
x, y, pl, stamina, max_stamina, flying_int = result
zone_name = "overworld" # Default for old schemas inventory: list[str] = []
if has_inventory:
inventory = json.loads(result[idx])
return { return {
"x": x, "x": x,
@ -270,6 +284,7 @@ def load_player_data(name: str) -> PlayerData | None:
"max_stamina": max_stamina, "max_stamina": max_stamina,
"flying": bool(flying_int), "flying": bool(flying_int),
"zone_name": zone_name, "zone_name": zone_name,
"inventory": inventory,
} }

21
src/mudlib/thing.py Normal file
View file

@ -0,0 +1,21 @@
"""Thing — an item that can exist in zones or inventories."""
from __future__ import annotations
from dataclasses import dataclass, field
from mudlib.object import Object
@dataclass
class Thing(Object):
"""An item in the world.
Things can be on the ground (location=zone, with x/y) or carried
by an entity (location=entity, no x/y). The portable flag controls
whether entities can pick them up.
"""
description: str = ""
portable: bool = True
aliases: list[str] = field(default_factory=list)

62
src/mudlib/things.py Normal file
View file

@ -0,0 +1,62 @@
"""Thing template loading, registry, and spawning."""
import tomllib
from dataclasses import dataclass, field
from pathlib import Path
from mudlib.object import Object
from mudlib.thing import Thing
@dataclass
class ThingTemplate:
"""Definition loaded from TOML — used to spawn Thing instances."""
name: str
description: str
portable: bool = True
aliases: list[str] = field(default_factory=list)
# Module-level registry
thing_templates: dict[str, ThingTemplate] = {}
def load_thing_template(path: Path) -> ThingTemplate:
"""Parse a thing TOML file into a ThingTemplate."""
with open(path, "rb") as f:
data = tomllib.load(f)
return ThingTemplate(
name=data["name"],
description=data["description"],
portable=data.get("portable", True),
aliases=data.get("aliases", []),
)
def load_thing_templates(directory: Path) -> dict[str, ThingTemplate]:
"""Load all .toml files in a directory into a dict keyed by name."""
templates: dict[str, ThingTemplate] = {}
for path in sorted(directory.glob("*.toml")):
template = load_thing_template(path)
templates[template.name] = template
return templates
def spawn_thing(
template: ThingTemplate,
location: Object | None,
*,
x: int | None = None,
y: int | None = None,
) -> Thing:
"""Create a Thing instance from a template at the given location."""
return Thing(
name=template.name,
description=template.description,
portable=template.portable,
aliases=list(template.aliases),
location=location,
x=x,
y=y,
)

View file

@ -368,3 +368,67 @@ async def test_dispatch_allows_matching_mode(player):
await commands.dispatch(player, "strike") await commands.dispatch(player, "strike")
assert called assert called
@pytest.mark.asyncio
async def test_look_shows_ground_items(player, test_zone):
"""look shows things on the ground at the player's position."""
from mudlib.player import players
from mudlib.thing import Thing
players.clear()
players[player.name] = player
Thing(name="rock", location=test_zone, x=5, y=5)
await look.cmd_look(player, "")
output = "".join([call[0][0] for call in player.writer.write.call_args_list])
assert "rock" in output.lower()
@pytest.mark.asyncio
async def test_look_shows_multiple_ground_items(player, test_zone):
"""look shows all things at the player's position."""
from mudlib.player import players
from mudlib.thing import Thing
players.clear()
players[player.name] = player
Thing(name="rock", location=test_zone, x=5, y=5)
Thing(name="sword", location=test_zone, x=5, y=5)
await look.cmd_look(player, "")
output = "".join([call[0][0] for call in player.writer.write.call_args_list])
assert "rock" in output.lower()
assert "sword" in output.lower()
@pytest.mark.asyncio
async def test_look_no_ground_items_no_extra_output(player, test_zone):
"""look with no ground items doesn't mention items."""
from mudlib.player import players
players.clear()
players[player.name] = player
await look.cmd_look(player, "")
output = "".join([call[0][0] for call in player.writer.write.call_args_list])
assert "on the ground" not in output.lower()
@pytest.mark.asyncio
async def test_look_ignores_items_at_other_positions(player, test_zone):
"""look doesn't show items that are at different positions."""
from mudlib.player import players
from mudlib.thing import Thing
players.clear()
players[player.name] = player
Thing(name="far_rock", location=test_zone, x=9, y=9)
await look.cmd_look(player, "")
output = "".join([call[0][0] for call in player.writer.write.call_args_list])
assert "far_rock" not in output.lower()

296
tests/test_get_drop.py Normal file
View file

@ -0,0 +1,296 @@
"""Tests for get and drop commands."""
from unittest.mock import AsyncMock, MagicMock
import pytest
from mudlib.commands import _registry
from mudlib.entity import Entity
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
# --- Object.move_to ---
def test_move_to_updates_location():
"""move_to changes the object's location pointer."""
terrain = [["." for _ in range(10)] for _ in range(10)]
zone = Zone(name="test", width=10, height=10, terrain=terrain)
entity = Entity(name="player", location=zone, x=5, y=5)
rock = Thing(name="rock", location=zone, x=5, y=5)
rock.move_to(entity)
assert rock.location is entity
def test_move_to_removes_from_old_contents():
"""move_to removes object from old location's contents."""
terrain = [["." for _ in range(10)] for _ in range(10)]
zone = Zone(name="test", width=10, height=10, terrain=terrain)
entity = Entity(name="player", location=zone, x=5, y=5)
rock = Thing(name="rock", location=zone, x=5, y=5)
rock.move_to(entity)
assert rock not in zone.contents
def test_move_to_adds_to_new_contents():
"""move_to adds object to new location's contents."""
terrain = [["." for _ in range(10)] for _ in range(10)]
zone = Zone(name="test", width=10, height=10, terrain=terrain)
entity = Entity(name="player", location=zone, x=5, y=5)
rock = Thing(name="rock", location=zone, x=5, y=5)
rock.move_to(entity)
assert rock in entity.contents
def test_move_to_from_none():
"""move_to works from location=None (template to world)."""
terrain = [["." for _ in range(10)] for _ in range(10)]
zone = Zone(name="test", width=10, height=10, terrain=terrain)
rock = Thing(name="rock")
rock.move_to(zone, x=3, y=7)
assert rock.location is zone
assert rock.x == 3
assert rock.y == 7
assert rock in zone.contents
def test_move_to_sets_coordinates():
"""move_to can set x/y coordinates."""
terrain = [["." for _ in range(10)] for _ in range(10)]
zone = Zone(name="test", width=10, height=10, terrain=terrain)
rock = Thing(name="rock", location=zone, x=1, y=1)
entity = Entity(name="player", location=zone, x=5, y=5)
rock.move_to(entity)
# When moving to inventory, coordinates should be cleared
assert rock.x is None
assert rock.y is None
def test_move_to_zone_sets_coordinates():
"""move_to a zone sets x/y from args."""
terrain = [["." for _ in range(10)] for _ in range(10)]
zone = Zone(name="test", width=10, height=10, terrain=terrain)
entity = Entity(name="player", location=zone, x=5, y=5)
rock = Thing(name="rock", location=entity)
rock.move_to(zone, x=3, y=7)
assert rock.x == 3
assert rock.y == 7
# --- cmd_get ---
@pytest.mark.asyncio
async def test_get_picks_up_thing(player, test_zone):
"""get moves a thing from zone to player inventory."""
from mudlib.commands.things import cmd_get
rock = Thing(name="rock", location=test_zone, x=5, y=5)
await cmd_get(player, "rock")
assert rock.location is player
assert rock in player.contents
assert rock not in test_zone.contents
@pytest.mark.asyncio
async def test_get_sends_confirmation(player, test_zone, mock_writer):
"""get sends feedback to the player."""
from mudlib.commands.things import cmd_get
Thing(name="rock", location=test_zone, x=5, y=5)
await cmd_get(player, "rock")
output = mock_writer.write.call_args_list[-1][0][0]
assert "rock" in output.lower()
@pytest.mark.asyncio
async def test_get_nothing_there(player, test_zone, mock_writer):
"""get with no matching item gives feedback."""
from mudlib.commands.things import cmd_get
await cmd_get(player, "sword")
output = mock_writer.write.call_args_list[-1][0][0]
assert "don't see" in output.lower() or "nothing" in output.lower()
@pytest.mark.asyncio
async def test_get_non_portable(player, test_zone, mock_writer):
"""get rejects non-portable things."""
from mudlib.commands.things import cmd_get
fountain = Thing(
name="fountain",
location=test_zone,
x=5,
y=5,
portable=False,
)
await cmd_get(player, "fountain")
# Fountain should still be in zone, not in player
assert fountain.location is test_zone
assert fountain not in player.contents
output = mock_writer.write.call_args_list[-1][0][0]
assert "can't" in output.lower()
@pytest.mark.asyncio
async def test_get_no_args(player, mock_writer):
"""get with no arguments gives usage hint."""
from mudlib.commands.things import cmd_get
await cmd_get(player, "")
output = mock_writer.write.call_args_list[-1][0][0]
assert "get what" in output.lower() or "what" in output.lower()
@pytest.mark.asyncio
async def test_get_matches_aliases(player, test_zone):
"""get matches thing aliases."""
from mudlib.commands.things import cmd_get
can = Thing(
name="pepsi can",
aliases=["can", "pepsi"],
location=test_zone,
x=5,
y=5,
)
await cmd_get(player, "pepsi")
assert can.location is player
# --- cmd_drop ---
@pytest.mark.asyncio
async def test_drop_puts_thing_on_ground(player, test_zone):
"""drop moves a thing from inventory to zone at player's position."""
from mudlib.commands.things import cmd_drop
rock = Thing(name="rock", location=player)
await cmd_drop(player, "rock")
assert rock.location is test_zone
assert rock.x == player.x
assert rock.y == player.y
assert rock in test_zone.contents
assert rock not in player.contents
@pytest.mark.asyncio
async def test_drop_sends_confirmation(player, test_zone, mock_writer):
"""drop sends feedback to the player."""
from mudlib.commands.things import cmd_drop
Thing(name="rock", location=player)
await cmd_drop(player, "rock")
output = mock_writer.write.call_args_list[-1][0][0]
assert "rock" in output.lower()
@pytest.mark.asyncio
async def test_drop_not_carrying(player, mock_writer):
"""drop with item not in inventory gives feedback."""
from mudlib.commands.things import cmd_drop
await cmd_drop(player, "sword")
output = mock_writer.write.call_args_list[-1][0][0]
assert "not carrying" in output.lower() or "don't have" in output.lower()
@pytest.mark.asyncio
async def test_drop_no_args(player, mock_writer):
"""drop with no arguments gives usage hint."""
from mudlib.commands.things import cmd_drop
await cmd_drop(player, "")
output = mock_writer.write.call_args_list[-1][0][0]
assert "drop what" in output.lower() or "what" in output.lower()
@pytest.mark.asyncio
async def test_drop_requires_zone(player, mock_writer):
"""drop without a zone location gives error."""
from mudlib.commands.things import cmd_drop
player.location = None
Thing(name="rock", location=player)
await cmd_drop(player, "rock")
output = mock_writer.write.call_args_list[-1][0][0]
assert "nowhere" in output.lower() or "can't" in output.lower()
@pytest.mark.asyncio
async def test_drop_matches_aliases(player, test_zone):
"""drop matches thing aliases."""
from mudlib.commands.things import cmd_drop
can = Thing(
name="pepsi can",
aliases=["can", "pepsi"],
location=player,
)
await cmd_drop(player, "can")
assert can.location is test_zone
# --- command registration ---
def test_get_command_registered():
"""get command is registered."""
import mudlib.commands.things # noqa: F401
assert "get" in _registry
def test_drop_command_registered():
"""drop command is registered."""
import mudlib.commands.things # noqa: F401
assert "drop" in _registry

105
tests/test_inventory.py Normal file
View file

@ -0,0 +1,105 @@
"""Tests for inventory command."""
from unittest.mock import AsyncMock, MagicMock
import pytest
from mudlib import commands
from mudlib.commands import things # noqa: F401
from mudlib.player import Player
from mudlib.thing import Thing
from mudlib.zone import Zone
@pytest.fixture
def test_zone():
"""Create a test zone."""
return Zone(
name="testzone",
width=10,
height=10,
toroidal=True,
terrain=[["." for _ in range(10)] for _ in range(10)],
)
@pytest.fixture
def mock_writer():
"""Create a mock writer."""
return MagicMock(write=MagicMock(), drain=AsyncMock())
@pytest.fixture
def mock_reader():
"""Create a mock reader."""
return MagicMock()
@pytest.fixture
def player(mock_reader, mock_writer, test_zone):
"""Create a test player."""
return Player(
name="TestPlayer",
x=5,
y=5,
reader=mock_reader,
writer=mock_writer,
location=test_zone,
)
@pytest.mark.asyncio
async def test_inventory_empty(player, mock_writer):
"""Test inventory with no items."""
from mudlib.commands.things import cmd_inventory
await cmd_inventory(player, "")
output = mock_writer.write.call_args_list[-1][0][0]
assert "You aren't carrying anything." in output
@pytest.mark.asyncio
async def test_inventory_one_item(player, mock_writer):
"""Test inventory with one item."""
from mudlib.commands.things import cmd_inventory
Thing(name="rock", location=player)
await cmd_inventory(player, "")
output = mock_writer.write.call_args_list[-1][0][0]
assert "You are carrying:" in output
assert "rock" in output
@pytest.mark.asyncio
async def test_inventory_multiple_items(player, mock_writer):
"""Test inventory with multiple items."""
from mudlib.commands.things import cmd_inventory
Thing(name="rock", location=player)
Thing(name="sword", location=player)
Thing(name="shield", location=player)
await cmd_inventory(player, "")
output = mock_writer.write.call_args_list[-1][0][0]
assert "You are carrying:" in output
assert "rock" in output
assert "sword" in output
assert "shield" in output
def test_inventory_command_registered():
"""Test that inventory command is registered."""
assert "inventory" in commands._registry
cmd = commands._registry["inventory"]
assert cmd.name == "inventory"
def test_inventory_alias_registered():
"""Test that 'i' alias is registered."""
assert "i" in commands._registry
cmd = commands._registry["i"]
assert cmd.name == "inventory"

View file

@ -0,0 +1,170 @@
"""Tests for inventory persistence."""
import json
import pytest
from mudlib.player import Player
from mudlib.store import (
create_account,
init_db,
load_player_data,
save_player,
)
from mudlib.thing import Thing
from mudlib.things import ThingTemplate, thing_templates
from mudlib.zone import Zone
@pytest.fixture
def db(tmp_path):
"""Initialize a temporary database."""
db_path = tmp_path / "test.db"
init_db(db_path)
return db_path
@pytest.fixture
def test_zone():
terrain = [["." for _ in range(10)] for _ in range(10)]
return Zone(
name="testzone",
width=10,
height=10,
terrain=terrain,
)
@pytest.fixture(autouse=True)
def register_templates():
"""Register test templates."""
thing_templates.clear()
thing_templates["rock"] = ThingTemplate(
name="rock",
description="a rock",
aliases=["stone"],
)
thing_templates["sword"] = ThingTemplate(
name="sword",
description="a sword",
)
yield
thing_templates.clear()
# --- save ---
def test_save_player_with_inventory(db, test_zone):
"""save_player persists inventory as JSON list of template names."""
import sqlite3
create_account("Alice", "pass123")
player = Player(
name="Alice",
x=5,
y=5,
location=test_zone,
)
Thing(name="rock", location=player)
Thing(name="sword", location=player)
save_player(player)
# Check raw DB value
conn = sqlite3.connect(str(db))
cursor = conn.cursor()
cursor.execute("SELECT inventory FROM accounts WHERE name = ?", ("Alice",))
row = cursor.fetchone()
conn.close()
assert row is not None
inventory = json.loads(row[0])
assert sorted(inventory) == ["rock", "sword"]
def test_save_player_empty_inventory(db, test_zone):
"""save_player stores empty list when no inventory."""
import sqlite3
create_account("Bob", "pass123")
player = Player(
name="Bob",
x=5,
y=5,
location=test_zone,
)
save_player(player)
conn = sqlite3.connect(str(db))
cursor = conn.cursor()
cursor.execute("SELECT inventory FROM accounts WHERE name = ?", ("Bob",))
row = cursor.fetchone()
conn.close()
assert row is not None
assert json.loads(row[0]) == []
# --- load ---
def test_load_player_data_includes_inventory(db):
"""load_player_data returns inventory list."""
import sqlite3
create_account("Alice", "pass123")
# Manually set inventory in DB
conn = sqlite3.connect(str(db))
cursor = conn.cursor()
cursor.execute(
"UPDATE accounts SET inventory = ? WHERE name = ?",
(json.dumps(["rock", "sword"]), "Alice"),
)
conn.commit()
conn.close()
data = load_player_data("Alice")
assert data is not None
assert sorted(data["inventory"]) == ["rock", "sword"]
def test_load_player_data_empty_inventory(db):
"""load_player_data returns empty list for no inventory."""
create_account("Bob", "pass123")
data = load_player_data("Bob")
assert data is not None
assert data["inventory"] == []
def test_save_load_duplicate_items(db, test_zone):
"""Duplicate items (two rocks) round-trip correctly."""
create_account("Dupes", "pass123")
player = Player(
name="Dupes",
x=5,
y=5,
location=test_zone,
)
Thing(name="rock", location=player)
Thing(name="rock", location=player)
save_player(player)
data = load_player_data("Dupes")
assert data is not None
assert data["inventory"] == ["rock", "rock"]
def test_load_player_data_migration_no_column(db):
"""Old DB without inventory column returns empty list."""
create_account("Charlie", "pass123")
# init_db already ran the migration adding the inventory column,
# so this just verifies a fresh account gets empty inventory
data = load_player_data("Charlie")
assert data is not None
assert data["inventory"] == []

116
tests/test_thing.py Normal file
View file

@ -0,0 +1,116 @@
"""Tests for the Thing class."""
from mudlib.entity import Entity
from mudlib.object import Object
from mudlib.thing import Thing
from mudlib.zone import Zone
# --- construction ---
def test_thing_creation_minimal():
"""Thing can be created with just a name."""
t = Thing(name="rock")
assert t.name == "rock"
assert t.location is None
assert t.description == ""
assert t.portable is True # default: things are portable
def test_thing_creation_with_description():
"""Thing can have a description."""
t = Thing(name="sword", description="a rusty iron sword")
assert t.description == "a rusty iron sword"
def test_thing_creation_non_portable():
"""Thing can be marked as non-portable (fixture, scenery)."""
t = Thing(name="fountain", portable=False)
assert t.portable is False
def test_thing_in_zone():
"""Thing 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)
rock = Thing(name="rock", location=zone, x=3, y=7)
assert rock.location is zone
assert rock.x == 3
assert rock.y == 7
assert rock in zone.contents
def test_thing_in_entity_inventory():
"""Thing can be placed in an entity (inventory)."""
terrain = [["." for _ in range(10)] for _ in range(10)]
zone = Zone(name="test", width=10, height=10, terrain=terrain)
entity = Entity(name="player", location=zone, x=5, y=5)
sword = Thing(name="sword", location=entity)
assert sword.location is entity
assert sword in entity.contents
# Inventory items don't need coordinates
assert sword.x is None
assert sword.y is None
def test_thing_is_subclass_of_object():
"""Thing inherits from Object."""
t = Thing(name="gem")
assert isinstance(t, Object)
def test_thing_aliases_default_empty():
"""Thing aliases default to empty list."""
t = Thing(name="rock")
assert t.aliases == []
def test_thing_aliases():
"""Thing can have aliases for matching."""
t = Thing(name="pepsi can", aliases=["can", "pepsi"])
assert t.aliases == ["can", "pepsi"]
# --- entity.can_accept ---
def test_entity_can_accept_portable_thing():
"""Entity accepts portable things (inventory)."""
entity = Entity(name="player")
sword = Thing(name="sword", portable=True)
assert entity.can_accept(sword) is True
def test_entity_rejects_non_portable_thing():
"""Entity rejects non-portable things."""
entity = Entity(name="player")
fountain = Thing(name="fountain", portable=False)
assert entity.can_accept(fountain) is False
def test_entity_rejects_non_thing():
"""Entity rejects objects that aren't Things."""
entity = Entity(name="player")
other = Object(name="abstract")
assert entity.can_accept(other) is False
# --- zone interaction ---
def test_zone_contents_at_finds_things():
"""Zone.contents_at finds things at a position."""
terrain = [["." for _ in range(10)] for _ in range(10)]
zone = Zone(name="test", width=10, height=10, terrain=terrain)
rock = Thing(name="rock", location=zone, x=5, y=5)
result = zone.contents_at(5, 5)
assert rock in result
def test_zone_contents_near_finds_things():
"""Zone.contents_near finds things within range."""
terrain = [["." for _ in range(10)] for _ in range(10)]
zone = Zone(name="test", width=10, height=10, terrain=terrain)
rock = Thing(name="rock", location=zone, x=5, y=5)
result = zone.contents_near(5, 5, 3)
assert rock in result

View file

@ -0,0 +1,152 @@
"""Tests for thing template loading and spawning."""
import textwrap
import pytest
from mudlib.thing import Thing
from mudlib.things import (
ThingTemplate,
load_thing_template,
load_thing_templates,
spawn_thing,
)
from mudlib.zone import Zone
@pytest.fixture
def test_zone():
terrain = [["." for _ in range(10)] for _ in range(10)]
return Zone(
name="testzone",
width=10,
height=10,
terrain=terrain,
)
# --- ThingTemplate ---
def test_thing_template_creation():
"""ThingTemplate holds definition data."""
t = ThingTemplate(
name="rock",
description="a smooth grey rock",
portable=True,
aliases=["stone"],
)
assert t.name == "rock"
assert t.description == "a smooth grey rock"
assert t.portable is True
assert t.aliases == ["stone"]
def test_thing_template_defaults():
"""ThingTemplate has sensible defaults."""
t = ThingTemplate(name="rock", description="a rock")
assert t.portable is True
assert t.aliases == []
# --- load_thing_template ---
def test_load_thing_template(tmp_path):
"""Load a thing template from a TOML file."""
toml_content = textwrap.dedent("""\
name = "rusty sword"
description = "a sword covered in rust"
portable = true
aliases = ["sword", "rusty"]
""")
p = tmp_path / "sword.toml"
p.write_text(toml_content)
template = load_thing_template(p)
assert template.name == "rusty sword"
assert template.description == "a sword covered in rust"
assert template.portable is True
assert template.aliases == ["sword", "rusty"]
def test_load_thing_template_minimal(tmp_path):
"""Load thing template with only required fields."""
toml_content = textwrap.dedent("""\
name = "rock"
description = "a rock"
""")
p = tmp_path / "rock.toml"
p.write_text(toml_content)
template = load_thing_template(p)
assert template.name == "rock"
assert template.portable is True # default
assert template.aliases == [] # default
def test_load_thing_template_non_portable(tmp_path):
"""Load a non-portable thing template."""
toml_content = textwrap.dedent("""\
name = "fountain"
description = "a stone fountain"
portable = false
""")
p = tmp_path / "fountain.toml"
p.write_text(toml_content)
template = load_thing_template(p)
assert template.portable is False
# --- load_thing_templates ---
def test_load_thing_templates(tmp_path):
"""Load all thing templates from a directory."""
for name in ["rock", "sword"]:
p = tmp_path / f"{name}.toml"
p.write_text(f'name = "{name}"\ndescription = "a {name}"\n')
templates = load_thing_templates(tmp_path)
assert "rock" in templates
assert "sword" in templates
assert len(templates) == 2
def test_load_thing_templates_empty_dir(tmp_path):
"""Empty directory returns empty dict."""
templates = load_thing_templates(tmp_path)
assert templates == {}
# --- spawn_thing ---
def test_spawn_thing_in_zone(test_zone):
"""spawn_thing creates a Thing instance in a zone."""
template = ThingTemplate(
name="rock",
description="a smooth grey rock",
portable=True,
aliases=["stone"],
)
thing = spawn_thing(template, test_zone, x=3, y=7)
assert isinstance(thing, Thing)
assert thing.name == "rock"
assert thing.description == "a smooth grey rock"
assert thing.portable is True
assert thing.aliases == ["stone"]
assert thing.location is test_zone
assert thing.x == 3
assert thing.y == 7
assert thing in test_zone.contents
def test_spawn_thing_without_coordinates(test_zone):
"""spawn_thing can create a thing without position (template/inventory)."""
template = ThingTemplate(name="gem", description="a sparkling gem")
thing = spawn_thing(template, None)
assert thing.location is None
assert thing.x is None
assert thing.y is None