From f340d4c6e45c4b7f8ba32be6a046598a9e4adf8d Mon Sep 17 00:00:00 2001 From: Tegiminis Date: Tue, 12 Jul 2022 01:22:59 -0700 Subject: [PATCH] initial buff contrib commit --- .../contrib/game_systems/buffs/__init__.py | 0 evennia/contrib/game_systems/buffs/buff.py | 768 ++++++++++++++++++ evennia/contrib/game_systems/buffs/tests.py | 275 +++++++ 3 files changed, 1043 insertions(+) create mode 100644 evennia/contrib/game_systems/buffs/__init__.py create mode 100644 evennia/contrib/game_systems/buffs/buff.py create mode 100644 evennia/contrib/game_systems/buffs/tests.py diff --git a/evennia/contrib/game_systems/buffs/__init__.py b/evennia/contrib/game_systems/buffs/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/evennia/contrib/game_systems/buffs/buff.py b/evennia/contrib/game_systems/buffs/buff.py new file mode 100644 index 0000000000..254ca5e990 --- /dev/null +++ b/evennia/contrib/game_systems/buffs/buff.py @@ -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) \ No newline at end of file diff --git a/evennia/contrib/game_systems/buffs/tests.py b/evennia/contrib/game_systems/buffs/tests.py new file mode 100644 index 0000000000..352e1670ef --- /dev/null +++ b/evennia/contrib/game_systems/buffs/tests.py @@ -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)