initial buff contrib commit

This commit is contained in:
Tegiminis 2022-07-12 01:22:59 -07:00
parent 8a9ccb4bbe
commit f340d4c6e4
3 changed files with 1043 additions and 0 deletions

View file

@ -0,0 +1,768 @@
"""Buffs - Tegiminis 2022
A buff is a timed object, attached to a game entity, that modifies values, triggers
code, or both. It is a common design pattern in RPGs, particularly action games.
This contrib gives you a buff handler to apply to your objects, a buff class to extend them,
and a sample property class to show how to automatically check modifiers.
## Quick Start
Assign the handler to a property on the object, like so.
```python
@lazy_property
def buffs(self) -> BuffHandler:
return BuffHandler(self)```
You may then call the handler to add or manipulate buffs.
### Customization
If you want to customize the handler, you can feed the constructor two arguments:
- `dbkey`: The string you wish to use as a key for the buff database. Defaults to "buffs". This allows you to keep separate buff pools - for example, "buffs" and "perks".
- `autopause`: If you want this handler to automatically pause certain buffs when its owning object is unpuppeted.
> IMPORTANT: If you enable autopausing, you MUST initialize the property in your object's
> `at_init` hook to cache. Otherwise, a hot reload can cause playtime buffs to not update properly
> on puppet/unpuppet. You have been warned!
Let's say you want another handler for an object, `perks`, which has a separate database and
respects playtime buffs. You'd assign this new property as so:
```python
@lazy_property
def perks(self) -> BuffHandler:
return BuffHandler(self, dbkey='perks', autopause=True)
```
And add `self.perks` to the object's `at_init`.
### Using the Handler
To actually make use of the handler, you still have to do some leg work.
#### Apply a Buff
Call the handler `add(BuffClass)` method. This requires a class reference, and also contains a number of
optional arguments to customize the buff's duration, stacks, and so on.
```python
self.buffs.handler.add(StrengthBuff) # A single stack of StrengthBuff with normal duration
self.buffs.handler.add(DexBuff, stacks=3, duration=60) # Three stacks of DexBuff, with a duration of 60 seconds
```
Two important attributes on the buff are checked when the buff is applied: `refresh` and `unique`.
- `refresh` (default: True) determines if a buff's timer is refreshed when it is reapplied.
- `unique` determines if the buff uses the buff's normal key (True) or one created with the key and the applier's dbref (False)
#### Modify
Call the handler `check(value, stat)` method wherever you want to see the modified value.
This will return the value, modified by and relevant buffs on the handler's owner (identified by
the `stat` string).
For example, let's say you want to modify how much damage you take. That might look something like this
```python
# The method we call to damage ourselves
def take_damage(self, source, damage):
_damage = self.buffs.check(damage, 'taken_damage')
self.db.health -= _damage
```
#### Trigger
Call the handler `trigger(triggerstring)` method wherever you want an event call. This
will call the `on_trigger` hook method on all buffs with the relevant trigger.
For example, let's say you want to trigger a buff to "detonate" when you hit your target with an attack.
You'd write a buff with at least the following stats:
```python
triggers = ['take_damage']
def on_trigger(self, trigger, *args, **kwargs)
self.owner.take_damage(100)
```
And then call `handler.trigger('take_damage')` in the method you use to take damage.
#### Tick
Ticking buffs are slightly special. They are similar to trigger buffs in that they run code, but instead of
doing so on an event trigger, they do so are a periodic tick. A common use case for a buff like this is a poison,
or a heal over time.
All you need to do to make a buff tick is ensure the `tickrate` is 1 or higher, and it has code in its `on_tick`
method. Once you add it to the handler, it starts ticking!
## Buffs
But wait! You still have to actually create the buffs you're going to be applying.
Creating a buff is very easy: extend BaseBuff into a new class, and fill in all the relevant buff details.
However, there are a lot of individual moving parts to a buff! Here's a step-through of the important stuff.
### Basics
Regardless of any mods or hook methods, all buffs have the following qualities:
- They have customizable `key`, `name`, and `flavor` strings.
- They can stack, if `maxstacks` is not equal to 1. If it's 0, the buff stacks forever.
- They have a `duration`, and automatically clean up at the end of it (-1 for infinite duration, 0 to cleanup immediately).
### Modifiers
Buffs which have one or more Mod objects in them can modify stats. You can use the handler method to check all
mods of a specific stat string and apply their modifications to the value; however, you are encouraged to use
`check` in a getter/setter, for easy access.
Mod objects consist of only four values, assigned by the constructor in this order:
- `stat`: The stat you want to modify. When `check` is called, this string is used to find all the mods that are to be collected.
- `mod`: The modifier. Defaults are 'add' and 'mult'. Modifiers are calculated additively, and in standard arithmetic order (see `_calculate_mods` for more)
- `value`: How much value the modifier gives regardless of stacks
- `perstack`: How much value the modifier grants per stack, INCLUDING the first. (defualt: 0)
To add a mod to a buff, you do so in the buff definition, like this:
```
def DamageBuff(BaseBuff):
mods = [Mod('damage', 'add', 10)]
```
No mods applied to the value are permanent in any way. All calculations are done at
runtime, and the mod values are never stored anywhere except on the buff in question. In
other words: you don't need to track the origin of particular stat mods, and you
will never permanently change a stat modified by a trait buff. To remove the modification, simply
remove the buff off the object.
### Triggers
Buffs which have one or more strings in the `triggers` attribute can be triggered by events.
When the handler `trigger` method is called, it searches all buffs on the handler for any with a matching
trigger, then calls their `on_trigger` methods. You can tell which trigger is the one it fired with by the `trigger`
argument in the method.
```
def AmplifyBuff(BaseBuff):
triggers = ['damage', 'heal']
def on_trigger(self, trigger, **kwargs):
if trigger == 'damage': print('Damage trigger called!')
if trigger == 'heal': print('Heal trigger called!')
```
### Ticking
A buff with ticking isn't much different than one which triggers. You're still executing arbitrary code off
the buff class. The main thing is you need to have a `tickrate` higher than 1.
```
# this buff will tick 6 times between application and cleanup.
duration = 30
tickrate = 5
def on_tick(self, initial, **kwargs):
self.owner.take_damage(10)
```
It's important to note the buff always ticks once when applied. For this first tick only, `initial` will be True
in the `on_tick` hook method.
### Extras
Buffs have a grab-bag of extra functionality to make your life easier!
You can restrict whether or not the buff will check or trigger through defining the `conditional` hook. As long
as it returns a "truthy" value, the buff apply itself. This is useful for making buffs dependent on game state - for
example, if you want a buff that makes the player take more damage when they are on fire.
```python
def conditional(self, *args, **kwargs):
if self.owner.buffs.get_by_type(FireBuff): return True
return False
```
There are a number of helper methods. If you have a buff instance - for example, because you got the buff with
`handler.get(key)` - you can `pause`, `unpause`, `remove`, `dispel`, and even `lengthen` or `shorten` the duration.
Finally, if your handler has `autopause` enabled, any buffs with truthy `playtime` value will automatically pause
and unpause when the object the handler is attached to is puppetted or unpuppetted.
### How Does It Work?
Buffs are stored in two parts alongside each other in the cache: a reference to the buff class, and as mutable data.
You can technically store any information you like in the cache; by default, it's all the basic timing and event
information necessary for the system to run. When the buff is instanced, this cache is fed to the constructor
When you use the handler to get a buff, you get an instanced version of that buff created from these two parts, or
a dictionary of these buffs in the format of {uid: instance}. Buffs are only instanced as long as is necessary to
run methods on them.
#### Context
You may have noticed that almost every important handler method also passes a `context` dictionary.
Context is an important concept for this handler. Every method which modifies, triggers, or checks a buff passes this
dictionary (default: empty) to the buff hook methods as keyword arguments (**kwargs). This allows you to make those
methods "event-aware" by storing relevant data in the dictionary you feed to the method.
For example, let's say you want a "thorns" buff which damages enemies that attack you. Let's take our `take_damage` method
and add a context to the mix.
```
def take_damage(attacker, damage):
context = {'attacker': attacker, 'damage': damage}
_damage = self.buffs.check(damage, 'taken_damage', context=context)
self.db.health -= _damage
```
Now we use the values that context passes to the buff kwargs to customize our logic.
```
triggers = ['taken_damage']
# This is the hook method on our thorns buff
def on_trigger(self, trigger, attacker=None, damage=0, **kwargs):
attacker.db.health -= damage * 0.2
```
Apply the buff, take damage, and watch the thorns buff do its work!
"""
import time
from evennia.server import signals
from evennia.utils import utils, search
from evennia.typeclasses.attributes import AttributeProperty
class BaseBuff():
key = 'template' # The buff's unique key. Will be used as the buff's key in the handler
name = 'Template' # The buff's name. Used for user messaging
flavor = 'Template' # The buff's flavor text. Used for user messaging
visible = True # If the buff is considered "visible" to the "view" method
triggers = [] # The effect's trigger strings, used for functions.
duration = -1 # Default buff duration; -1 or lower for permanent buff, 0 for an "instant" buff (removed immediately after it is added)
playtime = False # Does this buff pause automatically when the puppet its own is unpuppetted? No effect on objects that won't be puppetted.
refresh = True # Does the buff refresh its timer on application?
unique = True # Does the buff overwrite existing buffs with the same key on the same target?
maxstacks = 1 # The maximum number of stacks the buff can have. If >1, this buff will stack.
tickrate = 0 # How frequent does this buff tick, in seconds (cannot be lower than 1)
mods = [] # List of mod objects. See Mod class below for more detail
@property
def ticknum(self):
'''Returns how many ticks this buff has gone through as an integer.'''
x = (time.time() - self.start) / self.tickrate
return int(x)
@property
def owner(self):
return self.handler.owner
@property
def ticking(self)-> bool:
'''Returns if this buff ticks or not (tickrate => 1)'''
return self.tickrate >= 1
@property
def stacking(self) -> bool:
'''Returns if this buff stacks or not (maxstacks > 1)'''
return self.maxstacks > 1
def __init__(self, handler, uid) -> None:
self.handler: BuffHandler = handler
self.uid = uid
cache:dict = handler.db.get(uid)
self.start = cache.get('start')
self.duration = cache.get('duration')
self.prevtick = cache.get('prevtick')
self.paused = cache.get('paused')
self.stacks = cache.get('stacks')
self.source = cache.get('source')
def conditional(self, *args, **kwargs):
'''Hook function for conditional stat mods. This must return True in
order for a mod to be applied, or a trigger to fire.'''
return True
#region helper methods
def remove(self, loud=True, expire=False, dispel=False, delay=0, context={}):
'''Helper method which removes this buff from its handler.'''
self.handler.remove(self.uid, loud, dispel, delay, context)
def dispel(self, loud=True, dispel=True, delay=0, context={}):
'''Helper method which dispels this buff (removes and calls on_dispel).'''
self.handler.remove(self.uid, loud, dispel, delay, context)
def pause(self):
'''Helper method which pauses this buff on its handler.'''
self.handler.pause(self.uid)
def unpause(self):
'''Helper method which unpauses this buff on its handler.'''
self.handler.unpause(self.uid)
def lengthen(self, value):
'''Helper method which lengthens a buff's timer. Positive = increase'''
self.handler.modify_duration(self.uid, value)
def shorten(self, value):
'''Helper method which shortens a buff's timer. Positive = decrease'''
self.handler.modify_duration(self.uid, -1*value)
#endregion
#region hook methods
def on_apply(self, *args, **kwargs):
'''Hook function to run when this buff is applied to an object.'''
pass
def on_remove(self, *args, **kwargs):
'''Hook function to run when this buff is removed from an object.'''
pass
def on_remove_stack(self, *args, **kwargs):
'''Hook function to run when this buff loses stacks.'''
pass
def on_dispel(self, *args, **kwargs):
'''Hook function to run when this buff is dispelled from an object (removed by someone other than the buff holder).'''
pass
def on_expire(self, *args, **kwargs):
'''Hook function to run when this buff expires from an object.'''
pass
def after_check(self, *args, **kwargs):
'''Hook function to run after this buff's mods are checked.'''
pass
def on_trigger(self, trigger:str, *args, **kwargs):
'''Hook for the code you want to run whenever the effect is triggered.
Passes the trigger string to the function, so you can have multiple
triggers on one buff.'''
pass
def on_tick(self, initial: bool, *args, **kwargs):
'''Hook for actions that occur per-tick, a designer-set sub-duration.
`initial` tells you if it's the first tick that happens (when a buff is applied).'''
pass
#endregion
class Mod():
'''A single stat mod object. One buff or trait can hold multiple mods, for the same or different stats.'''
stat = 'null' # The stat string that is checked to see if this mod should be applied
value = 0 # Buff's value
perstack = 0 # How much additional value is added to the buff per stack
modifier = 'add' # The modifier the buff applies. 'add' or 'mult'
def __init__(self, stat: str, modifier: str, value, perstack=0.0) -> None:
'''
Args:
stat: The stat the buff affects. Normally matches the object attribute name
mod: The modifier the buff applies. "add" for add/sub or "mult" for mult/div
value: The value of the modifier
perstack: How much is added to the base, per stack (including first).'''
self.stat = stat
self.modifier = modifier
self.value = value
self.perstack = perstack
class BuffHandler(object):
ownerref = None
dbkey = "buffs"
autopause = False
def __init__(self, owner, dbkey=dbkey, autopause=autopause):
self.ownerref = owner.dbref
self.dbkey = dbkey
self.autopause = autopause
if autopause:
signals.SIGNAL_OBJECT_POST_UNPUPPET.connect(self.pause_playtime)
signals.SIGNAL_OBJECT_POST_PUPPET.connect(self.unpause_playtime)
def __getattr__(self, key):
if key not in self.db.keys(): raise AttributeError
return self.get(key)
#region properties
@property
def owner(self):
return search.search_object(self.ownerref)[0]
@property
def db(self):
'''The object attribute we use for the buff database. Auto-creates if not present.
Convenience shortcut (equal to self.owner.db.dbkey)'''
if not self.owner.attributes.has(self.dbkey): self.owner.attributes.add(self.dbkey, {})
return self.owner.attributes.get(self.dbkey)
@property
def traits(self):
'''All buffs on this handler that modify a stat.'''
_t = {k:self.get(k) for k,v in self.db.items() if v['ref'].mods}
return _t
@property
def effects(self):
'''All buffs on this handler that trigger off an event.'''
_e = {k:self.get(k) for k,v in self.db.items() if v['ref'].triggers}
return _e
@property
def playtime(self):
'''All buffs on this handler that only count down during active playtime.'''
_pt = {k:self.get(k) for k,v in self.db.items() if v['ref'].playtime}
return _pt
@property
def paused(self):
'''All buffs on this handler that are paused.'''
_p = {k:self.get(k) for k,v in self.db.items() if v['paused'] == True}
return _p
@property
def expired(self):
'''All buffs on this handler that have expired.'''
_e = { k: self.get(k)
for k,v in self.db.items()
if not v['paused']
if v['duration'] > -1
if v['duration'] < time.time() - v['start'] }
return _e
@property
def visible(self):
'''All buffs on this handler that are visible.'''
_v = { k: self.get(k)
for k,v in self.db.items()
if v['ref'].visible }
return _v
@property
def all(self):
'''Returns dictionary of instanced buffs equivalent to ALL buffs on this handler,
regardless of state, type, or anything else. You will only need this to extend
handler functionality. It is otherwise unused.'''
_a = {k:self.get(k) for k,v in self.db.items()}
return _a
#endregion
#region methods
def add(self, buff: BaseBuff, key:str=None,
stacks=1, duration=None, source=None,
context={}, *args, **kwargs
):
'''Add a buff to this object, respecting all stacking/refresh/reapplication rules. Takes
a number of optional parameters to allow for customization.
Args:
buff: The buff class you wish to add
source: (optional) The source of this buff.
stacks: (optional) The number of stacks you want to add, if the buff is stacking
duration: (optional) The amount of time, in seconds, you want the buff to last.
context: (optional) An existing context you want to add buff details to
'''
_context = context
source = self.owner
# Create the buff dict that holds a reference and all runtime information.
b = {
'ref': buff,
'start': time.time(),
'duration': buff.duration,
'prevtick': None,
'paused': False,
'stacks': stacks,
'source': source }
# Generate the pID (procedural ID) from the object's dbref (uID) and buff key.
# This is the actual key the buff uses on the dictionary
uid = key
if not uid:
if source: mix = str(source.dbref).replace("#","")
uid = buff.key if buff.unique is True else buff.key + mix
# If the buff is on the dictionary, we edit existing values for refreshing/stacking
if uid in self.db.keys():
b = dict( self.db[uid] )
if buff.refresh: b['start'] = time.time()
if buff.maxstacks>1: b['stacks'] = min( b['stacks'] + stacks, buff.maxstacks )
# Setting duration and initial tick, if relevant
b['prevtick'] = time.time() if buff.tickrate>=1 else None
if duration: b['duration'] = duration
# Apply the buff!
self.db[uid] = b
# Create the buff instance and run the on-application hook method
instance: BaseBuff = self.get(uid)
instance.on_apply(**_context)
if instance.ticking: tick_buff(self, uid, _context)
# Clean up the buff at the end of its duration through a delayed cleanup call
if b['duration'] > -1: utils.delay( b['duration'], cleanup_buffs, self, persistent=True )
# Apply the buff and pass the Context upwards.
# return _context
def remove(self, buffkey,
loud=True, dispel=False, expire=False,
context={}, *args, **kwargs
):
'''Remove a buff or effect with matching key from this object. Normally calls on_remove,
calls on_expire if the buff expired naturally, and optionally calls on_dispel.
Args:
key: The buff key
loud: Calls on_remove when True. Default remove hook.
dispel: Calls on_dispel when True
expire: Calls on_expire when True. Used when cleaned up.
'''
if buffkey not in self.db: return None
_context = context
buff: BaseBuff = self.db[buffkey]['ref']
instance : BaseBuff = buff(self, buffkey)
if loud:
if dispel: instance.on_dispel(**context)
elif expire: instance.on_expire(**context)
instance.on_remove(**context)
del instance
del self.db[buffkey]
return _context
def remove_by_type(self, bufftype:BaseBuff,
loud=True, dispel=False, expire=False,
context={}, *args, **kwargs
):
'''Removes all buffs of a specified type from this object'''
_remove = self.get_by_type(bufftype)
if not _remove: return None
_context = context
for k,instance in _remove.items():
instance: BaseBuff
if loud:
if dispel: instance.on_dispel(**context)
elif expire: instance.on_expire(**context)
instance.on_remove(**context)
del instance
del self.db[k]
return _context
def get(self, buffkey: str):
'''If the specified key is on this handler, return the instanced buff. Otherwise return None.
You should delete this when you're done with it, so that garbage collection doesn't have to.'''
buff = self.db.get(buffkey)
if buff: return buff["ref"](self, buffkey)
else: return None
def get_by_type(self, buff:BaseBuff):
'''Returns a dictionary of instanced buffs of the specified type in the format {uid: instance}.'''
return {k: self.get(k) for k,v in self.db.items() if v['ref'] == buff}
def get_by_stat(self, stat:str, context={}):
'''Returns a dictionary of instanced buffs which modify the specified stat in the format {uid: instance}.'''
_cache = self.traits
if not _cache: return None
buffs = {k:buff
for k,buff in _cache.items()
for m in buff.mods
if m.stat == stat
if not buff.paused
if buff.conditional(**context)}
return buffs
def get_by_trigger(self, trigger:str, context={}):
'''Returns a dictionary of instanced buffs which fire off the designated trigger, in the format {uid: instance}.'''
_cache = self.effects
return {k:buff
for k,buff in _cache.items()
if trigger in buff.triggers
if not buff.paused
if buff.conditional(**context)}
def check(self, value: float, stat: str, loud=True, context={}):
'''Finds all buffs and perks related to a stat and applies their effects.
Args:
value: The value you intend to modify
stat: The string that designates which stat buffs you want
Returns the value modified by relevant buffs.'''
# Buff cleanup to make sure all buffs are valid before processing
self.cleanup()
# Find all buffs and traits related to the specified stat.
applied = self.get_by_stat(stat, context)
if not applied: return value
# The final result
final = self._calculate_mods(value, stat, applied)
# Run the "after check" functions on all relevant buffs
for buff in applied.values():
buff: BaseBuff
if loud: buff.after_check(**context)
del buff
return final
def trigger(self, trigger: str, context:dict = {}):
'''Activates all perks and effects on the origin that have the same trigger string.
Takes a trigger string and a dictionary that is passed to the buff as kwargs.
'''
self.cleanup()
_effects = self.get_by_trigger(trigger, context)
if _effects is None: return None
# Trigger all buffs whose trigger matches the trigger string
for buff in _effects.values():
buff: BaseBuff
if trigger in buff.triggers and not buff.paused:
buff.on_trigger(trigger, **context)
def pause(self, key: str):
"""Pauses the buff. This excludes it from being checked for mods, triggered, or cleaned up.
Used to make buffs 'playtime' instead of 'realtime'."""
if key in self.db.keys():
# Mark the buff as paused
buff = dict(self.db[key])
if buff['paused']: return
buff['paused'] = True
# Figure out our new duration
t = time.time() # Current Time
s = buff['start'] # Start
d = buff['duration'] # Duration
e = s + d # End
nd = e - t # New duration
# Apply the new duration
if nd > 0:
buff['duration'] = nd
self.db[key] = buff
else: self.remove(key)
return
def unpause(self, key: str):
'''Unpauses a buff. This makes it visible to the various buff systems again.'''
if key in self.db.keys():
# Mark the buff as unpaused
buff = dict(self.db[key])
if not buff['paused']: return
buff['paused'] = False
# Start our new timer
buff['start'] = time.time()
self.db[key] = buff
utils.delay( buff['duration'], cleanup_buffs, self, persistent=True )
return
def pause_playtime(self, sender=owner, **kwargs):
'''Pauses all playtime buffs when attached object is puppeted.'''
if sender != self.owner: return
buffs = self.playtime
for buff in buffs.values(): buff.pause()
def unpause_playtime(self, sender=owner, **kwargs):
'''Unpauses all playtime buffs when attached object is unpuppeted.'''
if sender != self.owner: return
buffs = self.playtime
for buff in buffs.values(): buff.unpause()
pass
def modify_duration(self, key, value, set=False):
'''Modifies the duration of a buff. Normally adds/subtracts; call with "set=True" to set it to the value instead'''
if key in self.db.keys():
if set: self.db[key]['duration'] = value
else: self.db[key]['duration'] += value
def view(self) -> list:
'''Returns a buff flavor text as a dictionary of tuples in the format {key: (name, flavor)}. Common use for this is a buff readout of some kind.'''
self.cleanup()
_flavor = {
k:(buff.name, buff.flavor)
for k, buff in self.visible
}
def cleanup(self):
'''Removes expired buffs, ensures pause state is respected.'''
self.validate_state()
cleanup_buffs(self)
def validate_state(self):
'''Validates the state of paused/unpaused playtime buffs.'''
if not self.autopause: return
if self.owner.has_account: self.unpause_playtime()
elif not self.owner.has_account: self.pause_playtime()
#region private methods
def _calculate_mods(self, value, stat:str, buffs:dict):
'''Calculates a return value from a base value, a stat string, and a dictionary of instanced buffs with associated mods.'''
if not buffs: return value
add = 0
mult = 0
for buff in buffs.values():
for mod in buff.mods:
buff:BaseBuff
mod:Mod
if mod.stat == stat:
if mod.modifier == 'add': add += mod.value + ( (buff.stacks) * mod.perstack)
if mod.modifier == 'mult': mult += mod.value + ( (buff.stacks) * mod.perstack)
final = (value + add) * (1.0 + mult)
return final
#endregion
#endregion
class BuffableProperty(AttributeProperty):
'''An example of a way you can extend AttributeProperty to create properties that automatically check buffs for you.'''
def at_get(self, value, obj):
_value = obj.buffs.check(value, self._key)
return _value
def cleanup_buffs(handler: BuffHandler):
'''Cleans up all expired buffs from a handler.'''
_remove = handler.expired
for v in _remove.values(): v.remove(expire=True)
def tick_buff(handler: BuffHandler, uid: str, context={}, initial=True):
'''Ticks a buff. If a buff's tickrate is 1 or larger, this is called when the buff is applied, and then once per tick cycle.'''
# Cache a reference and find the buff on the object
if uid not in handler.db.keys(): return
# Instantiate the buff and tickrate
buff: BaseBuff = handler.get(uid)
tr = buff.tickrate
# This stops the old ticking process if you refresh/stack the buff
if tr > time.time() - buff.prevtick and initial != True: return
# Always tick this buff on initial
if initial: buff.on_tick(initial, **context)
# Tick this buff one last time, then remove
if buff.duration <= time.time() - buff.start:
if tr < time.time() - buff.prevtick: buff.on_tick(initial, **context)
buff.remove(expire=True)
return
# Tick this buff on-time
if tr <= time.time() - buff.prevtick: buff.on_tick(initial, **context)
handler.db[uid]['prevtick'] = time.time()
# Recur this function at the tickrate interval, if it didn't stop/fail
utils.delay(tr, tick_buff, handler=handler, uid=uid, context=context, initial=False, persistent=True)

View file

@ -0,0 +1,275 @@
# in a module tests.py somewhere i your game dir
from unittest.mock import Mock, patch
from evennia import DefaultObject, create_object
from evennia.utils import create
from evennia.utils.utils import lazy_property
# the function we want to test
from .buff import BaseBuff, Mod, BuffHandler, BuffableProperty
from evennia.utils.test_resources import EvenniaTest
class _EmptyBuff(BaseBuff):
pass
class _TestModBuff(BaseBuff):
key = 'tmb'
name = 'tmb'
flavor = 'modderbuff'
maxstacks = 5
mods = [
Mod('stat1', 'add', 10, 5),
Mod('stat2', 'mult', 0.5)
]
class _TestModBuff2(BaseBuff):
key = 'tmb2'
name = 'tmb2'
flavor = 'modderbuff2'
maxstacks = 1
mods = [
Mod('stat1', 'mult', 1.0),
Mod('stat1', 'add', 10)
]
class _TestTrigBuff(BaseBuff):
key = 'ttb'
name = 'ttb'
flavor = 'triggerbuff'
triggers = ['test1', 'test2']
def on_trigger(self, trigger: str, *args, **kwargs):
if trigger == 'test1': self.owner.db.triggertest1 = True
if trigger == 'test2': self.owner.db.triggertest2 = True
class _TestConBuff(BaseBuff):
key = 'tcb'
name = 'tcb'
flavor = 'condbuff'
triggers = ['condtest']
def conditional(self, *args, **kwargs):
return self.owner.db.cond1
def on_trigger(self, trigger: str, attacker=None, defender=None, damage=0, *args, **kwargs):
defender.db.att, defender.db.dmg = attacker, damage
class _TestComplexBuff(BaseBuff):
key = 'tcomb'
name = 'complex'
flavor = 'combuff'
triggers = ['comtest', 'complextest']
mods = [
Mod('com1', 'add', 0, 10),
Mod('com1', 'add', 15),
Mod('com1', 'mult', 2.0),
Mod('com2', 'add', 100)
]
def conditional(self, cond=False, *args, **kwargs):
return not cond
def on_trigger(self, trigger: str, *args, **kwargs):
if trigger == 'comtest': self.owner.db.comtext = {'cond': True}
else: self.owner.db.comtext = {}
class _TestTimeBuff(BaseBuff):
key = 'ttib'
name = 'ttib'
flavor = 'timerbuff'
maxstacks = 1
tickrate = 1
duration = 5
mods = [Mod('timetest', 'add', 665)]
def on_tick(self, initial=True, *args, **kwargs):
self.owner.db.ticktest = True
class BuffableObject(DefaultObject):
stat1 = BuffableProperty(10)
@lazy_property
def buffs(self) -> BuffHandler:
return BuffHandler(self)
def at_init(self):
self.stat1, self.buffs
return super().at_init()
class TestBuffsAndHandler(EvenniaTest):
"This tests a number of things about buffs."
def setUp(self):
super().setUp()
self.obj1.handler = BuffHandler(self.obj1, 'buffs')
def tearDown(self):
"""done after every test_* method below """
super().tearDown()
def test_addremove(self):
'''tests adding and removing buffs'''
# setup
handler: BuffHandler = self.obj1.handler
# add
handler.add(_TestModBuff)
self.assertEqual( self.obj1.db.buffs['tmb']['ref'], _TestModBuff)
# remove
handler.remove('tmb')
self.assertEqual( self.obj1.db.buffs.get('tmb'), None)
# remove by type
handler.add(_TestModBuff)
handler.remove_by_type(_TestModBuff)
self.assertEqual( self.obj1.db.buffs.get('tmb'), None)
# remove by buff instance
handler.add(_TestModBuff)
handler.all['tmb'].remove()
self.assertEqual( self.obj1.db.buffs.get('tmb'), None)
def test_getters(self):
'''tests all built-in getters'''
# setup
handler: BuffHandler = self.obj1.handler
handler.add(_TestModBuff)
handler.add(_TestTrigBuff)
# normal getters
self.assertEqual(isinstance(handler.tmb, _TestModBuff), True)
self.assertEqual(isinstance(handler.get('tmb'),_TestModBuff), True)
# stat getters
self.assertEqual(isinstance(handler.get_by_stat('stat1')['tmb'], _TestModBuff), True)
self.assertEqual(handler.get_by_stat('nullstat'), {})
# trigger getters
self.assertEqual('ttb' in handler.get_by_trigger('test1').keys(), True)
self.assertEqual('ttb' in handler.get_by_trigger('nulltrig').keys(), False)
# type getters
self.assertEqual('tmb' in handler.get_by_type(_TestModBuff), True)
self.assertEqual('tmb' in handler.get_by_type(_EmptyBuff), False)
def test_details(self):
'''tests that buff details like name and flavor are correct'''
handler: BuffHandler = self.obj1.handler
handler.add(_TestModBuff)
handler.add(_TestTrigBuff)
self.assertEqual(handler.tmb.flavor, 'modderbuff')
self.assertEqual(handler.ttb.name, 'ttb')
def test_modify(self):
'''tests to ensure that values are modified correctly, and stack across mods'''
# setup
handler: BuffHandler = self.obj1.handler
_stat1, _stat2 = 0, 10
handler.add(_TestModBuff)
# stat1 and 2 basic mods
self.assertEqual(handler.check(_stat1, 'stat1'), 15)
self.assertEqual(handler.check(_stat2, 'stat2'), 15)
# checks can take any base value
self.assertEqual(handler.check(_stat1, 'stat2'), 0)
self.assertEqual(handler.check(_stat2, 'stat1'), 25)
# change to base stat reflected in check
_stat1 += 5
self.assertEqual(handler.check(_stat1, 'stat1'), 20)
_stat2 += 10
self.assertEqual(handler.check(_stat2, 'stat2'), 30)
# test stacking; single stack, multiple stack, max stacks
handler.add(_TestModBuff)
self.assertEqual(handler.check(_stat1, 'stat1'), 25)
handler.add(_TestModBuff, stacks=3)
self.assertEqual(handler.check(_stat1, 'stat1'), 40)
handler.add(_TestModBuff, stacks=5)
self.assertEqual(handler.check(_stat1, 'stat1'), 40)
# stat2 mod doesn't stack
self.assertEqual(handler.check(_stat2, 'stat2'), 30)
# layers with second mod
handler.add(_TestModBuff2)
self.assertEqual(handler.check(_stat1, 'stat1'), 100)
self.assertEqual(handler.check(_stat2, 'stat2'), 30)
handler.remove_by_type(_TestModBuff)
self.assertEqual(handler.check(_stat1, 'stat1'), 30)
self.assertEqual(handler.check(_stat2, 'stat2'), 20)
def test_trigger(self):
'''tests to ensure triggers correctly fire'''
# setup
handler: BuffHandler = self.obj1.handler
handler.add(_TestTrigBuff)
# trigger buffs
handler.trigger('nulltest')
self.assertEqual(self.obj1.db.triggertest1, None)
self.assertEqual(self.obj1.db.triggertest2, None)
handler.trigger('test1')
self.assertEqual(self.obj1.db.triggertest1, True)
self.assertEqual(self.obj1.db.triggertest2, None)
handler.trigger('test2')
self.assertEqual(self.obj1.db.triggertest1, True)
self.assertEqual(self.obj1.db.triggertest2, True)
def test_context_conditional(self):
'''tests to ensure context is passed to buffs, and also tests conditionals'''
# setup
handler: BuffHandler = self.obj1.handler
handler.add(_TestConBuff)
self.obj1.db.cond1, self.obj1.db.att, self.obj1.db.dmg = False, None, 0
# context to pass, containing basic event data and a little extra to be ignored
_testcontext = {'attacker':self.obj2, 'defender':self.obj1, 'damage':5, 'overflow':10}
# test negative conditional
self.assertEqual(handler.get_by_type(_TestConBuff)['tcb'].conditional(**_testcontext), False)
handler.trigger('condtest', _testcontext)
self.assertEqual(self.obj1.db.att, None)
self.assertEqual(self.obj1.db.dmg, 0)
# test positive conditional + context passing
self.obj1.db.cond1 = True
self.assertEqual(handler.get_by_type(_TestConBuff)['tcb'].conditional(**_testcontext), True)
handler.trigger('condtest', _testcontext)
self.assertEqual(self.obj1.db.att, self.obj2)
self.assertEqual(self.obj1.db.dmg, 5)
def test_complex(self):
'''tests a complex mod (conditionals, multiple triggers/mods)'''
# setup
handler: BuffHandler = self.obj1.handler
self.obj1.db.comone, self.obj1.db.comtwo, self.obj1.db.comtext = 10, 0, {}
handler.add(_TestComplexBuff)
# stat checks work correctly and separately
self.assertEqual(self.obj1.db.comtext, {})
self.assertEqual(handler.check(self.obj1.db.comone, 'com1'), 105)
self.assertEqual(handler.check(self.obj1.db.comtwo, 'com2'), 100)
# stat checks don't happen if the conditional is true
handler.trigger('comtest', self.obj1.db.comtext)
self.assertEqual(self.obj1.db.comtext, {'cond': True})
self.assertEqual(handler.get_by_type(_TestComplexBuff)['tcomb'].conditional(**self.obj1.db.comtext), False)
self.assertEqual(handler.check(self.obj1.db.comone, 'com1', context=self.obj1.db.comtext), 10)
self.assertEqual(handler.check(self.obj1.db.comtwo, 'com2', context=self.obj1.db.comtext), 0)
handler.trigger('complextest', self.obj1.db.comtext)
self.assertEqual(handler.check(self.obj1.db.comone, 'com1', context=self.obj1.db.comtext), 10)
self.assertEqual(handler.check(self.obj1.db.comtwo, 'com2', context=self.obj1.db.comtext), 0)
# separate trigger follows different codepath
self.obj1.db.comtext = {'cond': False}
handler.trigger('complextest', self.obj1.db.comtext)
self.assertEqual(self.obj1.db.comtext, {})
self.assertEqual(handler.check(self.obj1.db.comone, 'com1', context=self.obj1.db.comtext), 105)
self.assertEqual(handler.check(self.obj1.db.comtwo, 'com2', context=self.obj1.db.comtext), 100)
def test_timing(self):
'''tests timing-related features, such as ticking and duration'''
# setup
handler: BuffHandler = self.obj1.handler
handler.add(_TestTimeBuff)
self.obj1.db.timetest, self.obj1.db.ticktest = 1, False
# test duration and ticking
self.assertTrue(handler.ttib.ticking)
self.assertEqual(handler.get('ttib').duration, 5)
handler.get('ttib').on_tick()
self.assertTrue(self.obj1.db.ticktest)
# test duration modification and cleanup
handler.modify_duration('ttib', 0, set=True)
self.assertEqual(handler.get('ttib').duration, 0)
handler.cleanup()
self.assertFalse(handler.get('ttib'), None)
def test_buffableproperty(self):
'''tests buffable properties'''
# setup
self.propobj = create.create_object(BuffableObject, key='testobj')
self.propobj.buffs.add(_TestModBuff)
self.assertEqual(self.propobj.stat1, 25)
self.propobj.buffs.remove('tmb')
self.assertEqual(self.propobj.stat1, 10)