diff --git a/src/mudlib/entity.py b/src/mudlib/entity.py index ac26660..2238159 100644 --- a/src/mudlib/entity.py +++ b/src/mudlib/entity.py @@ -1,9 +1,15 @@ """Base entity class for characters in the world.""" +from __future__ import annotations + from dataclasses import dataclass, field +from typing import TYPE_CHECKING from mudlib.object import Object +if TYPE_CHECKING: + from mudlib.thing import Thing + @dataclass class Entity(Object): @@ -23,6 +29,12 @@ class Entity(Object): defense_locked_until: float = 0.0 # monotonic time when defense recovery ends 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: """Send a message to this entity. Base implementation is a no-op.""" pass diff --git a/src/mudlib/thing.py b/src/mudlib/thing.py new file mode 100644 index 0000000..1411182 --- /dev/null +++ b/src/mudlib/thing.py @@ -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) diff --git a/tests/test_thing.py b/tests/test_thing.py new file mode 100644 index 0000000..fe81735 --- /dev/null +++ b/tests/test_thing.py @@ -0,0 +1,117 @@ +"""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