diff --git a/evennia/contrib/base_systems/components/README.md b/evennia/contrib/base_systems/components/README.md index 75489e3008..fc594aa036 100644 --- a/evennia/contrib/base_systems/components/README.md +++ b/evennia/contrib/base_systems/components/README.md @@ -50,7 +50,29 @@ class Health(Component): health = DBField(default=1) ``` -Note that default is optional and will default to None +Note that default is optional and will default to None. + +Adding a component to a host will also a similarly named tag with 'components' as category. +A Component named health will appear as key="health, category="components". +This allows you to retrieve objects with specific components by searching with the tag. + +It is also possible to add Component Tags the same way, using TagField. +TagField accepts a default value and can be used to store a single or multiple tags. +Default values are automatically added when the component is added. +Component Tags are cleared from the host if the component is removed. + +Example: +```python +from evennia.contrib.base_systems.components import Component, TagField + +class Health(Component): + resistances = TagField() + vulnerability = TagField(default="fire", enforce_single=True) +``` + +The 'resistances' field in this example can be set to multiple times and it will keep the added tags. +The 'vulnerability' field in this example will override the previous tag with the new one. + Each typeclass using the ComponentHolderMixin can declare its components diff --git a/evennia/contrib/base_systems/components/__init__.py b/evennia/contrib/base_systems/components/__init__.py index 705e9ee411..1aa94a1df1 100644 --- a/evennia/contrib/base_systems/components/__init__.py +++ b/evennia/contrib/base_systems/components/__init__.py @@ -9,7 +9,7 @@ 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.dbfield import DBField, NDBField, TagField from evennia.contrib.base_systems.components.holder import ComponentHolderMixin, ComponentProperty diff --git a/evennia/contrib/base_systems/components/component.py b/evennia/contrib/base_systems/components/component.py index f260610e54..4b1697da38 100644 --- a/evennia/contrib/base_systems/components/component.py +++ b/evennia/contrib/base_systems/components/component.py @@ -144,6 +144,11 @@ class Component: ndb_fields = getattr(self, "_ndb_fields", {}) return ndb_fields.keys() + @property + def tag_field_names(self): + tag_fields = getattr(self, "_tag_fields", {}) + return tag_fields.keys() + class ComponentRegisterError(Exception): pass diff --git a/evennia/contrib/base_systems/components/dbfield.py b/evennia/contrib/base_systems/components/dbfield.py index 8e4f63b5da..7e2d16edee 100644 --- a/evennia/contrib/base_systems/components/dbfield.py +++ b/evennia/contrib/base_systems/components/dbfield.py @@ -52,3 +52,64 @@ class NDBField(NAttributeProperty): ndb_fields = {} setattr(owner, '_ndb_fields', ndb_fields) ndb_fields[name] = self + + +class TagField: + """ + Component Tags Descriptor. + Allows you to set Tags related to a component on the class. + The tags are set with a prefixed category, so it can support + multiple tags or enforce a single one. + + Default value of a tag is added when the component is registered. + Tags are removed if the component itself is removed. + """ + def __init__(self, default=None, enforce_single=False): + self._category_key = None + self._default = default + self._enforce_single = enforce_single + + def __set_name__(self, owner, name): + """ + Called when TagField is first assigned to the class. + It is called with the component class and the name of the field. + """ + self._category_key = f"{owner.name}::{name}" + tag_fields = getattr(owner, "_tag_fields", None) + if tag_fields is None: + tag_fields = {} + setattr(owner, '_tag_fields', tag_fields) + tag_fields[name] = self + + def __get__(self, instance, owner): + """ + Called when retrieving the value of the TagField. + It is called with the component instance and the class. + """ + tag_value = instance.host.tags.get( + default=self._default, + category=self._category_key, + ) + return tag_value + + def __set__(self, instance, value): + """ + Called when setting a value on the TagField. + It is called with the component instance and the value. + """ + + tag_handler = instance.host.tags + if self._enforce_single: + tag_handler.clear(category=self._category_key) + + tag_handler.add( + key=value, + category=self._category_key, + ) + + def __delete__(self, instance): + """ + Used when 'del' is called on the TagField. + It is called with the component instance. + """ + instance.host.tags.clear(category=self._category_key) diff --git a/evennia/contrib/base_systems/components/holder.py b/evennia/contrib/base_systems/components/holder.py index ef0be1a242..ddd606151d 100644 --- a/evennia/contrib/base_systems/components/holder.py +++ b/evennia/contrib/base_systems/components/holder.py @@ -64,7 +64,7 @@ class ComponentHandler: """ self._set_component(component) self.db_names.append(component.name) - self.host.tags.add(component.name, category="components") + self._add_component_tags(component) component.at_added(self.host) def add_default(self, name): @@ -85,9 +85,24 @@ class ComponentHandler: new_component = component.default_create(self.host) self._set_component(new_component) self.db_names.append(name) - self.host.tags.add(name, category="components") + self._add_component_tags(new_component) new_component.at_added(self.host) + def _add_component_tags(self, component): + """ + Private method that adds the Tags set on a Component via TagFields + It will also add the name of the component so objects can be filtered + by the components the implement. + + Args: + component (object): The component instance that is added. + """ + self.host.tags.add(component.name, category="components") + for tag_field_name in component.tag_field_names: + default_tag = type(component).__dict__[tag_field_name]._default + if default_tag: + setattr(component, tag_field_name, default_tag) + def remove(self, component): """ Method to remove a component instance from a host. @@ -100,9 +115,9 @@ class ComponentHandler: """ component_name = component.name if component_name in self._loaded_components: + self._remove_component_tags(component) component.at_removed(self.host) self.db_names.remove(component_name) - self.host.tags.remove(component_name, category="components") del self._loaded_components[component_name] else: message = f"Cannot remove {component_name} from {self.host.name} as it is not registered." @@ -123,11 +138,24 @@ class ComponentHandler: message = f"Cannot remove {name} from {self.host.name} as it is not registered." raise ComponentIsNotRegistered(message) + self._remove_component_tags(instance) instance.at_removed(self.host) self.db_names.remove(name) - self.host.tags.remove(name, category="components") + del self._loaded_components[name] + def _remove_component_tags(self, component): + """ + Private method that will remove the Tags set on a Component via TagFields + It will also remove the component name tag. + + Args: + component (object): The component instance that is removed. + """ + self.host.tags.remove(component.name, category="components") + for tag_field_name in component.tag_field_names: + delattr(component, tag_field_name) + def get(self, name): """ Method to retrieve a cached Component instance by its name. @@ -225,8 +253,8 @@ class ComponentHolderMixin(object): Method that add component related tags that were set using ComponentProperty. """ super().basetype_posthook_setup() - for component_name in self.db.component_names: - self.tags.add(component_name, category="components") + for component in self.components._loaded_components.values(): + self.components._add_component_tags(component) @property def components(self) -> ComponentHandler: diff --git a/evennia/contrib/base_systems/components/tests.py b/evennia/contrib/base_systems/components/tests.py index b9b18f28ba..c374971e3f 100644 --- a/evennia/contrib/base_systems/components/tests.py +++ b/evennia/contrib/base_systems/components/tests.py @@ -1,4 +1,4 @@ -from evennia.contrib.base_systems.components import Component, DBField +from evennia.contrib.base_systems.components import Component, DBField, TagField from evennia.contrib.base_systems.components.holder import ComponentProperty, ComponentHolderMixin from evennia.objects.objects import DefaultCharacter from evennia.utils.test_resources import EvenniaTest @@ -14,12 +14,17 @@ class ComponentTestB(Component): name = "test_b" my_int = DBField(default=1) my_list = DBField(default=[]) + default_tag = TagField(default="initial_value") + single_tag = TagField(enforce_single=True) + multiple_tags = TagField() + default_single_tag = TagField(default="initial_value", enforce_single=True) class RuntimeComponentTestC(Component): name = "test_c" my_int = DBField(default=6) my_dict = DBField(default={}) + added_tag = TagField(default="added_value") class CharacterWithComponents(ComponentHolderMixin, DefaultCharacter): @@ -111,7 +116,11 @@ class TestComponents(EvenniaTest): def test_host_has_class_component_tags(self): assert self.char1.tags.has(key="test_a", category="components") assert self.char1.tags.has(key="test_b", category="components") + assert self.char1.tags.has(key="initial_value", category="test_b::default_tag") + assert self.char1.test_b.default_tag == "initial_value" assert not self.char1.tags.has(key="test_c", category="components") + assert not self.char1.tags.has(category="test_b::single_tag") + assert not self.char1.tags.has(category="test_b::multiple_tags") def test_host_has_added_component_tags(self): rct = RuntimeComponentTestC.create(self.char1) @@ -119,12 +128,16 @@ class TestComponents(EvenniaTest): test_c = self.char1.components.get('test_c') assert self.char1.tags.has(key="test_c", category="components") + assert self.char1.tags.has(key="added_value", category="test_c::added_tag") + assert test_c.added_tag == "added_value" def test_host_has_added_default_component_tags(self): self.char1.components.add_default("test_c") test_c = self.char1.components.get("test_c") assert self.char1.tags.has(key="test_c", category="components") + assert self.char1.tags.has(key="added_value", category="test_c::added_tag") + assert test_c.added_tag == "added_value" def test_host_remove_component_tags(self): rct = RuntimeComponentTestC.create(self.char1) @@ -134,6 +147,7 @@ class TestComponents(EvenniaTest): handler.remove(rct) assert not self.char1.tags.has(key="test_c", category="components") + assert not self.char1.tags.has(key="added_value", category="test_c::added_tag") def test_host_remove_by_name_component_tags(self): rct = RuntimeComponentTestC.create(self.char1) @@ -142,4 +156,33 @@ class TestComponents(EvenniaTest): assert self.char1.tags.has(key="test_c", category="components") handler.remove_by_name("test_c") - assert not self.char1.tags.has(key="test_c", category="components") \ No newline at end of file + assert not self.char1.tags.has(key="test_c", category="components") + assert not self.char1.tags.has(key="added_value", category="test_c::added_tag") + + def test_component_tags_only_hold_one_value_when_enforce_single(self): + test_b = self.char1.components.get('test_b') + test_b.single_tag = "first_value" + test_b.single_tag = "second value" + + assert self.char1.tags.has(key="second value", category="test_b::single_tag") + assert test_b.single_tag == "second value" + assert not self.char1.tags.has(key="first_value", category="test_b::single_tag") + + def test_component_tags_default_value_is_overridden_when_enforce_single(self): + test_b = self.char1.components.get('test_b') + test_b.default_single_tag = "second value" + + assert self.char1.tags.has(key="second value", category="test_b::default_single_tag") + assert test_b.default_single_tag == "second value" + assert not self.char1.tags.has(key="first_value", category="test_b::default_single_tag") + + def test_component_tags_support_multiple_values_by_default(self): + test_b = self.char1.components.get('test_b') + test_b.multiple_tags = "first value" + test_b.multiple_tags = "second value" + test_b.multiple_tags = "third value" + + assert all(val in test_b.multiple_tags for val in ("first value", "second value", "third value")) + assert self.char1.tags.has(key="first value", category="test_b::multiple_tags") + assert self.char1.tags.has(key="second value", category="test_b::multiple_tags") + assert self.char1.tags.has(key="third value", category="test_b::multiple_tags")