diff --git a/evennia/contrib/game_systems/buffs/buff.py b/evennia/contrib/game_systems/buffs/buff.py index 254ca5e990..b931f0284b 100644 --- a/evennia/contrib/game_systems/buffs/buff.py +++ b/evennia/contrib/game_systems/buffs/buff.py @@ -44,16 +44,19 @@ 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. +optional arguments to customize the buff's duration, stacks, and so on. You can also store any arbitrary value +in the buff's cache by passing a dictionary through the `to_cache` argument. This will not overwrite the normal +values on the cache. ```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 +self.buffs.add(StrengthBuff) # A single stack of StrengthBuff with normal duration +self.buffs.add(DexBuff, stacks=3, duration=60) # Three stacks of DexBuff, with a duration of 60 seconds +self.buffs.add(ReflectBuff, to_cache={'reflect': 0.5}) # A single stack of ReflectBuff, with an extra cache value ``` 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) +- `unique` (default: True) determines if the buff uses the buff's normal key (True) or one created with the key and the applier's dbref (False) #### Modify @@ -73,14 +76,14 @@ def take_damage(self, source, 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. +will call the `at_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) +def at_trigger(self, trigger, *args, **kwargs) self.owner.take_damage(100) ``` @@ -92,7 +95,7 @@ Ticking buffs are slightly special. They are similar to trigger buffs in that th 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` +All you need to do to make a buff tick is ensure the `tickrate` is 1 or higher, and it has code in its `at_tick` method. Once you add it to the handler, it starts ticking! ## Buffs @@ -121,10 +124,10 @@ Mod objects consist of only four values, assigned by the constructor in this ord - `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) +- `perstack`: How much value the modifier grants per stack, INCLUDING the first. (default: 0) To add a mod to a buff, you do so in the buff definition, like this: -``` +```python def DamageBuff(BaseBuff): mods = [Mod('damage', 'add', 10)] ``` @@ -140,14 +143,14 @@ remove the buff off the object. 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` +trigger, then calls their `at_trigger` methods. You can tell which trigger is the one it fired with by the `trigger` argument in the method. -``` +```python def AmplifyBuff(BaseBuff): triggers = ['damage', 'heal'] - def on_trigger(self, trigger, **kwargs): + def at_trigger(self, trigger, **kwargs): if trigger == 'damage': print('Damage trigger called!') if trigger == 'heal': print('Heal trigger called!') ``` @@ -156,22 +159,22 @@ def AmplifyBuff(BaseBuff): 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. -``` +```python # this buff will tick 6 times between application and cleanup. duration = 30 tickrate = 5 -def on_tick(self, initial, **kwargs): +def at_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. +in the `at_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 +as it returns a "truthy" value, the buff will 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 @@ -181,10 +184,14 @@ def conditional(self, *args, **kwargs): ``` 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. +`handler.get(key)` - you can `pause`, `unpause`, `remove`, `dispel`, etc. 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. +and unpause when the object the handler is attached to is puppetted or unpuppetted. This even works with ticking buffs, +although if you have less than 1 second of tick duration remaining, it will round up to 1s. + +If you want more control over this process, you can comment out the signal subscriptions on the handler and move the autopause logic +to your object's at_pre/post_puppet/unpuppet hooks. ### How Does It Work? @@ -193,15 +200,15 @@ You can technically store any information you like in the cache; by default, it' 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 +a dictionary of these buffs in the format of {buffkey: instance}. Buffs are only instanced as long as is necessary to run methods on them. -#### Context +## Context -You may have noticed that almost every important handler method also passes a `context` dictionary. +You may have noticed that almost every important handler method optionally accepts 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 +dictionary (default: empty) to the buff hook methods as keyword arguments (**kwargs). It is used for nothing else. 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 @@ -211,401 +218,627 @@ 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.buffs.trigger('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): +def at_trigger(self, trigger, attacker=None, damage=0, **kwargs): + if not attacker: return attacker.db.health -= damage * 0.2 ``` Apply the buff, take damage, and watch the thorns buff do its work! +""" -""" - +from random import random 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. +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 - 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. + triggers = [] # The effect's trigger strings, used for functions. - 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 + handler = None + start = 0 + # Default buff duration; -1 or lower for permanent, 0 for "instant" (removed immediately) + duration = -1 + + playtime = False # Does this buff autopause when owning object is unpuppeted? + + 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. + stacks = 1 # If >1, used as the default when applying this buff + 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 + cache = {} @property def ticknum(self): - '''Returns how many ticks this buff has gone through as an integer.''' + """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 this buff's owner (the object its handler is attached to)""" + if not self.handler: + return None return self.handler.owner @property - def ticking(self)-> bool: - '''Returns if this buff ticks or not (tickrate => 1)''' + 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)''' + """Returns if this buff stacks or not (maxstacks > 1)""" return self.maxstacks > 1 - def __init__(self, handler, uid) -> None: + def __init__(self, handler, buffkey, cache) -> None: + """ + Args: + handler: The handler this buff is attached to + buffkey: The key this buff uses on the cache + cache: The cache dictionary (what you get if you use `handler.buffcache.get(key)`)""" 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') + self.buffkey = buffkey + # Cache assignment + self.cache = cache + # Default system cache values + self.start = self.cache.get("start") + self.duration = self.cache.get("duration") + self.prevtick = self.cache.get("prevtick") + self.paused = self.cache.get("paused") + self.stacks = self.cache.get("stacks") + self.source = self.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.''' + """Hook function for conditional evaluation. + + This must return True for a buff to apply modifiers, trigger effects, or tick.""" 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) + # region helper methods + def remove(self, loud=True, expire=False, context=None): + """Helper method which removes this buff from its handler. Use dispel if you are dispelling it instead. - def pause(self): - '''Helper method which pauses this buff on its handler.''' - self.handler.pause(self.uid) + Args: + loud: (optional) Whether to call at_remove or not (default: True) + expire: (optional) Whether to call at_expire or not (default: False) + delay: (optional) How long you want to delay the remove call for + context: (optional) A dictionary you wish to pass to the at_remove/at_expire method as kwargs""" + if not context: + context = {} + self.handler.remove(self.buffkey, loud=loud, expire=expire, context=context) - def unpause(self): - '''Helper method which unpauses this buff on its handler.''' - self.handler.unpause(self.uid) + def dispel(self, loud=True, delay=0, context=None): + """Helper method which dispels this buff (removes and calls at_dispel). - def lengthen(self, value): - '''Helper method which lengthens a buff's timer. Positive = increase''' - self.handler.modify_duration(self.uid, value) + Args: + loud: (optional) Whether to call at_remove or not (default: True) + delay: (optional) How long you want to delay the remove call for + context: (optional) A dictionary you wish to pass to the at_remove/at_dispel method as kwargs""" + if not context: + context = {} + self.handler.remove(self.buffkey, loud=loud, dispel=True, delay=delay, context=context) - 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.''' + def pause(self, context=None): + """Helper method which pauses this buff on its handler. + + Args: + context: (optional) A dictionary you wish to pass to the at_pause method as kwargs""" + if not context: + context = {} + self.handler.pause(self.buffkey, context) + + def unpause(self, context=None): + """Helper method which unpauses this buff on its handler. + + Args: + context: (optional) A dictionary you wish to pass to the at_unpause method as kwargs""" + if not context: + context = {} + self.handler.unpause(self.buffkey, context) + + def reset(self): + """Resets the buff start time as though it were just applied; functionally identical to a refresh""" + self.handler.buffcache[self.buffkey]["start"] = time.time() + + # endregion + + # region hook methods + def at_apply(self, *args, **kwargs): + """Hook function to run when this buff is applied to an object.""" pass - def on_remove_stack(self, *args, **kwargs): - '''Hook function to run when this buff loses stacks.''' + def at_remove(self, *args, **kwargs): + """Hook function to run when this buff is removed from an object.""" 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).''' + def at_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.''' + def at_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.''' + def at_pre_check(self, *args, **kwargs): + """Hook function to run before this buff's modifiers are checked.""" pass - def on_trigger(self, trigger:str, *args, **kwargs): - '''Hook for the code you want to run whenever the effect is triggered. + def at_post_check(self, *args, **kwargs): + """Hook function to run after this buff's mods are checked.""" + pass + + def at_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.''' + 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).''' + def at_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 at_pause(self, *args, **kwargs): + """Hook for when this buff is paused""" + pass + + def at_unpause(self, *args, **kwargs): + """Hook for when this buff is unpaused.""" + 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 + 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).''' + 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 - + _owner = None + def __init__(self, owner, dbkey=dbkey, autopause=autopause): + """ + Args: + owner: The object this handler is attached to + dbkey: (optional) The string key of the db attribute to use for the buff cache + autopause: (optional) Whether this handler autopauses playtime buffs on owning object's unpuppet""" 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) + 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 + # region properties @property def owner(self): - return search.search_object(self.ownerref)[0] + """The object this handler is attached to.""" + if self.ownerref: + _owner = search.search_object(self.ownerref) + if _owner: + return _owner[0] + else: + return None @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, {}) + def buffcache(self): + """The object attribute we use for the buff cache. Auto-creates if not present.""" + if not self.owner: + return {} + 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} + """All buffs on this handler that modify a stat.""" + _t = {k: buff for k, buff in self.get_all().items() if buff.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} + """All buffs on this handler that trigger off an event.""" + _e = {k: buff for k, buff in self.get_all().items() if buff.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} + """All buffs on this handler that only count down during active playtime.""" + _pt = {k: buff for k, buff in self.get_all().items() if buff.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} + """All buffs on this handler that are paused.""" + _p = {k: buff for k, buff in self.get_all().items() if buff.paused} 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'] } + """All buffs on this handler that have expired (no duration or no stacks).""" + _cache = self.all + _e = { + k: buff + for k, buff in _cache.items() + if not buff.paused + if buff.duration > -1 + if buff.duration < time.time() - buff.start + } + _nostacks = {k: buff for k, buff in _cache.items() if buff.stacks <= 0} + _e.update(_nostacks) 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 } + """All buffs on this handler that are visible.""" + _v = {k: buff for k, buff in self.get_all().items() if buff.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()} + """Returns dictionary of instanced buffs equivalent to ALL buffs on this handler, + regardless of state, type, or anything else.""" + _a = self.get_all() 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 + + # endregion + + # region methods + def add( + self, + buff: BaseBuff, + key: str = None, + stacks=0, + duration=None, + source=None, + to_cache=None, + context=None, + *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. + buff: The buff class type you wish to add + key: (optional) The key you wish to use for this buff; overrides defaults 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 + duration: (optional) The amount of time, in seconds, you want the buff to last; overrides defaults + source: (optional) The source of this buff. (default: None) + to_cache: (optional) A dictionary to store in the buff's cache; does not overwrite default cache keys + context: (optional) A dictionary you wish to pass to the at_apply method as kwargs + """ + if not isinstance(buff, type): + raise ValueError + if not context: + context = {} + b = {} + _context = dict(context) + if to_cache: + b = dict(to_cache) + if stacks < 1: + stacks = min(1, buff.stacks) # 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 } + b.update( + { + "ref": buff, + "start": time.time(), + "duration": buff.duration, + "prevtick": time.time(), + "paused": False, + "stacks": stacks, + "source": source, + } + ) - # Generate the pID (procedural ID) from the object's dbref (uID) and buff key. + # Generate the buffkey from the object's dbref and the default 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 + buffkey = key + if not buffkey: + if source: + mix = str(source.dbref).replace("#", "") + elif not (buff.unique or buff.refresh) or not source: + mix = str(random() * 10000) + buffkey = buff.key if buff.unique is True else buff.key + mix + + # Rules for applying over an existing buff + if buffkey in self.buffcache.keys(): + existing = dict(self.buffcache[buffkey]) + # Stacking + if buff.maxstacks > 1: + b["stacks"] = min(existing["stacks"] + stacks, buff.maxstacks) + elif buff.maxstacks < 1: + b["stacks"] = existing["stacks"] + stacks + # Carrying over old arbitrary cache values + cur_cache = {k: v for k, v in existing.items() if k not in b.keys()} + b.update(cur_cache) + # Setting overloaded duration + if duration: + b["duration"] = duration # Apply the buff! - self.db[uid] = b + self.buffcache[buffkey] = 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) - + instance: BaseBuff = buff(self, buffkey, b) + instance.at_apply(**_context) + if instance.ticking: + tick_buff(self, buffkey, _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 ) + if b["duration"] > -1: + utils.delay(b["duration"], self.cleanup, persistent=True) - # Apply the buff and pass the Context upwards. - # return _context + def remove(self, key, stacks=0, loud=True, dispel=False, expire=False, context=None): + """Remove a buff or effect with matching key from this object. Normally calls at_remove, + calls at_expire if the buff expired naturally, and optionally calls at_dispel. Can also + remove stacks instead of the entire buff (still calls at_remove). Typically called via a helper method + on the buff instance, or other methods on the handler. - 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. -''' + key: The buff key + loud: (optional) Calls at_remove when True. (default: True) + dispel: (optional) Calls at_dispel when True. (default: False) + expire: (optional) Calls at_expire when True. (default: False) + context: (optional) A dictionary you wish to pass to the at_remove/at_dispel/at_expire method as kwargs + """ + if not context: + context = {} + if key not in self.buffcache: + return None + + buff: BaseBuff = self.buffcache[key] + instance: BaseBuff = buff["ref"](self, key, buff) - 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) + if dispel: + instance.at_dispel(**context) + elif expire: + instance.at_expire(**context) + instance.at_remove(**context) del instance - del self.db[buffkey] + if not stacks: + del self.buffcache[key] + elif stacks: + self.buffcache[key]["stacks"] -= stacks - 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''' + def remove_by_type( + self, + bufftype: BaseBuff, + loud=True, + dispel=False, + expire=False, + context=None, + ): + """Removes all buffs of a specified type from this object. Functionally similar to remove, but takes a type instead. + + Args: + bufftype: The buff class to remove + loud: (optional) Calls at_remove when True. (default: True) + dispel: (optional) Calls at_dispel when True. (default: False) + expire: (optional) Calls at_expire when True. (default: False) + context: (optional) A dictionary you wish to pass to the at_remove/at_dispel/at_expire method as kwargs + """ _remove = self.get_by_type(bufftype) - if not _remove: return None + if not _remove: + return None + self._remove_via_dict(_remove, loud, dispel, expire, context) - _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] + def remove_by_source( + self, + source, + loud=True, + dispel=False, + expire=False, + context=None, + ): + """Removes all buffs from the specified source from this object. Functionally similar to remove, but takes a source instead. - 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 + Args: + source: The source to search for + loud: (optional) Calls at_remove when True. (default: True) + dispel: (optional) Calls at_dispel when True. (default: False) + expire: (optional) Calls at_expire when True. (default: False) + context: (optional) A dictionary you wish to pass to the at_remove/at_dispel/at_expire method as kwargs + """ + _remove = self.get_by_source(source) + if not _remove: + return None + self._remove_via_dict(_remove, loud, dispel, expire, context) - 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 remove_by_cachevalue( + self, + key, + value=None, + loud=True, + dispel=False, + expire=False, + context=None, + ): + """Removes all buffs with the cachevalue from this object. Functionally similar to remove, but checks the buff's cache values instead. - 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 + Args: + key: The key of the cache value to check + value: (optional) The value to match to. If None, merely checks to see if the value exists + loud: (optional) Calls at_remove when True. (default: True) + dispel: (optional) Calls at_dispel when True. (default: False) + expire: (optional) Calls at_expire when True. (default: False) + context: (optional) A dictionary you wish to pass to the at_remove/at_dispel/at_expire method as kwargs + """ + _remove = self.get_by_cachevalue(key, value) + if not _remove: + return None + self._remove_via_dict(_remove, loud, dispel, expire, context) - 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)} + def clear(self, loud=True, dispel=False, expire=False, context=None): + """Removes all buffs on this handler""" + cache = self.all + self._remove_via_dict(cache, loud, dispel, expire, context) + + def get(self, key: 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. + + Args: + key: The key for the buff you wish to get""" + buff = self.buffcache.get(key) + if buff: + return buff["ref"](self, key, buff) + else: + return None + + def get_all(self): + """Returns a dictionary of instanced buffs (all of them) on this handler in the format {buffkey: instance}""" + _cache = dict(self.buffcache) + return {k: buff["ref"](self, k, buff) for k, buff in _cache.items()} + + def get_by_type(self, buff: BaseBuff, to_filter=None): + """Finds all buffs matching the given type. + + Args: + buff: The buff class to search for + to_filter: (optional) A dictionary you wish to slice. If not provided, uses the whole buffcache. + + Returns a dictionary of instanced buffs of the specified type in the format {buffkey: instance}.""" + _cache = self.get_all() if not to_filter else to_filter + return {k: _buff for k, _buff in _cache.items() if isinstance(_buff, buff)} + + def get_by_stat(self, stat: str, context=None, to_filter=None): + """Finds all buffs which contain a Mod object that modifies the specified stat. + + Args: + stat: The string identifier to find relevant mods + context: (optional) A dictionary you wish to pass to the conditional method as kwargs + to_filter: (optional) A dictionary you wish to slice. If not provided, uses the whole buffcache. + + Returns a dictionary of instanced buffs which modify the specified stat in the format {buffkey: instance}.""" + _cache = self.traits if not to_filter else to_filter + if not _cache: + return None + if not context: + context = {} + + 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() + def get_by_trigger(self, trigger: str, context=None, to_filter=None): + """Finds all buffs with the matching string in their triggers. + + Args: + trigger: The string identifier to find relevant buffs + context: (optional) A dictionary you wish to pass to the conditional method as kwargs + to_filter: (optional) A dictionary you wish to slice. If not provided, uses the whole buffcache. + + Returns a dictionary of instanced buffs which fire off the designated trigger, in the format {buffkey: instance}.""" + _cache = self.effects if not to_filter else to_filter + if not context: + context = {} + buffs = { + k: buff + for k, buff in _cache.items() if trigger in buff.triggers if not buff.paused - if buff.conditional(**context)} + if buff.conditional(**context) + } + return buffs + + def get_by_source(self, source, to_filter=None): + """Find all buffs with the matching source. - 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.''' + source: The source you want to filter buffs by + to_filter: (optional) A dictionary you wish to slice. If not provided, uses the whole buffcache. + + Returns a dictionary of instanced buffs which came from the provided source, in the format {buffkey: instance}.""" + _cache = self.all if not to_filter else to_filter + buffs = {k: buff for k, buff in _cache.items() if buff.source == source} + return buffs + + def get_by_cachevalue(self, key, value=None, to_filter=None): + """Find all buffs with a matching {key: value} pair in its cache. Allows you to search buffs by arbitrary cache values + + Args: + key: The key of the cache value to check + value: (optional) The value to match to. If None, merely checks to see if the value exists + to_filter: (optional) A dictionary you wish to slice. If not provided, uses the whole buffcache. + + Returns a dictionary of instanced buffs with cache values matching the specified value, in the format {buffkey: instance}.""" + _cache = self.all if not to_filter else to_filter + if not value: + buffs = {k: buff for k, buff in _cache.items() if buff.cache.get(key)} + elif value: + buffs = {k: buff for k, buff in _cache.items() if buff.cache.get(key) == value} + return buffs + + def check(self, value: float, stat: str, loud=True, context=None, trigger=False): + """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 + loud: (optional) Call the buff's at_post_check method after checking (default: True) + context: (optional) A dictionary you wish to pass to the at_pre_check/at_post_check methods as kwargs + trigger: (optional) Trigger buffs with the `stat` string as well. (default: False) + + 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. + if not context: + context = {} applied = self.get_by_stat(stat, context) - if not applied: return value + if not applied: + return value + for buff in applied.values(): + buff.at_pre_check(**context) # The final result final = self._calculate_mods(value, stat, applied) @@ -613,156 +846,263 @@ class BuffHandler(object): # Run the "after check" functions on all relevant buffs for buff in applied.values(): buff: BaseBuff - if loud: buff.after_check(**context) + if loud: + buff.at_post_check(**context) del buff + + # If you want to, also trigger buffs with the same stat string + if trigger: + self.trigger(stat, context) + 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. - ''' + + def trigger(self, trigger: str, context: dict = None): + """Calls the at_trigger method on all buffs with the matching trigger. + + Args: + trigger: The string identifier to find relevant buffs. Passed to the at_trigger method. + context: (optional) A dictionary you wish to pass to the at_trigger method as kwargs + """ self.cleanup() _effects = self.get_by_trigger(trigger, context) - if _effects is None: return None + if _effects is None: + return None + if not context: + context = {} # 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 + buff.at_trigger(trigger, **context) - # 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 + def pause(self, key: str, context=None): + """Pauses the buff. This excludes it from being checked for mods, triggered, or cleaned up. Used to make buffs 'playtime' instead of 'realtime'. + + Args: + key: The key for the buff you wish to pause + context: (optional) A dictionary you wish to pass to the at_pause method as kwargs + """ + if key in self.buffcache.keys(): + # Mark the buff as paused + buff = dict(self.buffcache[key]) + if buff["paused"]: + return + if not context: + context = {} + buff["paused"] = True + + # Math assignments + current = time.time() # Current Time + start = buff["start"] # Start + duration = buff["duration"] # Duration + prevtick = buff["prevtick"] # Previous tick timestamp + tickrate = buff["ref"].tickrate # Buff's tick rate + + # Original buff ending, and new duration + end = start + duration # End + newduration = end - current # New duration # Apply the new duration - if nd > 0: - buff['duration'] = nd - self.db[key] = buff - else: self.remove(key) + if newduration > 0: + buff["duration"] = newduration + if buff["ref"].ticking: + buff["tickleft"] = max(1, tickrate - (current - prevtick)) + self.buffcache[key] = buff + instance: BaseBuff = buff["ref"](self, key, buff) + instance.at_pause(**context) + 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(): + def unpause(self, key: str, context=None): + """Unpauses a buff. This makes it visible to the various buff systems again. + + Args: + key: The key for the buff you wish to pause + context: (optional) A dictionary you wish to pass to the at_unpause method as kwargs""" + if key in self.buffcache.keys(): # Mark the buff as unpaused - buff = dict(self.db[key]) - if not buff['paused']: return - buff['paused'] = False + buff = dict(self.buffcache[key]) + if not buff["paused"]: + return + if not context: + context = {} + buff["paused"] = False - # Start our new timer - buff['start'] = time.time() - self.db[key] = buff - utils.delay( buff['duration'], cleanup_buffs, self, persistent=True ) + # Math assignments + tickrate = buff["ref"].tickrate + if buff["ref"].ticking: + tickleft = buff["tickleft"] + current = time.time() # Current Time + + # Start our new timer, adjust prevtick + buff["start"] = current + if buff["ref"].ticking: + buff["prevtick"] = current - (tickrate - tickleft) + self.buffcache[key] = buff + instance: BaseBuff = buff["ref"](self, key, buff) + instance.at_unpause(**context) + utils.delay(buff["duration"], cleanup_buffs, self, persistent=True) + if buff["ref"].ticking: + utils.delay( + tickrate, tick_buff, handler=self, buffkey=key, initial=False, 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 set_duration(self, key, value): + """Sets the duration of the specified buff. - 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 + Args: + key: The key of the buff whose duration you want to set + value: The value you want the new duration to be""" + if key in self.buffcache.keys(): + self.buffcache[key]["duration"] = value - 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.''' + def view(self) -> dict: + """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 - } + _flavor = {k: (buff.name, buff.flavor) for k, buff in self.visible} + + return _flavor def cleanup(self): - '''Removes expired buffs, ensures pause state is respected.''' - self.validate_state() + """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 _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 + def _pause_playtime(self, sender=owner, **kwargs): + """Pauses all playtime buffs when attached object is unpuppeted.""" + 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 puppeted.""" + if sender != self.owner: + return + buffs = self.playtime + for buff in buffs.values(): + buff.unpause() + pass + + 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. + + Args: + value: The base value to modify + stat: The string identifier to search mods for + buffs: The dictionary of buffs to apply""" + 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) + 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) * max(0, 1.0 + mult) return final - #endregion - #endregion + def _remove_via_dict(self, buffs: dict, loud=True, dispel=False, expire=False, context=None): + """Removes buffs within the provided dictionary from this handler. Used for remove methods besides the basic remove.""" + if not context: + context = {} + for k, instance in buffs.items(): + instance: BaseBuff + if loud: + if dispel: + instance.at_dispel(**context) + elif expire: + instance.at_expire(**context) + instance.at_remove(**context) + del instance + del self.buffcache[k] + + # endregion + # endregion + class BuffableProperty(AttributeProperty): - '''An example of a way you can extend AttributeProperty to create properties that automatically check buffs for you.''' + """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.''' +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, buffkey: str, context=None, 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. + + Args: + handler: The handler managing the ticking buff + buffkey: The key of the ticking buff + context: (optional) A dictionary you wish to pass to the at_tick method as kwargs + initial: (optional) Whether this tick_buff call is the first one. Starts True, changes to False for future ticks""" # Cache a reference and find the buff on the object - if uid not in handler.db.keys(): return + if buffkey not in handler.buffcache.keys(): + return # Instantiate the buff and tickrate - buff: BaseBuff = handler.get(uid) + buff: BaseBuff = handler.get(buffkey) 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) + if tr > time.time() - buff.prevtick and initial != True: return - # Tick this buff on-time - if tr <= time.time() - buff.prevtick: buff.on_tick(initial, **context) - - handler.db[uid]['prevtick'] = time.time() + # Only fire the at_tick methods if the conditional is truthy + if buff.conditional(): + # Always tick this buff on initial + if initial: + buff.at_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.at_tick(initial, **context) + buff.remove(expire=True) + return + + # Tick this buff on-time + if tr <= time.time() - buff.prevtick: + buff.at_tick(initial, **context) + + handler.buffcache[buffkey]["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) \ No newline at end of file + utils.delay( + tr, + tick_buff, + handler=handler, + buffkey=buffkey, + context=context, + initial=False, + persistent=True, + ) diff --git a/evennia/contrib/game_systems/buffs/samplebuffs.py b/evennia/contrib/game_systems/buffs/samplebuffs.py new file mode 100644 index 0000000000..7a05b1ec02 --- /dev/null +++ b/evennia/contrib/game_systems/buffs/samplebuffs.py @@ -0,0 +1,141 @@ +import random +from .buff import BaseBuff, Mod + + +class Exploit(BaseBuff): + key = "exploit" + name = "Exploit" + flavor = "You are learning your opponent's weaknesses." + + duration = -1 + maxstacks = 20 + + triggers = ["hit"] + + stack_msg = { + 1: "You begin to notice flaws in your opponent's defense.", + 10: "You've begun to match the battle's rhythm.", + 20: "You've found a gap in the guard!", + } + + def conditional(self, *args, **kwargs): + if self.handler.get_by_type(Exploited): + return False + return True + + def at_trigger(self, trigger: str, *args, **kwargs): + chance = self.stacks / 20 + roll = random.random() + + if chance > roll: + self.handler.add(Exploited) + self.owner.msg("An opportunity presents itself!") + elif chance < roll: + self.handler.add(Exploit) + + if self.stacks in self.stack_msg: + self.owner.msg(self.stack_msg[self.stacks]) + + +class Exploited(BaseBuff): + key = "exploited" + name = "Exploited" + flavor = "You have sensed your target's vulnerability, and are poised to strike." + + duration = 30 + + mods = [Mod("damage", "add", 100)] + + def at_post_check(self, *args, **kwargs): + self.owner.msg("You ruthlessly exploit your target's weakness!") + self.remove(quiet=True) + + def at_remove(self, *args, **kwargs): + self.owner.msg("You have waited too long; the opportunity passes.") + + +class Leeching(BaseBuff): + key = "leeching" + name = "Leeching" + flavor = "Attacking this target fills you with vigor." + + duration = 30 + + triggers = ["taken_damage"] + + def at_trigger(self, trigger: str, attacker=None, damage=None, *args, **kwargs): + if not attacker or not damage: + return + attacker.msg("You have been healed for {heal} life!".format(heal=damage * 0.1)) + + +class Poison(BaseBuff): + key = "poison" + name = "Poison" + flavor = "A poison wracks this body with painful spasms." + + duration = 120 + + maxstacks = 5 + tickrate = 5 + dmg = 5 + + playtime = True + + def at_pause(self, *args, **kwargs): + self.owner.db.prelogout_location.msg_contents( + "{actor} stops twitching, their flesh a deathly pallor.".format(actor=self.owner.named) + ) + + def at_unpause(self, *args, **kwargs): + self.owner.location.msg_contents( + "{actor} begins to twitch again, their cheeks flushing red with blood.".format( + actor=self.owner.named + ) + ) + + def at_tick(self, initial=True, *args, **kwargs): + _dmg = self.dmg * self.stacks + if not initial: + self.owner.location.msg_contents( + "Poison courses through {actor}'s body, dealing {damage} damage.".format( + actor=self.owner.named, damage=_dmg + ) + ) + + +class Sated(BaseBuff): + key = "sated" + name = "Sated" + flavor = "You have eaten a great meal!" + + duration = 180 + maxstacks = 3 + + mods = [Mod("mood", "add", 15)] + + +class StatBuff(BaseBuff): + """Customize the stat this buff affects by feeding a list in the order [stat, mod, base, perstack] to the cache argument when added""" + + key = "statbuff" + name = "statbuff" + flavor = "This buff affects the following stats: {stats}" + + maxstacks = 0 + refresh = True + unique = False + + cache = {"modgen": ["foo", "add", 0, 0]} + + def __init__(self, handler, buffkey, cache={}) -> None: + super().__init__(handler, buffkey, cache) + # Finds our "modgen" cache value, which we pass on application + modgen = list(self.cache["modgen"]) + if modgen: + self.mods = [Mod(*modgen)] + msg = "" + _msg = [mod.stat for mod in self.mods] + for stat in _msg: + msg += stat + self.flavor = self.flavor.format(stats=msg) diff --git a/evennia/contrib/game_systems/buffs/tests.py b/evennia/contrib/game_systems/buffs/tests.py index 352e1670ef..c16ea7f5c0 100644 --- a/evennia/contrib/game_systems/buffs/tests.py +++ b/evennia/contrib/game_systems/buffs/tests.py @@ -3,87 +3,95 @@ from unittest.mock import Mock, patch from evennia import DefaultObject, create_object from evennia.utils import create from evennia.utils.utils import lazy_property +from .samplebuffs import StatBuff + # 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' + key = "tmb" + name = "tmb" + flavor = "modderbuff" maxstacks = 5 - mods = [ - Mod('stat1', 'add', 10, 5), - Mod('stat2', 'mult', 0.5) - ] + mods = [Mod("stat1", "add", 10, 5), Mod("stat2", "mult", 0.5)] + class _TestModBuff2(BaseBuff): - key = 'tmb2' - name = 'tmb2' - flavor = 'modderbuff2' + key = "tmb2" + name = "tmb2" + flavor = "modderbuff2" maxstacks = 1 - mods = [ - Mod('stat1', 'mult', 1.0), - Mod('stat1', 'add', 10) - ] + mods = [Mod("stat1", "mult", 1.0), Mod("stat1", "add", 10)] + class _TestTrigBuff(BaseBuff): - key = 'ttb' - name = 'ttb' - flavor = 'triggerbuff' - triggers = ['test1', 'test2'] + key = "ttb" + name = "ttb" + flavor = "triggerbuff" + triggers = ["test1", "test2"] + + def at_trigger(self, trigger: str, *args, **kwargs): + if trigger == "test1": + self.owner.db.triggertest1 = True + if trigger == "test2": + self.owner.db.triggertest2 = True - 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'] + 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): + def at_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'] + 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) + 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 = {} + def at_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' + key = "ttib" + name = "ttib" + flavor = "timerbuff" maxstacks = 1 tickrate = 1 duration = 5 - mods = [Mod('timetest', 'add', 665)] + mods = [Mod("timetest", "add", 665)] - def on_tick(self, initial=True, *args, **kwargs): + def at_tick(self, initial=True, *args, **kwargs): self.owner.db.ticktest = True + class BuffableObject(DefaultObject): stat1 = BuffableProperty(10) @@ -95,181 +103,258 @@ class BuffableObject(DefaultObject): 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') - + self.testobj = create.create_object(BuffableObject, key="testobj") + def tearDown(self): - """done after every test_* method below """ + """done after every test_* method below""" + self.testobj.buffs.clear() + del self.testobj super().tearDown() def test_addremove(self): - '''tests adding and removing buffs''' + """tests adding and removing buffs""" # setup - handler: BuffHandler = self.obj1.handler + handler: BuffHandler = self.testobj.buffs # add - handler.add(_TestModBuff) - self.assertEqual( self.obj1.db.buffs['tmb']['ref'], _TestModBuff) + handler.add(_TestModBuff, to_cache={"cachetest": True}) + handler.add(_TestTrigBuff) + self.assertEqual(self.testobj.db.buffs["tmb"]["ref"], _TestModBuff) + self.assertTrue(self.testobj.db.buffs["tmb"].get("cachetest")) + self.assertFalse(self.testobj.db.buffs["ttb"].get("cachetest")) # remove - handler.remove('tmb') - self.assertEqual( self.obj1.db.buffs.get('tmb'), None) + handler.remove("tmb") + self.assertFalse(self.testobj.db.buffs.get("tmb")) # remove by type handler.add(_TestModBuff) handler.remove_by_type(_TestModBuff) - self.assertEqual( self.obj1.db.buffs.get('tmb'), None) + self.assertFalse(self.testobj.db.buffs.get("tmb")) # remove by buff instance handler.add(_TestModBuff) - handler.all['tmb'].remove() - self.assertEqual( self.obj1.db.buffs.get('tmb'), None) + handler.all["tmb"].remove() + self.assertFalse(self.testobj.db.buffs.get("tmb")) + # remove by source + handler.add(_TestModBuff) + handler.remove_by_source(None) + self.assertFalse(self.testobj.db.buffs.get("tmb")) + # remove by cachevalue + handler.add(_TestModBuff) + handler.remove_by_cachevalue("failure", True) + self.assertTrue(self.testobj.db.buffs.get("tmb")) + # remove all + handler.add(_TestModBuff) + handler.clear() + self.assertFalse(self.testobj.db.buffs.get("tmb")) def test_getters(self): - '''tests all built-in getters''' + """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) + handler: BuffHandler = self.testobj.buffs + handler.add(_TestModBuff, source=self.obj2) + handler.add(_TestTrigBuff, to_cache={"ttbcache": True}) + # normal getter + self.assertTrue(isinstance(handler.get("tmb"), _TestModBuff)) # stat getters - self.assertEqual(isinstance(handler.get_by_stat('stat1')['tmb'], _TestModBuff), True) - self.assertEqual(handler.get_by_stat('nullstat'), {}) + self.assertTrue(isinstance(handler.get_by_stat("stat1")["tmb"], _TestModBuff)) + self.assertFalse(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) + self.assertTrue("ttb" in handler.get_by_trigger("test1").keys()) + self.assertFalse("ttb" in handler.get_by_trigger("nulltrig").keys()) # type getters - self.assertEqual('tmb' in handler.get_by_type(_TestModBuff), True) - self.assertEqual('tmb' in handler.get_by_type(_EmptyBuff), False) - + self.assertTrue("tmb" in handler.get_by_type(_TestModBuff)) + self.assertFalse("tmb" in handler.get_by_type(_EmptyBuff)) + # source getter + self.assertTrue("tmb" in handler.get_by_source(self.obj2)) + self.assertFalse("ttb" in handler.get_by_source(self.obj2)) + # cachevalue getter + self.assertFalse(handler.get("tmb").cache.get("ttbcache")) + self.assertFalse(handler.get("ttb").cache.get("testfalse")) + self.assertFalse("tmb" in handler.get_by_cachevalue("ttbcache")) + self.assertTrue("ttb" in handler.get_by_cachevalue("ttbcache")) + self.assertTrue("ttb" in handler.get_by_cachevalue("ttbcache", True)) + def test_details(self): - '''tests that buff details like name and flavor are correct''' - handler: BuffHandler = self.obj1.handler + """tests that buff details like name and flavor are correct""" + handler: BuffHandler = self.testobj.buffs handler.add(_TestModBuff) handler.add(_TestTrigBuff) - self.assertEqual(handler.tmb.flavor, 'modderbuff') - self.assertEqual(handler.ttb.name, 'ttb') - + self.assertEqual(handler.get("tmb").flavor, "modderbuff") + self.assertEqual(handler.get("ttb").name, "ttb") + def test_modify(self): - '''tests to ensure that values are modified correctly, and stack across mods''' + """tests to ensure that values are modified correctly, and stack across mods""" # setup - handler: BuffHandler = self.obj1.handler + handler: BuffHandler = self.testobj.buffs _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) + 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) + 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) + self.assertEqual(handler.check(_stat1, "stat1"), 20) _stat2 += 10 - self.assertEqual(handler.check(_stat2, 'stat2'), 30) + 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) + self.assertEqual(handler.check(_stat1, "stat1"), 25) handler.add(_TestModBuff, stacks=3) - self.assertEqual(handler.check(_stat1, 'stat1'), 40) + self.assertEqual(handler.check(_stat1, "stat1"), 40) handler.add(_TestModBuff, stacks=5) - self.assertEqual(handler.check(_stat1, 'stat1'), 40) + self.assertEqual(handler.check(_stat1, "stat1"), 40) # stat2 mod doesn't stack - self.assertEqual(handler.check(_stat2, 'stat2'), 30) + 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) + 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) - + self.assertEqual(handler.check(_stat1, "stat1"), 30) + self.assertEqual(handler.check(_stat2, "stat2"), 20) + def test_trigger(self): - '''tests to ensure triggers correctly fire''' + """tests to ensure triggers correctly fire""" # setup - handler: BuffHandler = self.obj1.handler + handler: BuffHandler = self.testobj.buffs 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) + handler.trigger("nulltest") + self.assertFalse(self.testobj.db.triggertest1) + self.assertFalse(self.testobj.db.triggertest2) + handler.trigger("test1") + self.assertTrue(self.testobj.db.triggertest1) + self.assertFalse(self.testobj.db.triggertest2) + handler.trigger("test2") + self.assertTrue(self.testobj.db.triggertest1) + self.assertTrue(self.testobj.db.triggertest2) def test_context_conditional(self): - '''tests to ensure context is passed to buffs, and also tests conditionals''' + """tests to ensure context is passed to buffs, and also tests conditionals""" # setup - handler: BuffHandler = self.obj1.handler + handler: BuffHandler = self.testobj.buffs handler.add(_TestConBuff) - self.obj1.db.cond1, self.obj1.db.att, self.obj1.db.dmg = False, None, 0 + self.testobj.db.cond1, self.testobj.db.att, self.testobj.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} + _testcontext = { + "attacker": self.obj2, + "defender": self.testobj, + "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) + self.assertEqual( + handler.get_by_type(_TestConBuff)["tcb"].conditional(**_testcontext), False + ) + handler.trigger("condtest", _testcontext) + self.assertEqual(self.testobj.db.att, None) + self.assertEqual(self.testobj.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) - + self.testobj.db.cond1 = True + self.assertEqual(handler.get_by_type(_TestConBuff)["tcb"].conditional(**_testcontext), True) + handler.trigger("condtest", _testcontext) + self.assertEqual(self.testobj.db.att, self.obj2) + self.assertEqual(self.testobj.db.dmg, 5) + def test_complex(self): - '''tests a complex mod (conditionals, multiple triggers/mods)''' + """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: BuffHandler = self.testobj.buffs + self.testobj.db.comone, self.testobj.db.comtwo, self.testobj.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) + self.assertEqual(self.testobj.db.comtext, {}) + self.assertEqual(handler.check(self.testobj.db.comone, "com1"), 105) + self.assertEqual(handler.check(self.testobj.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) + handler.trigger("comtest", self.testobj.db.comtext) + self.assertEqual(self.testobj.db.comtext, {"cond": True}) + self.assertEqual( + handler.get_by_type(_TestComplexBuff)["tcomb"].conditional(**self.testobj.db.comtext), + False, + ) + self.assertEqual( + handler.check(self.testobj.db.comone, "com1", context=self.testobj.db.comtext), 10 + ) + self.assertEqual( + handler.check(self.testobj.db.comtwo, "com2", context=self.testobj.db.comtext), 0 + ) + handler.trigger("complextest", self.testobj.db.comtext) + self.assertEqual( + handler.check(self.testobj.db.comone, "com1", context=self.testobj.db.comtext), 10 + ) + self.assertEqual( + handler.check(self.testobj.db.comtwo, "com2", context=self.testobj.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) + self.testobj.db.comtext = {"cond": False} + handler.trigger("complextest", self.testobj.db.comtext) + self.assertEqual(self.testobj.db.comtext, {}) + self.assertEqual( + handler.check(self.testobj.db.comone, "com1", context=self.testobj.db.comtext), 105 + ) + self.assertEqual( + handler.check(self.testobj.db.comtwo, "com2", context=self.testobj.db.comtext), 100 + ) def test_timing(self): - '''tests timing-related features, such as ticking and duration''' + """tests timing-related features, such as ticking and duration""" # setup - handler: BuffHandler = self.obj1.handler + handler: BuffHandler = self.testobj.buffs handler.add(_TestTimeBuff) - self.obj1.db.timetest, self.obj1.db.ticktest = 1, False + self.testobj.db.timetest, self.testobj.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) + _instance = handler.get("ttib") + self.assertTrue(_instance.ticking) + self.assertEqual(_instance.duration, 5) + _instance.at_tick() + self.assertTrue(self.testobj.db.ticktest) # test duration modification and cleanup - handler.modify_duration('ttib', 0, set=True) - self.assertEqual(handler.get('ttib').duration, 0) + handler.set_duration("ttib", 0) + self.assertEqual(handler.get("ttib").duration, 0) handler.cleanup() - self.assertFalse(handler.get('ttib'), None) - + self.assertFalse(handler.get("ttib"), None) + def test_buffableproperty(self): - '''tests buffable properties''' + """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) + self.testobj.buffs.add(_TestModBuff) + self.assertEqual(self.testobj.stat1, 25) + self.testobj.buffs.remove("tmb") + self.assertEqual(self.testobj.stat1, 10) + + def test_stresstest(self): + """tests large amounts of buffs, and related removal methods""" + # setup + for x in range(1, 20): + self.testobj.buffs.add(_TestModBuff, key="test" + str(x)) + self.testobj.buffs.add(_TestTrigBuff, key="trig" + str(x)) + self.assertEqual(self.testobj.stat1, 295) + self.testobj.buffs.trigger("test1") + self.testobj.buffs.remove_by_type(_TestModBuff) + self.assertEqual(self.testobj.stat1, 10) + self.testobj.buffs.clear() + self.assertFalse(self.testobj.buffs.all) + + def test_modgen(self): + """test generating mods on the fly""" + # setup + handler: BuffHandler = self.testobj.buffs + self.testobj.db.gentest = 5 + self.assertEqual(self.testobj.db.gentest, 5) + tc = {"modgen": ["gentest", "add", 5, 0]} + handler.add(StatBuff, key="gentest", to_cache=tc) + self.assertEqual(handler.check(self.testobj.db.gentest, "gentest"), 10) + tc = {"modgen": ["gentest", "add", 10, 0]} + handler.add(StatBuff, key="gentest", to_cache=tc) + self.assertEqual(handler.check(self.testobj.db.gentest, "gentest"), 15) + self.assertEqual( + handler.get("gentest").flavor, "This buff affects the following stats: gentest" + )