mirror of
https://github.com/evennia/evennia.git
synced 2026-04-01 05:27:17 +02:00
initial buff contrib commit
This commit is contained in:
parent
8a9ccb4bbe
commit
f340d4c6e4
3 changed files with 1043 additions and 0 deletions
0
evennia/contrib/game_systems/buffs/__init__.py
Normal file
0
evennia/contrib/game_systems/buffs/__init__.py
Normal file
768
evennia/contrib/game_systems/buffs/buff.py
Normal file
768
evennia/contrib/game_systems/buffs/buff.py
Normal 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)
|
||||
275
evennia/contrib/game_systems/buffs/tests.py
Normal file
275
evennia/contrib/game_systems/buffs/tests.py
Normal 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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue