diff --git a/src/mudlib/object.py b/src/mudlib/object.py new file mode 100644 index 0000000..ed000c2 --- /dev/null +++ b/src/mudlib/object.py @@ -0,0 +1,37 @@ +"""Base class for everything in the world.""" + +from __future__ import annotations + +from dataclasses import dataclass, field + + +@dataclass +class Object: + """Base class for everything in the world. + + Every object has a location (another Object, or None for top-level things + like zones). The location pointer is the entire containment system — + contents is just the reverse lookup. + """ + + name: str + location: Object | None = None + x: int | None = None + y: int | None = None + + _contents: list[Object] = field( + default_factory=list, init=False, repr=False, compare=False + ) + + def __post_init__(self) -> None: + if self.location is not None: + self.location._contents.append(self) + + @property + def contents(self) -> list[Object]: + """Everything whose location is this object.""" + return list(self._contents) + + def can_accept(self, obj: Object) -> bool: + """Whether this object accepts obj as contents. Default: no.""" + return False diff --git a/tests/test_object.py b/tests/test_object.py new file mode 100644 index 0000000..1a19e01 --- /dev/null +++ b/tests/test_object.py @@ -0,0 +1,73 @@ +"""Tests for the Object base class.""" + +from mudlib.object import Object + + +def test_object_creation_minimal(): + """Object can be created with just a name.""" + obj = Object(name="rock") + assert obj.name == "rock" + assert obj.location is None + assert obj.x is None + assert obj.y is None + + +def test_object_creation_with_position(): + """Object can have spatial coordinates.""" + obj = Object(name="tree", x=5, y=10) + assert obj.x == 5 + assert obj.y == 10 + + +def test_object_creation_with_location(): + """Object can be placed inside another object.""" + zone = Object(name="overworld") + tree = Object(name="oak", location=zone, x=3, y=7) + assert tree.location is zone + + +def test_contents_empty_by_default(): + """An object with nothing inside has empty contents.""" + obj = Object(name="empty") + assert obj.contents == [] + + +def test_contents_tracks_children(): + """Contents lists objects whose location is self.""" + zone = Object(name="overworld") + tree = Object(name="oak", location=zone, x=3, y=7) + rock = Object(name="rock", location=zone, x=1, y=1) + assert tree in zone.contents + assert rock in zone.contents + assert len(zone.contents) == 2 + + +def test_contents_nested(): + """Containment can nest: zone > player > backpack > item.""" + zone = Object(name="overworld") + player = Object(name="jared", location=zone, x=5, y=5) + backpack = Object(name="backpack", location=player) + gem = Object(name="gem", location=backpack) + + assert player in zone.contents + assert backpack in player.contents + assert gem in backpack.contents + # gem is NOT directly in zone or player + assert gem not in zone.contents + assert gem not in player.contents + + +def test_can_accept_default_false(): + """Object.can_accept() returns False by default.""" + obj = Object(name="rock") + other = Object(name="sword") + assert obj.can_accept(other) is False + + +def test_contents_returns_copy(): + """Contents returns a copy, not the internal list.""" + zone = Object(name="zone") + Object(name="thing", location=zone) + contents = zone.contents + contents.append(Object(name="intruder")) + assert len(zone.contents) == 1 # internal list unchanged