diff --git a/evennia/contrib/base_systems/components/README.md b/evennia/contrib/base_systems/components/README.md new file mode 100644 index 0000000000..75489e3008 --- /dev/null +++ b/evennia/contrib/base_systems/components/README.md @@ -0,0 +1,170 @@ +# Components + +_Contrib by ChrisLR 2021_ + +# The Components Contrib + +This contrib introduces Components and Composition to Evennia. +Each 'Component' class represents a feature that will be 'enabled' on a typeclass instance. +You can register these components on an entire typeclass or a single object at runtime. +It supports both persisted attributes and in-memory attributes by using Evennia's AttributeHandler. + +# Pros +- You can reuse a feature across multiple typeclasses without inheritance +- You can cleanly organize each feature into a self-contained class. +- You can check if your object supports a feature without checking its instance. + +# Cons +- It introduces additional complexity. +- A host typeclass instance is required. + +# How to install + +To enable component support for a typeclass, +import and inherit the ComponentHolderMixin, similar to this +```python +from evennia.contrib.base_systems.components import ComponentHolderMixin +class Character(ComponentHolderMixin, DefaultCharacter): +# ... +``` + +Components need to inherit the Component class directly and require a name. +```python +from evennia.contrib.components import Component + +class Health(Component): + name = "health" +``` + +Components may define DBFields or NDBFields at the class level. +DBField will store its values in the host's DB with a prefixed key. +NDBField will store its values in the host's NDB and will not persist. +The key used will be 'component_name__field_name'. +They use AttributeProperty under the hood. + +Example: +```python +from evennia.contrib.base_systems.components import Component, DBField + +class Health(Component): + health = DBField(default=1) +``` + +Note that default is optional and will default to None + + +Each typeclass using the ComponentHolderMixin can declare its components +in the class via the ComponentProperty. +These are components that will always be present in a typeclass. +You can also pass kwargs to override the default values +Example +```python +from evennia.contrib.base_systems.components import ComponentHolderMixin +class Character(ComponentHolderMixin, DefaultCharacter): + health = ComponentProperty("health", hp=10, max_hp=50) +``` + +You can then use character.components.health to access it. +The shorter form character.cmp.health also exists. +character.health would also be accessible but only for typeclasses that have +this component defined on the class. + +Alternatively you can add those components at runtime. +You will have to access those via the component handler. +Example +```python +character = self +vampirism = components.Vampirism.create(character) +character.components.add(vampirism) + +... + +vampirism_from_elsewhere = character.components.get("vampirism") +``` + +Keep in mind that all components must be imported to be visible in the listing. +As such, I recommend regrouping them in a package. +You can then import all your components in that package's __init__ + +Because of how Evennia import typeclasses and the behavior of python imports +I recommend placing the components package inside the typeclass package. +In other words, create a folder named components inside your typeclass folder. +Then, inside the 'typeclasses/__init__.py' file add the import to the folder, like +```python +from typeclasses import components +``` +This ensures that the components package will be imported when the typeclasses are imported. +You will also need to import each components inside the package's own 'typeclasses/components/__init__.py' file. +You only need to import each module/file from there but importing the right class is a good practice. +```python +from typeclasses.components.health import Health +``` +```python +from typeclasses.components import health +``` +Both of the above examples will work. + +# Full Example +```python +from evennia.contrib.base_systems import components + + +# This is the Component class +class Health(components.Component): + name = "health" + + # Stores the current and max values as Attributes on the host, defaulting to 100 + current = components.DBField(default=100) + max = components.DBField(default=100) + + def damage(self, value): + if self.current <= 0: + return + + self.current -= value + if self.current > 0: + return + + self.current = 0 + self.on_death() + + def heal(self, value): + hp = self.current + hp += value + if hp >= self.max_hp: + hp = self.max_hp + + self.current = hp + + @property + def is_dead(self): + return self.current <= 0 + + def on_death(self): + # Behavior is defined on the typeclass + self.host.on_death() + + +# This is how the Character inherits the mixin and registers the component 'health' +class Character(ComponentHolderMixin, DefaultCharacter): + health = ComponentProperty("health") + + +# This is an example of a command that checks for the component +class Attack(Command): + key = "attack" + aliases = ('melee', 'hit') + + def at_pre_cmd(self): + caller = self.caller + targets = self.caller.search(args, quiet=True) + valid_target = None + for target in targets: + # Attempt to retrieve the component, None is obtained if it does not exist. + if target.components.health: + valid_target = target + + if not valid_target: + caller.msg("You can't attack that!") + return True +``` diff --git a/evennia/contrib/base_systems/components/__init__.py b/evennia/contrib/base_systems/components/__init__.py new file mode 100644 index 0000000000..705e9ee411 --- /dev/null +++ b/evennia/contrib/base_systems/components/__init__.py @@ -0,0 +1,24 @@ +""" +Components - ChrisLR 2022 + +This is a basic Component System. +It allows you to use components on typeclasses using a simple syntax. +This helps writing isolated code and reusing it over multiple objects. + +See the docs for more information. +""" + +from evennia.contrib.base_systems.components.component import Component +from evennia.contrib.base_systems.components.dbfield import DBField, NDBField +from evennia.contrib.base_systems.components.holder import ComponentHolderMixin, ComponentProperty + + +def get_component_class(component_name): + subclasses = Component.__subclasses__() + component_class = next((sc for sc in subclasses if sc.name == component_name), None) + if component_class is None: + message = f"Component named {component_name} has not been found. " \ + f"Make sure it has been imported before being used." + raise Exception(message) + + return component_class diff --git a/evennia/contrib/base_systems/components/component.py b/evennia/contrib/base_systems/components/component.py new file mode 100644 index 0000000000..f260610e54 --- /dev/null +++ b/evennia/contrib/base_systems/components/component.py @@ -0,0 +1,149 @@ +""" +Components - ChrisLR 2022 + +This file contains the base class to inherit for creating new components. +""" +import itertools + + +class Component: + """ + This is the base class for components. + Any component must inherit from this class to be considered for usage. + + Each Component must supply the name, it is used as a slot name but also part of the attribute key. + """ + name = "" + + def __init__(self, host=None): + assert self.name, "All Components must have a Name" + self.host = host + + @classmethod + def default_create(cls, host): + """ + This is called when the host is created + and should return the base initialized state of a component. + + Args: + host (object): The host typeclass instance + + Returns: + Component: The created instance of the component + + """ + new = cls(host) + return new + + @classmethod + def create(cls, host, **kwargs): + """ + This is the method to call when supplying kwargs to initialize a component. + + Args: + host (object): The host typeclass instance + **kwargs: Key-Value of default values to replace. + To persist the value, the key must correspond to a DBField. + + Returns: + Component: The created instance of the component + + """ + + new = cls.default_create(host) + for key, value in kwargs.items(): + setattr(new, key, value) + + return new + + def cleanup(self): + """ + This deletes all component attributes from the host's db + """ + for attribute in self._all_db_field_names: + delattr(self, attribute) + + @classmethod + def load(cls, host): + """ + Loads a component instance + This is called whenever a component is loaded (ex: Server Restart) + + Args: + host (object): The host typeclass instance + + Returns: + Component: The loaded instance of the component + + """ + + return cls(host) + + def at_added(self, host): + """ + This is the method called when a component is registered on a host. + + Args: + host (object): The host typeclass instance + + """ + + if self.host: + if self.host == host: + return + else: + raise ComponentRegisterError("Components must not register twice!") + + self.host = host + + def at_removed(self, host): + """ + This is the method called when a component is removed from a host. + + Args: + host (object): The host typeclass instance + + """ + if host != self.host: + raise ComponentRegisterError("Component attempted to remove from the wrong host.") + self.host = None + + @property + def attributes(self): + """ + Shortcut property returning the host's AttributeHandler. + + Returns: + AttributeHandler: The Host's AttributeHandler + + """ + return self.host.attributes + + @property + def nattributes(self): + """ + Shortcut property returning the host's In-Memory AttributeHandler (Non Persisted). + + Returns: + AttributeHandler: The Host's In-Memory AttributeHandler + + """ + return self.host.nattributes + + @property + def _all_db_field_names(self): + return itertools.chain(self.db_field_names, self.ndb_field_names) + + @property + def db_field_names(self): + db_fields = getattr(self, "_db_fields", {}) + return db_fields.keys() + + @property + def ndb_field_names(self): + ndb_fields = getattr(self, "_ndb_fields", {}) + return ndb_fields.keys() + + +class ComponentRegisterError(Exception): + pass diff --git a/evennia/contrib/base_systems/components/dbfield.py b/evennia/contrib/base_systems/components/dbfield.py new file mode 100644 index 0000000000..8e4f63b5da --- /dev/null +++ b/evennia/contrib/base_systems/components/dbfield.py @@ -0,0 +1,54 @@ +""" +Components - ChrisLR 2022 + +This file contains the Descriptors used to set Fields in Components +""" +from evennia.typeclasses.attributes import AttributeProperty, NAttributeProperty + + +class DBField(AttributeProperty): + """ + Component Attribute Descriptor. + Allows you to set attributes related to a component on the class. + It uses AttributeProperty under the hood but prefixes the key with the component name. + """ + + def __set_name__(self, owner, name): + """ + Called when descriptor is first assigned to the class. + + Args: + owner (object): The component classF on which this is set + name (str): The name that was used to set the DBField. + """ + key = f"{owner.name}__{name}" + self._key = key + db_fields = getattr(owner, "_db_fields", None) + if db_fields is None: + db_fields = {} + setattr(owner, '_db_fields', db_fields) + db_fields[name] = self + + +class NDBField(NAttributeProperty): + """ + Component In-Memory Attribute Descriptor. + Allows you to set in-memory attributes related to a component on the class. + It uses NAttributeProperty under the hood but prefixes the key with the component name. + """ + + def __set_name__(self, owner, name): + """ + Called when descriptor is first assigned to the class. + + Args: + owner (object): The component class on which this is set + name (str): The name that was used to set the DBField. + """ + key = f"{owner.name}__{name}" + self._key = key + ndb_fields = getattr(owner, "_ndb_fields", None) + if ndb_fields is None: + ndb_fields = {} + setattr(owner, '_ndb_fields', ndb_fields) + ndb_fields[name] = self diff --git a/evennia/contrib/base_systems/components/holder.py b/evennia/contrib/base_systems/components/holder.py new file mode 100644 index 0000000000..afcabc95e3 --- /dev/null +++ b/evennia/contrib/base_systems/components/holder.py @@ -0,0 +1,243 @@ +""" +Components - ChrisLR 2022 + +This file contains the classes that allow a typeclass to use components. +""" + +from evennia.contrib.base_systems import components + + +class ComponentProperty: + """ + This allows you to register a component on a typeclass. + Components registered with this property are automatically added + to any instance of this typeclass. + + Defaults can be overridden for this typeclass by passing kwargs + """ + def __init__(self, component_name, **kwargs): + """ + Initializes the descriptor + + Args: + component_name (str): The name of the component + **kwargs (any): Key=Values overriding default values of the component + """ + self.component_name = component_name + self.values = kwargs + + def __get__(self, instance, owner): + component = instance.components.get(self.component_name) + return component + + def __set__(self, instance, value): + raise Exception("Cannot set a class property") + + def __set_name__(self, owner, name): + class_components = getattr(owner, "_class_components", None) + if not class_components: + class_components = [] + setattr(owner, "_class_components", class_components) + + class_components.append((self.component_name, self.values)) + + +class ComponentHandler: + """ + This is the handler that will be added to any typeclass that inherits from ComponentHolder. + It lets you add or remove components and will load components as needed. + It stores the list of registered components on the host .db with component_names as key. + """ + def __init__(self, host): + self.host = host + self._loaded_components = {} + + def add(self, component): + """ + Method to add a Component to a host. + It caches the loaded component and appends its name to the host's component name list. + It will also call the component's 'at_added' method, passing its host. + + Args: + component (object): The 'loaded' component instance to add. + + """ + self._set_component(component) + self.db_names.append(component.name) + component.at_added(self.host) + + def add_default(self, name): + """ + Method to add a Component initialized to default values on a host. + It will retrieve the proper component and instanciate it with 'default_create'. + It will cache this new component and add it to its list. + It will also call the component's 'at_added' method, passing its host. + + Args: + name (str): The name of the component class to add. + + """ + component = components.get_component_class(name) + if not component: + raise ComponentDoesNotExist(f"Component {name} does not exist.") + + new_component = component.default_create(self.host) + self._set_component(new_component) + self.db_names.append(name) + new_component.at_added(self.host) + + def remove(self, component): + """ + Method to remove a component instance from a host. + It removes the component from the cache and listing. + It will call the component's 'at_removed' method. + + Args: + component (object): The component instance to remove. + + """ + component_name = component.name + if component_name in self._loaded_components: + component.at_removed(self.host) + self.db_names.remove(component_name) + del self._loaded_components[component_name] + else: + message = f"Cannot remove {component_name} from {self.host.name} as it is not registered." + raise ComponentIsNotRegistered(message) + + def remove_by_name(self, name): + """ + Method to remove a component instance from a host. + It removes the component from the cache and listing. + It will call the component's 'at_removed' method. + + Args: + name (str): The name of the component to remove. + + """ + instance = self.get(name) + if not instance: + message = f"Cannot remove {name} from {self.host.name} as it is not registered." + raise ComponentIsNotRegistered(message) + + instance.at_removed(self.host) + self.db_names.remove(name) + del self._loaded_components[name] + + def get(self, name): + """ + Method to retrieve a cached Component instance by its name. + + Args: + name (str): The name of the component to retrieve. + + """ + return self._loaded_components.get(name) + + def has(self, name): + """ + Method to check if a component is registered and ready. + + Args: + name (str): The name of the component. + + """ + return name in self._loaded_components + + def initialize(self): + """ + Method that loads and caches each component currently registered on the host. + It retrieves the names from the registered listing and calls 'load' on each + prototype class that can be found from this listing. + + """ + component_names = self.db_names + if not component_names: + return + + for component_name in component_names: + component = components.get_component_class(component_name) + if component: + component_instance = component.load(self.host) + self._set_component(component_instance) + else: + message = f"Could not initialize runtime component {component_name} of {self.host.name}" + raise ComponentDoesNotExist(message) + + def _set_component(self, component): + self._loaded_components[component.name] = component + + @property + def db_names(self): + """ + Property shortcut to retrieve the registered component names + + Returns: + component_names (iterable): The name of each component that is registered + + """ + return self.host.attributes.get("component_names") + + def __getattr__(self, name): + return self.get(name) + + +class ComponentHolderMixin(object): + """ + Mixin to add component support to a typeclass + + Components are set on objects using the component.name as an object attribute. + All registered components are initialized on the typeclass. + They will be of None value if not present in the class components or runtime components. + """ + + def at_init(self): + """ + Method that initializes the ComponentHandler. + """ + super(ComponentHolderMixin, self).at_init() + setattr(self, "_component_handler", ComponentHandler(self)) + self.components.initialize() + + def basetype_setup(self): + """ + Method that initializes the ComponentHandler, creates and registers all + components that were set on the typeclass using ComponentProperty. + """ + super().basetype_setup() + component_names = [] + setattr(self, "_component_handler", ComponentHandler(self)) + class_components = getattr(self, "_class_components", ()) + for component_name, values in class_components: + component_class = components.get_component_class(component_name) + component = component_class.create(self, **values) + component_names.append(component_name) + self.components._loaded_components[component_name] = component + + self.db.component_names = component_names + + @property + def components(self) -> ComponentHandler: + """ + Property getter to retrieve the component_handler. + Returns: + ComponentHandler: This Host's ComponentHandler + """ + return getattr(self, "_component_handler", None) + + @property + def cmp(self) -> ComponentHandler: + """ + Shortcut Property getter to retrieve the component_handler. + Returns: + ComponentHandler: This Host's ComponentHandler + """ + return self.components + + +class ComponentDoesNotExist(Exception): + pass + + +class ComponentIsNotRegistered(Exception): + pass diff --git a/evennia/contrib/base_systems/components/tests.py b/evennia/contrib/base_systems/components/tests.py new file mode 100644 index 0000000000..23d97a82e3 --- /dev/null +++ b/evennia/contrib/base_systems/components/tests.py @@ -0,0 +1,109 @@ +from evennia.contrib.base_systems.components import Component, DBField +from evennia.contrib.base_systems.components.holder import ComponentProperty, ComponentHolderMixin +from evennia.objects.objects import DefaultCharacter +from evennia.utils.test_resources import EvenniaTest + + +class ComponentTestA(Component): + name = "test_a" + my_int = DBField(default=1) + my_list = DBField(default=[]) + + +class ComponentTestB(Component): + name = "test_b" + my_int = DBField(default=1) + my_list = DBField(default=[]) + + +class RuntimeComponentTestC(Component): + name = "test_c" + my_int = DBField(default=6) + my_dict = DBField(default={}) + + +class CharacterWithComponents(ComponentHolderMixin, DefaultCharacter): + test_a = ComponentProperty("test_a") + test_b = ComponentProperty("test_b", my_int=3, my_list=[1, 2, 3]) + + +class TestComponents(EvenniaTest): + character_typeclass = CharacterWithComponents + + def test_character_has_class_components(self): + assert self.char1.test_a + assert self.char1.test_b + + def test_character_instances_components_properly(self): + assert isinstance(self.char1.test_a, ComponentTestA) + assert isinstance(self.char1.test_b, ComponentTestB) + + def test_character_assigns_default_value(self): + assert self.char1.test_a.my_int == 1 + assert self.char1.test_a.my_list == [] + + def test_character_assigns_default_provided_values(self): + assert self.char1.test_b.my_int == 3 + assert self.char1.test_b.my_list == [1, 2, 3] + + def test_character_can_register_runtime_component(self): + rct = RuntimeComponentTestC.create(self.char1) + self.char1.components.add(rct) + test_c = self.char1.components.get('test_c') + + assert test_c + assert test_c.my_int == 6 + assert test_c.my_dict == {} + + def test_handler_can_add_default_component(self): + self.char1.components.add_default("test_c") + test_c = self.char1.components.get("test_c") + + assert test_c + assert test_c.my_int == 6 + + def test_handler_has_returns_true_for_any_components(self): + rct = RuntimeComponentTestC.create(self.char1) + handler = self.char1.components + handler.add(rct) + + assert handler.has("test_a") + assert handler.has("test_b") + assert handler.has("test_c") + + def test_can_remove_component(self): + rct = RuntimeComponentTestC.create(self.char1) + handler = self.char1.components + handler.add(rct) + handler.remove(rct) + + assert handler.has("test_a") + assert handler.has("test_b") + assert not handler.has("test_c") + + def test_can_remove_component_by_name(self): + rct = RuntimeComponentTestC.create(self.char1) + handler = self.char1.components + handler.add(rct) + handler.remove_by_name("test_c") + + assert handler.has("test_a") + assert handler.has("test_b") + assert not handler.has("test_c") + + def test_cannot_replace_component(self): + with self.assertRaises(Exception): + self.char1.test_a = None + + def test_can_get_component(self): + rct = RuntimeComponentTestC.create(self.char1) + handler = self.char1.components + handler.add(rct) + + assert handler.get("test_c") is rct + + def test_can_access_component_regular_get(self): + assert self.char1.cmp.test_a is self.char1.components.get('test_a') + + def test_returns_none_with_regular_get_when_no_attribute(self): + assert self.char1.cmp.does_not_exist is None