diff --git a/CHANGELOG.md b/CHANGELOG.md index 8bf926bd25..f35d1fd88b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## Main branch + +- Contrib: Container typeclass with new commands for storing and retrieving + things inside them (InspectorCaracal) +- Fix: The `AttributeHandler.all()` now actually accepts `category=` as + keyword arg, like our docs already claimed it should (Volund) + + ## Evennia 1.3.0 Apr 29, 2023 diff --git a/evennia/contrib/game_systems/containers/README.md b/evennia/contrib/game_systems/containers/README.md new file mode 100644 index 0000000000..e9e3d91c67 --- /dev/null +++ b/evennia/contrib/game_systems/containers/README.md @@ -0,0 +1,55 @@ +# Containers +Contribution by InspectorCaracal (2023) + +Adds the ability to put objects into other container objects by providing a container typeclass and extending certain base commands. + +## Installation + +To install, import and add the `ContainerCmdSet` to `CharacterCmdSet` in your `default_cmdsets.py` file: + +```python +from evennia.contrib.game_systems.containers import ContainerCmdSet + +class CharacterCmdSet(default_cmds.CharacterCmdSet): + # ... + + def at_cmdset_creation(self): + # ... + self.add(ContainerCmdSet) +``` + +This will replace the default `look` and `get` commands with the container-friendly versions provided by the contrib as well as add a new `put` command. + +## Usage + +The contrib includes a `ContribContainer` typeclass which has all of the set-up necessary to be used as a container. To use, all you need to do is create an object in-game with that typeclass - it will automatically inherit anything you implemented in your base Object typeclass as well. + + create bag:game_systems.containers.ContribContainer + +The contrib's `ContribContainer` comes with a capacity limit of a maximum number of items it can hold. This can be changed per individual object. + +In code: +```py +obj.capacity = 5 +``` +In game: + + set box/capacity = 5 + +You can also make any other objects usable as containers by setting the `get_from` lock type on it. + + lock mysterious box = get_from:true() + +## Extending + +The `ContribContainer` class is intended to be usable as-is, but you can also inherit from it for your own container classes to extend its functionality. Aside from having the container lock pre-set on object creation, it comes with three main additions: + +### `capacity` property + +`ContribContainer.capacity` is an `AttributeProperty` - meaning you can access it in code with `obj.capacity` and also set it in game with `set obj/capacity = 5` - which represents the capacity of the container as an integer. You can override this with a more complex representation of capacity on your own container classes. + +### `at_pre_get_from` and `at_pre_put_in` methods + +These two methods on `ContribContainer` are called as extra checks when attempting to either get an object from, or put an object in, a container. The contrib's `ContribContainer.at_pre_get_from` doesn't do any additional validation by default, while `ContribContainer.at_pre_put_in` does a simple capacity check. + +You can override these methods on your own child class to do any additional capacity or access checks. \ No newline at end of file diff --git a/evennia/contrib/game_systems/containers/__init__.py b/evennia/contrib/game_systems/containers/__init__.py new file mode 100644 index 0000000000..ccfcf28afa --- /dev/null +++ b/evennia/contrib/game_systems/containers/__init__.py @@ -0,0 +1 @@ +from .containers import ContainerCmdSet, ContribContainer # noqa diff --git a/evennia/contrib/game_systems/containers/containers.py b/evennia/contrib/game_systems/containers/containers.py new file mode 100644 index 0000000000..27024a62ad --- /dev/null +++ b/evennia/contrib/game_systems/containers/containers.py @@ -0,0 +1,307 @@ +""" +Containers + +Contribution by InspectorCaracal (2023) + +Adds the ability to put objects into other container objects by providing a container typeclass and extending certain base commands. + +To install, import and add the `ContainerCmdSet` to `CharacterCmdSet` in your `default_cmdsets.py` file: + + from evennia.contrib.game_systems.containers import ContainerCmdSet + + class CharacterCmdSet(default_cmds.CharacterCmdSet): + # ... + + def at_cmdset_creation(self): + # ... + self.add(ContainerCmdSet) + +The ContainerCmdSet includes: + + - a modified `look` command to look at or inside objects + - a modified `get` command to get objects from your location or inside objects + - a new `put` command to put objects from your inventory into other objects + +Create objects with the `ContribContainer` typeclass to easily create containers, +or implement the same locks/hooks in your own typeclasses. + +`ContribContainer` implements the following new methods: + + at_pre_get_from(getter, target, **kwargs) - called with the pre-get hooks + at_pre_put_in(putter, target, **kwargs) - called with the pre-put hooks +""" +from django.conf import settings + +from evennia import AttributeProperty, CmdSet, DefaultObject +from evennia.commands.default.general import CmdLook, CmdGet, CmdDrop +from evennia.utils import class_from_module + +# establish the right inheritance for container objects +_BASE_OBJECT_TYPECLASS = class_from_module(settings.BASE_OBJECT_TYPECLASS, DefaultObject) + + +class ContribContainer(_BASE_OBJECT_TYPECLASS): + """ + A type of Object which can be used as a container. + + It implements a very basic "size" limitation that is just a flat number of objects. + """ + + # This defines how many objects the container can hold. + capacity = AttributeProperty(default=20) + + def at_object_creation(self): + """ + Extends the base object `at_object_creation` method by setting the "get_from" lock to "true", + allowing other objects to be put inside and removed from this object. + + By default, a lock type not being explicitly set will fail access checks, so objects without + the new "get_from" access lock will fail the access checks and continue behaving as + non-container objects. + """ + super().at_object_creation() + self.locks.add("get_from:true()") + + def at_pre_get_from(self, getter, target, **kwargs): + """ + This will be called when something attempts to get another object FROM this object, + rather than when getting this object itself. + + Args: + getter (Object): The actor attempting to take something from this object. + target (Object): The thing this object contains that is being removed. + + Returns: + boolean: Whether the object `target` should be gotten or not. + + Notes: + If this method returns False/None, the getting is cancelled before it is even started. + """ + return True + + def at_pre_put_in(self, putter, target, **kwargs): + """ + This will be called when something attempts to put another object into this object. + + Args: + putter (Object): The actor attempting to put something in this object. + target (Object): The thing being put into this object. + + Returns: + boolean: Whether the object `target` should be put down or not. + + Notes: + If this method returns False/None, the putting is cancelled before it is even started. + To add more complex capacity checks, modify this method on your child typeclass. + """ + # check if we're already at capacity + if len(self.contents) >= self.capacity: + singular, _ = self.get_numbered_name(1, putter) + putter.msg(f"You can't fit anything else in {singular}.") + return False + + return True + + +class CmdContainerLook(CmdLook): + """ + look at location or object + + Usage: + look + look + look in + look * + + Observes your location or objects in your vicinity. + """ + + rhs_split = (" in ",) + + def func(self): + """ + Handle the looking. + """ + caller = self.caller + # by default, we don't look in anything + container = None + + if not self.args: + target = caller.location + if not target: + self.msg("You have no location to look at!") + return + elif self.rhs: + # we are looking in something, find that first + container = caller.search(self.rhs) + if not container: + return + + target = caller.search(self.lhs, location=container) + if not target: + return + + desc = caller.at_look(target) + # add the type=look to the outputfunc to make it + # easy to separate this output in client. + self.msg(text=(desc, {"type": "look"}), options=None) + + +class CmdContainerGet(CmdGet): + """ + pick up something + + Usage: + get + get from + + Picks up an object from your location or a container and puts it in + your inventory. + """ + + rhs_split = (" from ",) + + def func(self): + caller = self.caller + # by default, we get from the caller's location + location = caller.location + + if not self.args: + self.msg("Get what?") + return + + # check for a container as the location to get from + if self.rhs: + location = caller.search(self.rhs) + if not location: + return + # check access lock + if not location.access(caller, "get_from"): + # supports custom error messages on individual containers + if location.db.get_from_err_msg: + self.msg(location.db.get_from_err_msg) + else: + self.msg("You can't get things from that.") + return + + obj = caller.search(self.lhs, location=location) + if not obj: + return + if caller == obj: + self.msg("You can't get yourself.") + return + + # check if this object can be gotten + if not obj.access(caller, "get") or not obj.at_pre_get(caller): + if obj.db.get_err_msg: + self.msg(obj.db.get_err_msg) + else: + self.msg("You can't get that.") + return + + # calling possible at_pre_get_from hook on location + if hasattr(location, "at_pre_get_from") and not location.at_pre_get_from(caller, obj): + self.msg("You can't get that.") + return + + success = obj.move_to(caller, quiet=True, move_type="get") + if not success: + self.msg("This can't be picked up.") + else: + singular, _ = obj.get_numbered_name(1, caller) + if location == caller.location: + # we're picking it up from the area + caller.location.msg_contents(f"$You() $conj(pick) up {singular}.", from_obj=caller) + else: + # we're getting it from somewhere else + container_name, _ = location.get_numbered_name(1, caller) + caller.location.msg_contents( + f"$You() $conj(get) {singular} from {container_name}.", from_obj=caller + ) + # calling at_get hook method + obj.at_get(caller) + + +class CmdPut(CmdDrop): + """ + put an object into something else + + Usage: + put in + + Lets you put an object from your inventory into another + object in the vicinity. + """ + + key = "put" + rhs_split = ("=", " in ", " on ") + + def func(self): + caller = self.caller + if not self.args: + self.msg("Put what in where?") + return + + if not self.rhs: + super().func() + return + + obj = caller.search( + self.lhs, + location=caller, + nofound_string=f"You aren't carrying {self.args}.", + multimatch_string=f"You carry more than one {self.args}:", + ) + if not obj: + return + + container = caller.search(self.rhs) + if not container: + return + + # check access lock + if not container.access(caller, "get_from"): + # supports custom error messages on individual containers + if container.db.put_err_msg: + self.msg(container.db.put_err_msg) + else: + self.msg("You can't put things in that.") + return + + # Call the object script's at_pre_drop() method. + if not obj.at_pre_drop(caller): + self.msg("You can't put that down.") + return + + # Call the container's possible at_pre_put_in method. + if hasattr(container, "at_pre_put_in") and not container.at_pre_put_in(caller, obj): + self.msg("You can't put that there.") + return + + success = obj.move_to(container, quiet=True, move_type="drop") + if not success: + self.msg("This couldn't be dropped.") + else: + obj_name, _ = obj.get_numbered_name(1, caller) + container_name, _ = container.get_numbered_name(1, caller) + caller.location.msg_contents( + f"$You() $conj(put) {obj_name} in {container_name}.", from_obj=caller + ) + # Call the object script's at_drop() method. + obj.at_drop(caller) + + +class ContainerCmdSet(CmdSet): + """ + Extends the basic `look` and `get` commands to support containers, + and adds an additional `put` command. + """ + + key = "Container CmdSet" + + def at_cmdset_creation(self): + super().at_cmdset_creation() + + self.add(CmdContainerLook) + self.add(CmdContainerGet) + self.add(CmdPut) diff --git a/evennia/contrib/game_systems/containers/tests.py b/evennia/contrib/game_systems/containers/tests.py new file mode 100644 index 0000000000..aab8f631b2 --- /dev/null +++ b/evennia/contrib/game_systems/containers/tests.py @@ -0,0 +1,62 @@ +from evennia import create_object +from evennia.utils.test_resources import BaseEvenniaTest, BaseEvenniaCommandTest # noqa +from .containers import ContribContainer, CmdContainerGet, CmdContainerLook, CmdPut + + +class TestContainer(BaseEvenniaTest): + def setUp(self): + super().setUp() + # create a container to test with + self.container = create_object(key="Box", typeclass=ContribContainer, location=self.room1) + + def test_capacity(self): + # limit capacity to 1 + self.container.capacity = 1 + self.assertTrue(self.container.at_pre_put_in(self.char1, self.obj1)) + # put Obj2 in container to hit max capacity + self.obj2.location = self.container + self.assertFalse(self.container.at_pre_put_in(self.char1, self.obj1)) + + +class TestContainerCmds(BaseEvenniaCommandTest): + def setUp(self): + super().setUp() + # create a container to test with + self.container = create_object(key="Box", typeclass=ContribContainer, location=self.room1) + + def test_look_in(self): + # make sure the object is in the container so we can look at it + self.obj1.location = self.container + self.call(CmdContainerLook(), "obj in box", "Obj") + # move it into a non-container object and look at it there too + self.obj1.location = self.obj2 + self.call(CmdContainerLook(), "obj in obj2", "Obj") + + def test_get_and_put(self): + # get normally + self.call(CmdContainerGet(), "Obj", "You pick up an Obj.") + # put in the container + self.call(CmdPut(), "obj in box", "You put an Obj in a Box.") + # get from the container + self.call(CmdContainerGet(), "obj from box", "You get an Obj from a Box.") + + def test_locked_get_put(self): + # lock container + self.container.locks.add("get_from:false()") + # move object to container to try getting + self.obj1.location = self.container + self.call(CmdContainerGet(), "obj from box", "You can't get things from that.") + # move object to character to try putting + self.obj1.location = self.char1 + self.call(CmdPut(), "obj in box", "You can't put things in that.") + + def test_at_capacity_put(self): + # set container capacity + self.container.capacity = 1 + # move object to container to fill capacity + self.obj2.location = self.container + # move object to character to try putting + self.obj1.location = self.char1 + self.call(CmdPut(), "obj in box", "You can't fit anything else in a Box.") + + \ No newline at end of file diff --git a/evennia/typeclasses/attributes.py b/evennia/typeclasses/attributes.py index ca49680f20..d65b73b661 100644 --- a/evennia/typeclasses/attributes.py +++ b/evennia/typeclasses/attributes.py @@ -1392,11 +1392,12 @@ class AttributeHandler: """ self.backend.clear_attributes(category, accessing_obj, default_access) - def all(self, accessing_obj=None, default_access=True): + def all(self, category=None, accessing_obj=None, default_access=True): """ Return all Attribute objects on this object, regardless of category. Args: + category (str, optional): A given category to limit results to. accessing_obj (object, optional): Check the `attrread` lock on each attribute before returning them. If not given, this check is skipped. @@ -1410,6 +1411,8 @@ class AttributeHandler: """ attrs = self.backend.get_all_attributes() + if category: + attrs = [attr for attr in attrs if attr.category == category] if accessing_obj: return [