Add TraitProperties as alternative way to define Traits from the traits contrib. Also clean up docs to resolve #2450.

This commit is contained in:
Griatch 2021-08-30 22:33:21 +02:00
parent 6e975236eb
commit 78e063d9ca
4 changed files with 240 additions and 30 deletions

View file

@ -134,6 +134,17 @@ Full director-style emoting system replacing names with sdescs/recogs. Supports
Dynamic obfuscation of emotes when speaking unfamiliar languages. Also obfuscates whispers.
### Traits
*Whitenoise 2014, Griatch2021*
Powerful on-object properties (very extended Attributes) for representing
health, mana, skill-levels etc, with automatic min/max value, base, modifiers
and named tiers for different values. Also include timed rate increase/decrease
to have values change over a period of time.
### Turnbattle
*FlutterSprite 2017*

View file

@ -10,8 +10,7 @@ from copy import copy
from anything import Something
from mock import MagicMock, patch
from django.test import TestCase
from django.test import override_settings
from evennia.utils.test_resources import EvenniaTest
from evennia.utils.utils import lazy_property
from evennia.contrib import traits
@ -903,3 +902,36 @@ class TestNumericTraitOperators(TestCase):
self.assertGreaterEqual(8, self.st)
self.assertGreaterEqual(self.st, 0)
self.assertGreaterEqual(10, self.st)
class DummyCharacter(_MockObj):
@lazy_property
def strength(self):
return traits.TraitProperty(self, "str", trait_type="static", base=10, mod=2)
@lazy_property
def hunting(self):
return traits.TraitProperty(self, "hunting", trait_type="counter", base=10, mod=1, max=100)
@lazy_property
def health(self):
return traits.TraitProperty(self, "hp", trait_type="gauge", base=100)
class TestTraitFields(TestCase):
"""
Test the TraitField class.
"""
@patch("evennia.contrib.traits._TRAIT_CLASS_PATHS", new=_TEST_TRAIT_CLASS_PATHS)
def test_traitfields(self):
obj = DummyCharacter()
# from evennia import set_trace;set_trace()
self.assertEqual(12, obj.strength.value)
self.assertEqual(11, obj.hunting.value)
self.assertEqual(100, obj.health.value)
obj.strength.base += 5
self.assertEqual(17, obj.strength.value)

View file

@ -38,29 +38,78 @@ class Object(DefaultObject):
# this adds the handler as .traits
return TraitHandler(self)
def at_object_creation(self):
# (or wherever you want)
self.traits.add("str", "Strength", trait_type="static", base=10, mod=2)
self.traits.add("hp", "Health", trait_type="gauge", min=0, max=100)
self.traits.add("hunting", "Hunting Skill", trait_type="counter",
base=10, mod=1, min=0, max=100)
```
When adding the trait, you supply the name of the property (`hunting`) along
with a more human-friendly name ("Hunting Skill"). The latter will show if you
print the trait etc. The `trait_type` is important, this specifies which type
of trait this is (see below).
There is an alternative way to define Traits, as individual `TraitProperty` entities. The
advantage is that you can define the Traits directly in the class, much like Django model fields.
You'll be able to access them as e.g. `self.strength` instead of `self.traits.strength`. The
drawback is that you must make sure that the name of your TraitsProperties don't collide with any
other properties/methods on your class. The `.traits` handler will also not automatically be
available to you if you want to add traits on the fly later.
```python
# mygame/typeclasses/objects.py
from evennia import DefaultObject
from evennia.utils import lazy_property
from evennia.contrib.traits import TraitProperty
# ...
class Object(DefaultObject):
...
@lazy_property
def strength(self):
# note that the trait's name must be set exactly the same as the name of the property!
return TraitProperty(self, "strength", "Strength", trait_type="static", base=10, mod=2)
@lazy_property
def hp(self):
return TraitProperty(self, "hp", "Health", trait_type="gauge", min=0, base=100, mod=2)
@lazy_property
def hunting(self):
return TraitProperty(self, "hunting", "Hunting Skill", trait_type="counter",
base=10, mod=1, min=0, max=100)
```
> Note that the trait name ('str', 'hp' and 'hunting' above) must be set exactly the same as the
> name of the property. Also, while the TraitHandler `.traits` is used under the hood, the
> handler will only be spawned after the TraitProperty has loaded at least once. If having `.traits`
> available matters to you, use `@property` instead of `@lazy_property` for one of the above
> definitions to make sure the handler is always initialized.
## Using traits
A trait is added to the traithandler, after which one can access it
as a property on the handler (similarly to how you can do .db.attrname for Attributes
in Evennia).
A trait is added to the traithandler (if you use `TraitProperty` the handler is just created under
the hood) after which one can access it as a property on the handler (similarly to how you can do
.db.attrname for Attributes in Evennia).
```python
# this is an example using the "static" trait, described below
>>> obj.traits.add("hunting", "Hunting Skill", trait_type="static", base=4)
>>> obj.traits.hunting.value
4
>>> obj.traits.hunting.value += 5
>>> obj.traits.hunting.value
9
>>> obj.traits.add("hp", "Health", trait_type="gauge", min=0, max=100)
>>> obj.traits.strength.value
12 # base + mod
>>> obj.traits.strength.value += 5
>>> obj.traits.strength.value
17
>>> obj.traits.hp.value
100
102 # base + mod
>>> obj.traits.hp -= 200
>>> obj.traits.hp.value
0
0 # min of 0
>>> obj.traits.hp.reset()
>>> obj.traits.hp.value
100
@ -72,12 +121,16 @@ in Evennia).
>>> obj.traits.hp.effect
"poisoned!"
# with TraitProperties, works the same:
>>> obj.hunting.value
12
>>> obj.strength.value += 5
>>> obj.strength.value
17
```
When adding the trait, you supply the name of the property (`hunting`) along
with a more human-friendly name ("Hunting Skill"). The latter will show if you
print the trait etc. The `trait_type` is important, this specifies which type
of trait this is.
## Trait types
@ -435,12 +488,22 @@ class MandatoryTraitKey:
This represents a required key that must be
supplied when a Trait is initialized. It's used
by Trait classes when defining their required keys.
"""
"""
class TraitHandler:
"""
Factory class that instantiates Trait objects.
Factory class that instantiates Trait objects. Must be assigned as a property
on the class, usually with `lazy_property`.
Example:
::
class Object(DefaultObject):
...
@lazy_property
def traits(self):
# this adds the handler as .traits
return TraitHandler(self)
"""
@ -450,12 +513,16 @@ class TraitHandler:
Args:
obj (Object): Parent Object typeclass for this TraitHandler
db_attribute_key (str): Name of the DB attribute for trait data storage
db_attribute_key (str): Name of the DB attribute for trait data storage.
db_attribute_category (str): Name of DB attribute's category to trait data storage.
"""
# load the available classes, if necessary
_delayed_import_trait_classes()
# initialize any
# Note that .trait_data retains the connection to the database, meaning every
# update we do to .trait_data automatically syncs with database.
self.trait_data = obj.attributes.get(db_attribute_key, category=db_attribute_category)
@ -615,6 +682,96 @@ class TraitHandler:
self.remove(trait_key)
class TraitProperty:
"""
Optional extra: Allows for applying traits as individual properties directly on the parent class
instead for properties on the `.traits` handler. So with this you could access data e.g. as
`character.hp.value` instead of `character.traits.hp.value`. This still uses the traitshandler
under the hood.
Example:
::
from evennia.utils import lazy_property
from evennia.contrib.traits import TraitProperty
class Character(DefaultCharacter):
@lazy_property
def strength(self):
return TraitProperty(self, "str", "Strength", trait_type="static", base=10, mod=2)
@lazy_property
def hunting(self):
return TraitProperty(self, "hunting", "Hunting Skill", trait_type="counter",
base=10, mod=1, max=100)
@lazy_property
def health(self):
return TraitProperty(self, "hp", "Health", trait_type="gauge", min=0, base=100)
"""
def __init__(self,
obj,
trait_key,
**kwargs):
"""
Initialize a TraitField.
Args:
obj (Object): The object the TraitProperty is defined on.
trait_key (str): Name of Trait.
Kwargs:
traithandler_name (str): If given, this is used as the name of the TraitHandler created
behind the scenes. If not set, this will be a property `traits` on the class.
any: All other properties are the same as for adding a new trait of the given type using
the normal TraitHandler.
"""
_SA(self, "obj", obj)
_SA(self, "trait_key", trait_key)
traithandler_name = kwargs.pop("traithandler_name", "traits")
_SA(self, 'traithandler_name', traithandler_name)
_SA(self, 'trait_properties', kwargs)
@property
def traithandler(self):
"""
Get/create the underlying traithandler.
"""
try:
return getattr(_GA(self, "obj"), _GA(self, "traithandler_name"))
except AttributeError:
# traithandler not found; create a new on-demand
new_traithandler = TraitHandler(_GA(self, "obj"))
setattr(_GA(self, "obj"), _GA(self, "traithandler_name"), new_traithandler)
return new_traithandler
@property
def trait(self):
"""
Get/create the underlying trait on the traithandler
"""
trait_key = _GA(self, "trait_key")
traithandler = _GA(self, "traithandler")
trait = traithandler.get(trait_key)
if trait is None:
traithandler.add(
trait_key,
**_GA(self, "trait_properties")
)
trait = traithandler.get(trait_key) # this caches it properly
return trait
def __getattribute__(self, name):
return _GA(_GA(self, "trait"), name)
def __setattr__(self, name, value):
_SA(_GA(self, "trait"), name, value)
# Parent Trait class
@ -949,7 +1106,7 @@ class Trait:
class StaticTrait(Trait):
"""
Static Trait. This is a single value with a modifier,
with no concept of a 'current' value.
with no concept of a 'current' value or min/max etc.
value = base + mod
@ -964,6 +1121,16 @@ class StaticTrait(Trait):
return "{name:12} {status} ({mod:+3})".format(name=self.name, status=status, mod=self.mod)
# Helpers
@property
def base(self):
return self._data["base"]
@base.setter
def base(self, value):
if value is None:
self._data["base"] = self.default_keys["base"]
if type(value) in (int, float):
self._data["base"] = value
@property
def mod(self):
@ -1374,13 +1541,13 @@ class GaugeTrait(CounterTrait):
@max.setter
def max(self, value):
raise TraitException(
"The .max property is not settable " "on GaugeTraits. Set .base instead."
"The .max property is not settable on GaugeTraits. Set .mod and .base instead."
)
@max.deleter
def max(self):
raise TraitException(
"The .max property cannot be reset " "on GaugeTraits. Reset .mod and .base instead."
"The .max property cannot be reset on GaugeTraits. Reset .mod and .base instead."
)
@property

View file

@ -2048,7 +2048,7 @@ def deepsize(obj, max_depth=4):
_missing = object()
class lazy_property(object):
class lazy_property:
"""
Delays loading of property until first access. Credit goes to the
Implementation in the werkzeug suite: