Start copying Traithandler from Ainneve to contribs

This commit is contained in:
Griatch 2020-04-13 22:24:29 +02:00
parent 7d78eda3fd
commit 485ab5907c

873
evennia/contrib/traits.py Normal file
View file

@ -0,0 +1,873 @@
"""
Traits
Whitenoise 2014, Ainneve contributors,
Griatch 2020
A `Trait` represents a modifiable property of (usually) a Character. They can
be used to represent everything from attributes (str, agi etc) to skills
(hunting, swords etc) or effects (poisoned, rested etc) and has extra
functionality beyond using plain Attributes for this.
Traits use Evennia Attributes under the hood, making them persistent (they survive
a server reload/reboot).
### Adding Traits to a typeclass
To access and manipulate tragts on an object, its Typeclass needs to have a
`TraitHandler` assigned it. Usually, the handler is made available as `.traits`
(in the same way as `.tags` or `.attributes`).
Here's an example for adding the TraitHandler to the base Object class:
```python
# mygame/typeclasses/objects.py
from evennia import DefaultObject
from evennia.utils import lazy_property
from evennia.contrib.traits import TraitHandler
# ...
class Object(DefaultObject):
...
@lazy_property
def traits(self):
# this adds the handler as .traits
return TraitHandler(self)
```
### Trait Configuration
A single Trait can be one of three basic types:
- `Static` - this means a base value and an optional modifier. A typical example would be
something like a Strength stat or Skill value. That is, something that varies slowly or
not at all.
- `Counter` - a Trait of this type has a base value and a current value that
can vary inside a specified range. This could be used for skills that can only incrase
to a max value.
- `Gauge` - Modified counter type modeling a refillable "gauge" that varies between "empty"
and "full". The classic example is a Health stat.
```python
obj.traits.add("hp", name="Health", type="static",
base=0, mod=0, min=None, max=None, extra={})
```
All traits have a read-only `actual` property that will report the trait's
actual value.
Example:
```python
>>> hp = obj.traits.hp
>>> hp.actual
100
```
They also support storing arbitrary data via either dictionary key or
attribute syntax. Storage of arbitrary data in this way has the same
constraints as any nested collection type stored in a persistent Evennia
Attribute, so it is best to avoid attempting to store complex objects.
#### Static Trait Configuration
A static `Trait` stores a `base` value and a `mod` modifier value.
The trait's actual value is equal to `base`+`mod`.
Static traits can be used to model many different stats, such as
Strength, Character Level, or Defense Rating in many tabletop gaming
systems.
Constructor Args:
name (str): name of the trait
type (str): 'static' for static traits
base (int, float): base value of the trait
mod (int, optional): modifier value
extra (dict, optional): keys of this dict are accessible on the
`Trait` object as attributes or dict keys
Properties:
actual (int, float): returns the value of `mod`+`base` properties
extra (list[str]): list of keys stored in the extra data dict
Methods:
reset_mod(): sets the value of the `mod` property to zero
Examples:
'''python
>>> char.traits.add("str", "Strength", base=5)
>>> strength = char.traits.str
>>> strength.actual
5
>>> strength.mod = 2 # add a bonus to strength
>>> str(strength)
'Strength 7 (+2)'
>>> strength.reset_mod() # clear bonuses
>>> str(strength)
'Strength 5 (+0)'
>>> strength.newkey = 'newvalue'
>>> strength.extra
['newkey']
>>> strength
Trait({'name': 'Strength', 'type': 'trait', 'base': 5, 'mod': 0,
'min': None, 'max': None, 'extra': {'newkey': 'newvalue'}})
```
#### Counter Trait Configuration
Counter type `Trait` objects have a `base` value similar to static
traits, but adds a `current` value and a range along which it may
vary. Modifier values are applied to this `current` value instead
of `base` when determining the `actual` value. The `current` can
also be reset to its `base` value by calling the `reset_counter()`
method.
Counter style traits are best used to represent game traits such as
carrying weight, alignment points, a money system, or bonus/penalty
counters.
Constructor Args:
(all keys listed above for 'static', plus:)
min Optional(int, float, None): default None
minimum allowable value for current; unbounded if None
max Optional(int, float, None): default None
maximum allowable value for current; unbounded if None
Properties:
actual (int, float): returns the value of `mod`+`current` properties
Methods:
reset_counter(): resets `current` equal to the value of `base`
Examples:
```python
>>> char.traits.add("carry", "Carry Weight", base=0, min=0, max=10000)
>>> carry = caller.traits.carry
>>> str(carry)
'Carry Weight 0 ( +0)'
>>> carry.current -= 3 # try to go negative
>>> carry # enforces zero minimum
'Carry Weight 0 ( +0)'
>>> carry.current += 15
>>> carry
'Carry Weight 15 ( +0)'
>>> carry.mod = -5 # apply a modifier to reduce
>>> carry # apparent weight
'Carry Weight: 10 ( -5)'
>>> carry.current = 10000 # set a semi-large value
>>> carry # still have the modifier
'Carry Weight 9995 ( -5)'
>>> carry.reset() # remove modifier
>>> carry
'Carry Weight 10000 ( +0)'
>>> carry.reset_counter()
>>> carry
0
```
#### Gauge Trait Configuration
A "gauge" type `Trait` is a modified counter trait used to model a
gauge that can be emptied and refilled. The `base` property of a
gauge trait represents its "full" value. The `mod` property increases
or decreases that "full" value, rather than the `current`.
Gauge type traits are best used to represent traits such as health
points, stamina points, or magic points.
By default gauge type traits have a `min` of zero, and a `max` set
to the `base`+`mod` properties. A gauge will still work if its `max`
property is set to a value above its `base` or to None.
Constructor Args:
(all keys listed above for 'static', plus:)
min Optional(int, float, None): default 0
minimum allowable value for current; unbounded if None
max Optional(int, float, None, 'base'): default 'base'
maximum allowable value for current; unbounded if None;
if 'base', returns the value of `base`+`mod`.
Properties:
actual (int, float): returns the value of the `current` property
Methods:
fill_gauge(): adds the value of `base`+`mod` to `current`
percent(): returns the ratio of actual value to max value as
a percentage. if `max` is unbound, return the ratio of
`current` to `base`+`mod` instead.
Examples:
```python
>>> caller.traits.add("hp", "Health", base=10)
>>> hp = caller.traits.hp
>>> repr(hp)
GaugeTrait({'name': 'HP', 'type': 'gauge', 'base': 10, 'mod': 0,
'min': 0, 'max': 'base', 'current': 10, 'extra': {}})
>>> str(hp)
'HP: 10 / 10 ( +0)'
>>> hp.current -= 6 # take damage
>>> str(hp)
'HP: 4 / 10 ( +0)'
>>> hp.current -= 6 # take damage to below min
>>> str(hp)
'HP: 0 / 10 ( +0)'
>>> hp.fill() # refill trait
>>> str(hp)
'HP: 10 / 10 ( +0)'
>>> hp.current = 15 # try to set above max
>>> str(hp) # disallowed because max=='actual'
'HP: 10 / 10 ( +0)'
>>> hp.mod += 3 # bonus on full trait
>>> str(hp) # buffs flow to current
'HP: 13 / 13 ( +3)'
>>> hp.current -= 5
>>> str(hp)
'HP: 8 / 13 ( +3)'
>>> hp.reset() # remove bonus on reduced trait
>>> str(hp) # debuffs do not affect current
'HP: 8 / 10 ( +0)'
```
"""
from functools import total_ordering
from evennia.utils.dbserialize import _SaverDict
from evennia.utils import logger
from evennia.utils.utils import inherits_from
STATIC_TYPE = "static"
COUNTER_TYPE = "counter",
GAUGE_TYPE = "gauge"
TRAIT_TYPES = (STATIC_TYPE, COUNTER_TYPE, GAUGE_TYPE)
RANGE_TRAITS = (COUNTER_TYPE, GAUGE_TYPE)
class TraitException(Exception):
"""
Base exception class raised by `Trait` objects.
Args:
msg (str): informative error message
"""
def __init__(self, msg):
self.msg = msg
class TraitHandler:
"""
Factory class that instantiates Trait objects.
"""
def __init__(self, obj, db_attribute_key='traits', db_attribute_category="traits"):
"""
Initialize the handler and set up its internal Attribute-based storage.
Args:
obj (Object): Parent Object typeclass for this TraitHandler
db_attribute_key (str): Name of the DB attribute for trait data storage
"""
# Note that this 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)
if self.trait_data is None:
# no existing storage; initialize it
obj.attributes.add(db_attribute_key, {}, category=db_attribute_category)
self.trait_data = {}
self._cache = {}
def __len__(self):
"""Return number of Traits registered with the handler"""
return len(self.trait_data)
def __setattr__(self, key, value):
"""Returns error message if trait objects are assigned directly."""
if key in ('trait_data', '_cache'):
super().__setattr__(key, value)
else:
raise TraitException(
"Trait object not settable directly. Assign to one of "
f"`{key}.base`, `{key}.mod`, or `{key}.current` instead."
)
def __setitem__(self, key, value):
"""Returns error message if trait objects are assigned directly."""
return self.__setattr__(key, value)
def __getattr__(self, trait):
"""Returns Trait instances accessed as attributes."""
return self.get(trait)
def __getitem__(self, trait):
"""Returns `Trait` instances accessed as dict keys."""
return self.get(trait)
@property
def all(self):
"""
Get all trait keys in this handler.
Returns:
list: All Trait keys.
"""
return list(self.trait_data.keys())
def get(self, trait):
"""
Args:
trait (str): key from the traits dict containing config data
for the trait. "all" returns a list of all trait keys.
Returns:
(`Trait` or `None`): named Trait class or None if trait key
is not found in traits collection.
"""
trait = self._cache.get(trait)
if trait is None and trait in self.trait_data:
trait = self.cache[trait] = Trait(self.trait_data[trait])
return trait
def add(self, key, name=None, trait_type=STATIC_TYPE,
base=0, modifier=0, min_value=0, max_value=0,
force=False, **extra_properties):
"""
Create a new Trait and add it to the handler.
Args:
key (str): This is the name of the property that will be made
available on this handler (example 'hp').
name (str, optional): This is a longer name used in Trait
string representation (example 'Health'). If not given, this
will be set the same as `key`, starting with a capital letter.
trait_type (str, optional): One of 'static', 'counter' or 'gauge'.
base (int or float, optional): The base value, or 'full' value in the case
of a gauge.
modifier (int, optional): A modifier affecting the current or base value.
min_value (int or float, optional): The minimum allowed value.
max_value (int or float, optional): The maximum allowed value.
force (bool, optional): Always add, replacing any existing trait.
**extra_properties (any): All other kwargs will be made available as key:value
properties on the handler. These must all be possible to store
in an Attribute.
Raises:
TraitException: If specifying invalid values or an existing trait
already exists (and `force` is unset).
"""
if key in self.trait_data:
if force:
self.remove(key)
else:
raise TraitException(f"Trait '{key}' already exists.")
if trait_type not in TRAIT_TYPES:
raise TraitException("Trait-type '{trait_type} is invalid.")
trait_kwargs = dict(
name=name if name is not None else key.title(),
trait_type=trait_type,
base=base,
modifier=modifier,
min_value=min_value,
max_value=max_value,
extra_properties=extra_properties
)
self.trait_data[key] = trait_kwargs
def remove(self, key):
"""
Remove a Trait from the handler's parent object.
Args:
key (str): The name of the trait to remove.
"""
if key not in self.trait_data:
raise TraitException(f"Trait '{key}' not found.")
if key in self._cache:
del self.cache[key]
del self.trait_data[key]
def clear(self):
"""
Remove all Traits from the handler's parent object.
"""
for key in self.all:
self.remove(key)
# Parent Trait class
@total_ordering
class Trait:
"""Represents an object or Character trait.
Note:
See module docstring for configuration details.
"""
_keys = set("name", "type", "base", "mod", "current",
"min", "max", "extra_properties")
def __init__(self, trait_data):
"""
Initialize a Trait with stored data.
Args:
trait_data (_SaverDict or dict): This will be a _SaverDict if
passed from the TraitHandler, which means this will automatically
save itself the database when updating
"""
if not all(key in trait_data for key in self.valid_keys):
raise TraitException(
f"Required keys missing from trait_data "
f"(input was {list(trait_data.keys())}, "
f"required are {self.valid_keys}).")
self._type = trait_data['trait_type']
self._data = trait_data
self._locked = True
if not isinstance(trait_data, _SaverDict):
logger.log_warn(
f"Non-persistent Trait data (type(trait_data)) "
f"loaded for {type(self).__name__}.")
# Private helper members
def _enforce_bounds(self, value):
"""Ensures that incoming value falls within trait's range."""
if self._type in RANGE_TRAITS:
if self.min is not None and value <= self.min:
return self.min
if self._data['max'] == 'base' and value >= self.mod + self.base:
return self.mod + self.base
if self.max is not None and value >= self.max:
return self.max
return value
def _mod_base(self):
return self._enforce_bounds(self.mod + self.base)
def _mod_current(self):
return self._enforce_bounds(self.mod + self.current)
def __repr__(self):
"""Debug-friendly representation of this Trait."""
return "{}({{{}}})".format(
type(self).__name__,
', '.join(["'{}': {!r}".format(k, self._data[k])
for k in self._keys if k in self._data]))
def __str__(self):
status = "{actual:11}".format(actual=self.actual)
return "{name:12} {status} ({mod:+3})".format(
name=self.name,
status=status,
mod=self.mod)
# Extra Properties - allow access to properties on Trait
def __getitem__(self, key):
"""Access extra parameters as dict keys."""
try:
return self.__getattr__(key)
except AttributeError:
raise KeyError(key)
def __setitem__(self, key, value):
"""Set extra parameters as dict keys."""
self.__setattr__(key, value)
def __delitem__(self, key):
"""Delete extra parameters as dict keys."""
self.__delattr__(key)
def __getattr__(self, key):
"""Access extra parameters as attributes."""
try:
return self._data['extra_properties'][key]
except KeyError:
raise AttributeError(
"{} '{}' has no attribute {!r}".format(
type(self).__name__, self.name, key
))
def __setattr__(self, key, value):
"""Set extra parameters as attributes.
Arbitrary attributes set on a Trait object will be
stored in the 'extra' key of the `_data` attribute.
This behavior is enabled by setting the instance
variable `_locked` to True.
"""
propobj = getattr(self.__class__, key, None)
if isinstance(propobj, property):
if propobj.fset is None:
raise AttributeError(f"Can't set attribute {key}.")
propobj.fset(self, value)
else:
if (self.__dict__.get('_locked', False) and
key not in ('_keys',)):
self._data['extra_properties'][key] = value
else:
super().__setattr__(key, value)
def __delattr__(self, key):
"""Delete extra parameters as attributes."""
if key in self._data['extra_parameters']:
del self._data['extra_parameters'][key]
# Numeric operations
def __eq__(self, other):
"""Support equality comparison between Traits or Trait and numeric.
Note:
This class uses the @functools.total_ordering() decorator to
complete the rich comparison implementation, therefore only
`__eq__` and `__lt__` are implemented.
"""
if inherits_from(other, Trait):
return self.actual == other.actual
elif type(other) in (float, int):
return self.actual == other
else:
return NotImplemented
def __lt__(self, other):
"""Support less than comparison between `Trait`s or `Trait` and numeric."""
if inherits_from(other, Trait):
return self.actual < other.actual
elif type(other) in (float, int):
return self.actual < other
else:
return NotImplemented
def __pos__(self):
"""Access `actual` property through unary `+` operator."""
return self.actual
def __add__(self, other):
"""Support addition between `Trait`s or `Trait` and numeric"""
if inherits_from(other, Trait):
return self.actual + other.actual
elif type(other) in (float, int):
return self.actual + other
else:
return NotImplemented
def __sub__(self, other):
"""Support subtraction between `Trait`s or `Trait` and numeric"""
if inherits_from(other, Trait):
return self.actual - other.actual
elif type(other) in (float, int):
return self.actual - other
else:
return NotImplemented
def __mul__(self, other):
"""Support multiplication between `Trait`s or `Trait` and numeric"""
if inherits_from(other, Trait):
return self.actual * other.actual
elif type(other) in (float, int):
return self.actual * other
else:
return NotImplemented
def __floordiv__(self, other):
"""Support floor division between `Trait`s or `Trait` and numeric"""
if inherits_from(other, Trait):
return self.actual // other.actual
elif type(other) in (float, int):
return self.actual // other
else:
return NotImplemented
# commutative property
__radd__ = __add__
__rmul__ = __mul__
def __rsub__(self, other):
"""Support subtraction between `Trait`s or `Trait` and numeric"""
if inherits_from(other, Trait):
return other.actual - self.actual
elif type(other) in (float, int):
return other - self.actual
else:
return NotImplemented
def __rfloordiv__(self, other):
"""Support floor division between `Trait`s or `Trait` and numeric"""
if inherits_from(other, Trait):
return other.actual // self.actual
elif type(other) in (float, int):
return other // self.actual
else:
return NotImplemented
# Public members
@property
def name(self):
"""Display name for the trait."""
return self._data['name']
key = name
@property
def actual(self):
"The actual value of the trait"
return self._mod_base()
@property
def base(self):
"""The trait's base value.
Note:
The setter for this property will enforce any range bounds set
on this `Trait`.
"""
return self._data['base']
@base.setter
def base(self, amount):
if self._data.get('max', None) == 'base':
self._data['base'] = amount
if type(amount) in (int, float):
self._data['base'] = self._enforce_bounds(amount)
@property
def mod(self):
"""The trait's modifier."""
return self._data['modifier']
@mod.setter
def mod(self, amount):
if type(amount) in (int, float):
self._data['modifier'] = amount
@property
def min(self):
return self._data["min_value"]
@min.setter
def min(self, value):
self._data["min_value"] = value
@property
def max(self):
return self._data['max_value']
@max.setter
def max(self, value):
self._data["max_value"] = value
@property
def current(self):
"""The `current` value of the `Trait`."""
return self._data.get('current', self.base)
@current.setter
def current(self, value):
self._data["current"] = value
@property
def extra(self):
"""Returns a list containing available extra data keys."""
return self._data['extra'].keys()
def reset_mod(self):
"""Clears any mod value to 0."""
self.mod = 0
def reset(self):
"""Resets `current` property equal to `base` value."""
self.current = self.base
def percent(self):
"""Returns the value formatted as a percentage."""
return "100.0%"
# Implementation of the respective Trait types
class StaticTrait(Trait):
"""
Static Trait.
"""
@property
def min(self):
raise TraitException(f"Static Trait {self.key} has no minimum value.")
@min.setter
def min(self):
raise TraitException(f"Cannot set minimum value for static Trait {self.key}.")
@property
def max(self):
raise TraitException("Static Trait {self.key} has no maximum value.")
@max.setter
def max(self):
raise TraitException("Cannot set maximum value for static Trait {self.key}.")
@property
def current(self):
"""The `current` value of the `Trait`."""
return super().current
@current.setter
def current(self, value):
raise TraitException(
f"Cannot set 'current' property on static Trait {self.key}.")
def reset(self):
raise TraitException(
f"Cannot reset static Trait {self.key}.")
class CounterTrait(Trait):
"""
Counter Trait.
"""
@property
def actual(self):
"The actual value of the Trait"
return self._mod_current()
@property
def min(self):
"""The lower bound of the range."""
return super().min
@min.setter
def min(self, amount):
if amount is None:
self._data['min'] = amount
elif type(amount) in (int, float):
self._data['min'] = amount if amount < self.base else self.base
@property
def max(self):
if self._data['max_value'] == 'base':
return self._mod_base()
return super().max
@max.setter
def max(self):
"""The maximum value of the `Trait`.
Note:
This property may be set to the string literal 'base'.
When set this way, the property returns the value of the
`mod`+`base` properties.
"""
if self._data['max_value'] == 'base':
return self._mod_base()
return super().max
@max.setter
def max(self, value):
if value == 'base' or value is None:
self._data['max_value'] = value
elif type(value) in (int, float):
self._data['max_value'] = value if value > self.base else self.base
@property
def current(self):
"""The `current` value of the `Trait`."""
return super().current
@current.setter
def current(self, value):
if type(value) in (int, float):
self._data['current'] = self._enforce_bounds(value)
else:
raise AttributeError(
"'current' property is read-only on static 'Trait'.")
def percent(self):
"""Returns the value formatted as a percentage."""
if self.max:
return "{:3.1f}%".format(self.current * 100.0 / self.max)
elif self.base != 0:
return "{:3.1f}%".format(self.current * 100.0 / self._mod_base())
# if we get to this point, it's may be a divide by zero situation
return "100.0%"
class GaugeTrait(CounterTrait):
"""
Gauge Trait.
"""
def __str__(self):
status = "{actual:4} / {base:4}".format(
actual=self.actual,
base=self.base)
return "{name:12} {status} ({mod:+3})".format(
name=self.name,
status=status,
mod=self.mod)
@property
def actual(self):
"The actual value of the trait"
return self.current
@property
def mod(self):
"""The trait's modifier."""
return super().mod
@mod.setter
def mod(self, amount):
if type(amount) in (int, float):
self._data['modifier'] = amount
delta = amount - self._data['modifier']
if delta >= 0:
# apply increases to current
self.current = self._enforce_bounds(self.current + delta)
else:
# but not decreases, unless current goes out of range
self.current = self._enforce_bounds(self.current)
@property
def current(self):
"""The `current` value of the `Trait`."""
return self._data.get('current', self._mod_base())
@current.setter
def current(self, value):
super().current = value
def fill_gauge(self):
"""Adds the `mod`+`base` to the `current` value.
Note:
Will honor the upper bound if set.
"""
self.current = \
self._enforce_bounds(self.current + self._mod_base())