From f340d4c6e45c4b7f8ba32be6a046598a9e4adf8d Mon Sep 17 00:00:00 2001 From: Tegiminis Date: Tue, 12 Jul 2022 01:22:59 -0700 Subject: [PATCH 01/18] 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) From 3a52913bbbb6a1be17203a264e9d01f1ca6277c6 Mon Sep 17 00:00:00 2001 From: Tegiminis Date: Tue, 12 Jul 2022 01:33:57 -0700 Subject: [PATCH 02/18] new unit tests for the new getters/remove methods. general test case improvement (+47 squashed commit) Squashed commit: [da8f778e1] fixed issue with buff instancing saving previous cache information [50e381135] fixing clear to use new private method [523196876] added new removal methods, spun boilerplate into private method [ab2fe7a1b] misc [4a2c2e887] added get_by_cachevalue, for arbitrary cache value comparison [4e9199fd9] fix expired property [cc6411eb2] added option to trigger buffs at the same time you check them [eb112c12f] added get_by_source method to slice cache by source [5d880d4f9] added "clear" method to remove all buffs [49997e724] extended "expired" logic to include buffs with no stacks [7ef357ade] docstring fixes [306801d02] Added a little docstring bit for disconnecting autopause from signals and moving it to object puppet hooks [7a120a2f8] fixed the sample buff for poison to demonstrate how playtime buff pause hooks happen (as at_pause fires after the object is moved to None, you can't message the None-room's contents) [ada4457d1] made the pause variable assignments more verbose [d64369908] nomenclature change: uid -> buffkey [e5dd9a352] fixing places where the handler property name change from "db" to "buffcache" broke stuff on the buff class [553d12b4b] lots of docstring updates and comments; also made conditional hook work with ticking [b8d79583f] ran black autoformatter [d5550f331] changing all the default dictionary assignments to None instead [88c9a4889] sample buff of stat customizer [72323a19e] added sample buff which allows stat customization, and unit test to cover it [02453a79f] adding procgen ids for non-unique, non-refresh (ie, each time the buff is applied, it is as a full instance with a separate key) [c72e5f2d7] added to_filter argument to slice methods, letting you slice an arbitrary dictionary as well as the main cache [d8996f3a3] stress test for batch read/trigger [f06f74463] last bit of cacheing, docstring update [538afc676] fixing tests to not use __getattr__ on the handler [905cfd6bb] removing __getattr__ [e588742ea] hooking caching into the buff instancing process [7555ebae9] added method to instance entire cache, refactored dict-comprehension properties to use new method. should be more efficient? [45f15fc46] fix to reapply logic; stacking and old cache [d2460c9e6] docstring support for to_cache arg [62f3f27d8] added to_cache arg for buff application, allows you to store arbitrary info in the buff cache [6c2046497] general cleanup [357945459] forgot to finish the mod for the "sated" moodlet sample buff [b4b3ac48a] clamp minimum multiplier to 0 [da3e67fa6] allowing multiple stacks, cruft cleanup [62110fd00] sample buffs [77f6a56e8] fixing tests to work with new naming [a9202f67c] misc cleanup [887d9734b] allow infinite stacks [887fb4b29] change duration setter [da4277e2c] add at_pre_check [6009469bc] fix pickling errors with handler properties [89a2d02ee] allo ticking buffs to autopause [a77bded93] fix flavor return [60fcaa36f] swapping on_ to at_ for hooks to match evennia style [130e600ea] import cleanup --- evennia/contrib/game_systems/buffs/buff.py | 1082 +++++++++++------ .../contrib/game_systems/buffs/samplebuffs.py | 141 +++ evennia/contrib/game_systems/buffs/tests.py | 387 +++--- 3 files changed, 1088 insertions(+), 522 deletions(-) create mode 100644 evennia/contrib/game_systems/buffs/samplebuffs.py 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" + ) From a1e004ded544cbcb1ed7df024237e0b36ad5a30f Mon Sep 17 00:00:00 2001 From: Tegiminis Date: Sun, 24 Jul 2022 03:53:23 -0700 Subject: [PATCH 03/18] initial readme commit --- evennia/contrib/game_systems/buffs/README.md | 239 +++++++++++++++++++ evennia/contrib/game_systems/buffs/buff.py | 3 +- 2 files changed, 241 insertions(+), 1 deletion(-) create mode 100644 evennia/contrib/game_systems/buffs/README.md diff --git a/evennia/contrib/game_systems/buffs/README.md b/evennia/contrib/game_systems/buffs/README.md new file mode 100644 index 0000000000..a1147c6ca0 --- /dev/null +++ b/evennia/contrib/game_systems/buffs/README.md @@ -0,0 +1,239 @@ +# Buffs + +Contribution by 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. 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.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` (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 + +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 `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 at_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 `at_tick` +method. Once you add it to the handler, it starts ticking! + +### Context + +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). 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 +and add a context to the mix. + +```python +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. +```python +def ThornsBuff(BaseBuff): + ... + triggers = ['taken_damage'] + # This is the hook method on our thorns buff + 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! + +## 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. (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)] +``` + +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 `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 at_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. +```python +def Poison(BaseBuff): + ... + # this buff will tick 6 times between application and cleanup. + duration = 30 + tickrate = 5 + 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 `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 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 +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`, 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. 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? + +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 {buffkey: instance}. Buffs are only instanced as long as is necessary to +run methods on them. diff --git a/evennia/contrib/game_systems/buffs/buff.py b/evennia/contrib/game_systems/buffs/buff.py index b931f0284b..3c6b525ee6 100644 --- a/evennia/contrib/game_systems/buffs/buff.py +++ b/evennia/contrib/game_systems/buffs/buff.py @@ -1,4 +1,5 @@ -"""Buffs - Tegiminis 2022 +""" +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. From 6ba3cf12fc88cc1021d114691ce23453e9dce75c Mon Sep 17 00:00:00 2001 From: Tegiminis Date: Sun, 24 Jul 2022 04:15:12 -0700 Subject: [PATCH 04/18] fixing goofs exposed by conditional rework, readme tweaks (+8 squashed commit) Squashed commit: [7d0ff84f5] more readme changes... i can't stop... [8259163dc] added new removers for parity with getters, altered conditional logic [d1db0e4a2] added getter/remover section [0bec38d51] misc fixes [614df9883] adding test for stack removal, fix to stack removal logic [77149aaaf] third readme edit, buff module docstring edit, tweak to modgen samplebuff [ca992fd1c] editing buff section of readme [f33eec3d8] first edit of readme --- evennia/contrib/game_systems/buffs/README.md | 257 +++++++++--- evennia/contrib/game_systems/buffs/buff.py | 395 ++++++++---------- .../contrib/game_systems/buffs/samplebuffs.py | 2 +- evennia/contrib/game_systems/buffs/tests.py | 8 +- 4 files changed, 375 insertions(+), 287 deletions(-) diff --git a/evennia/contrib/game_systems/buffs/README.md b/evennia/contrib/game_systems/buffs/README.md index a1147c6ca0..eb0b81d2ea 100644 --- a/evennia/contrib/game_systems/buffs/README.md +++ b/evennia/contrib/game_systems/buffs/README.md @@ -2,11 +2,15 @@ Contribution by 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. +A buff is a timed object, attached to a game entity. It is capable of modifying values, triggering 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. +This contrib offers you: +- A buff handler to apply to your objects (`BuffHandler`). +- A buff class to extend from to create your own buffs (`BaseBuff`). +- A sample property class to show how to automatically check modifiers (`BuffableProperty`). +- A command which applies buffs (`CmdBuff`). +- Some sample buffs to learn from (`samplebuffs.py`). ## Quick Start Assign the handler to a property on the object, like so. @@ -25,7 +29,7 @@ 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 +> **Note**: 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! @@ -33,27 +37,27 @@ Let's say you want another handler for an object, `perks`, which has a separate 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) +@lazy_property +def perks(self) -> BuffHandler: + return BuffHandler(self, dbkey='perks', autopause=True) ``` -And add `self.perks` to the object's `at_init`. +And initialize it by adding `self.perks` to the object's `at_init`. -### Using the Handler +## Using the Handler -To actually make use of the handler, you still have to do some leg work. +Here's how to make use of your new handler. -#### Apply a Buff +### Apply a Buff -Call the handler `add(BuffClass)` method. This requires a class reference, and also contains a number of +Call the handler `add` method. This requires a class reference, and also contains a number of 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.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(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 ``` @@ -61,13 +65,63 @@ Two important attributes on the buff are checked when the buff is applied: `refr - `refresh` (default: True) determines if a buff's timer is refreshed when it is reapplied. - `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 +If both are `False`, the buff creates a random (rather than buff + source) key each time it is applied, and will never refresh. + +### Get Buffs + +The handler has several getter methods which return instanced buffs. You won't need to use these for basic functionality, but if you want to manipulate +buffs after application, they are very useful. The handler's `check`/`trigger` methods utilize some of these getters, while others are just for developer convenience. + +`get(key)` is the most basic getter. It returns a single buff instance, or `None` if the buff doesn't exist on the handler. It is also the only getter +that returns a single buff instance, rather than a dictionary. + +Grouped getters, listed below, return a dictionary of values in the format `{buffkey: instance}`. If you want to iterate over all these buffs, +you should do so via the `dict.values()` method. + +- `get_all()` returns all buffs on this handler. You can also use the `handler.all` property. +- `get_by_type(BuffClass)` returns buffs of the specified type. +- `get_by_stat(stat)` returns buffs with a `Mod` object of the specified `stat` string in their `mods` list. Used by `handler.check(stat)`. +- `get_by_trigger(trigger)` returns buffs with the specified trigger in their `triggers` list. Used by `handler.trigger(key)`. +- `get_by_source(Object)` returns buffs applied by the specified `source` object. +- `get_by_cachevalue(key, value)` returns buffs with the matching `key: value` pair in their cache. `value` is optional. + +All grouped getters besides `get_all()` can "slice" an existing dictionary through the optional `to_filter` argument. + +```python +dict1 = handler.get_by_type(Burned) # This finds all "Burned" buffs on the handler +dict2 = handler.get_by_source(self, to_filter=dict1) # This filters dict1 to find buffs with the matching source +``` + +> **Note**: Most of these getters also have an associated handler property. For example, `handler.effects` returns all buffs which can be triggered, which +> is then iterated over by the `get_by_trigger` method. + +### Remove Buffs + +There are also a number of remover methods. Generally speaking, these follow the same format as the getters. + +- `remove(key)` removes the buff with the specified key. +- `clear()` removes all buffs. +- `remove_by_type(BuffClass)` removes buffs of the specified type. +- `remove_by_stat(stat)` removes buffs with a `Mod` object of the specified `stat` string in their `mods` list. +- `remove_by_trigger(trigger)` removes buffs with the specified trigger in their `triggers` list. +- `remove_by_source(Object)` removes buffs applied by the specified source +- `remove_by_cachevalue(key, value)` removes buffs with the matching `key: value` pair in their cache. `value` is optional. + +You can also remove a buff by calling the instance's `remove` helper method. You can do this on the dictionaries returned by the +getters listed above. + +```python +to_remove = handler.get_by_trigger(trigger) # Finds all buffs with the specified trigger +for buff in to_remove.values(): buff.remove() # Removes all buffs in the to_remove dictionary via helper methods +``` + +### Check Modifiers 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 +This will return the `value`, modified by any 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 +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 @@ -76,37 +130,64 @@ def take_damage(self, source, damage): self.db.health -= _damage ``` -#### Trigger +This method call the `at_pre_check` and `at_post_check` methods at the relevant points in the process. You can use to this make +buffs that are reactive to being checked; for example, removing themselves, altering their values, or interacting with the game state. -Call the handler `trigger(triggerstring)` method wherever you want an event call. This +> **Note**: You can also trigger relevant buffs at the same time as you check them by ensuring the optional argument `trigger` is True in the `check` method. + +### Trigger Buffs + +Call the handler `trigger(string)` method wherever you want an event call. This 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: +You'd write a buff that might look like this: ```python -triggers = ['take_damage'] -def at_trigger(self, trigger, *args, **kwargs) - self.owner.take_damage(100) +def Detonate(BaseBuff): + ... + triggers = ['take_damage'] + def at_trigger(self, trigger, *args, **kwargs) + self.owner.take_damage(100) + self.remove() ``` And then call `handler.trigger('take_damage')` in the method you use to take damage. -#### Tick +> **Note** You could also do this through mods and `at_post_check` if you like, depending on how to want to add the damage. + +### Ticking 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, +doing so on an event trigger, they do so on a periodic tick. A common use case for a buff like this is a poison, or a heal over time. +```python +def Poison(BaseBuff): + ... + tickrate = 5 + 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 + ) + ) +``` + 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! +> **Note**: Ticking buffs always tick on initial application, when `initial` is `True`. If you don't want your hook to fire at that time, +> make sure to check the value of `initial` in your `at_tick` method. + ### Context -You may have noticed that almost every important handler method optionally accepts a `context` dictionary. +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). It is used for nothing else. 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 @@ -131,26 +212,40 @@ def ThornsBuff(BaseBuff): ``` Apply the buff, take damage, and watch the thorns buff do its work! -## Buffs +## Creating New 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. +Creating a new 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: +Regardless of any other functionality, all buffs have the following class attributes: - 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). +- They have a `duration`, and automatically clean up at the end of it. Use -1 for infinite duration, and 0 to cleanup immediately. (default: -1) +- They can stack, if `maxstacks` is not equal to 1. If it's 0, the buff stacks forever. (default: 1) +- They can be `unique`, which determines if they have a unique namespace or not. (default: True) +- They can `refresh`, which resets the duration when stacked or reapplied. (default: True) +- They can be `playtime` buffs, where duration only counts down during active play. (default: False) + +They also always store some useful mutable information about themselves in the cache: + +- The `ref` class, which is the buff class path we use to construct the buff. +- The `start` timestamp of when the buff was applied. +- Their `source`, if specified; this allows you to track who or what applied the buff. +- The `prevtick` timestamp of the previous time this buff ticked. +- The current `duration`. This can vary from the class duration, as you might apply buffs with variable durations, or alter them. +- The number of `stacks` they have. +- Whether they are `paused` or not. Paused buffs do not clean up, modify values, tick, or fire any hook methods. + +You can always access the raw cache dictionary through the `cache` attribute on an instanced buff. This is grabbed when you get the buff through +a handler method, so it may not always reflect recent changes you've made, depending on how you structure your buff calls. All of the above +mutable information can be found in this cache, as well as any arbitrary information you pass through the handler `add` method (via `to_cache`). ### 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. +Mods are stored in the `mods` list attribute. 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: @@ -159,24 +254,40 @@ Mod objects consist of only four values, assigned by the constructor in this ord - `value`: How much value the modifier gives regardless of stacks - `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: +The most basic way to add a Mod to a buff is to do so in the buff class definition, like this: + ```python 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. +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. + +> **Note**: You can add your own modifier types by overloading the `_calculate_mods` method, which contains the basic modifier application logic. + +#### Generating Mods (Advanced) + +An advanced way to do mods is to generate them when the buff is initialized. This lets you create mods on the fly that are reactive to the game state. + +```python +def GeneratedStatBuff(BaseBuff): + ... + def __init__(self, handler, buffkey, cache={}) -> None: + super().__init__(handler, buffkey, cache) + # Finds our "modgen" cache value, and generates a mod from it + modgen = list(self.cache.get("modgen")) + if modgen: + self.mods = [Mod(*modgen)] +``` ### 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 `at_trigger` methods. You can tell which trigger is the one it fired with by the `trigger` +trigger, then calls their `at_trigger` methods. Buffs can have multiple triggers, and you can tell which trigger was fired by the `trigger` argument in the method. ```python @@ -192,6 +303,7 @@ 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 def Poison(BaseBuff): ... @@ -201,39 +313,46 @@ def Poison(BaseBuff): 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 `at_tick` hook method. +> **Note**: The buff always ticks once when applied. For this **first tick only**, `initial` will be True in the `at_tick` hook method. + +Ticks utilize a persistent delay, so they should be pickleable. As long as you are not adding new properties to your buff class, this shouldn't be a concern. +If you **are** adding new properties, try to ensure they do not end up with a circular code path to their object or handler, as this will cause pickling errors. ### Extras -Buffs have a grab-bag of extra functionality to make your life easier! +Buffs have a grab-bag of extra functionality to let you add complexity to your designs. -You can restrict whether or not the buff will check or trigger through defining the `conditional` hook. As long +#### Conditionals + +You can restrict whether or not the buff will `check`, `trigger`, or `tick` through defining the `conditional` hook. As long 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. +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 +def FireSick(BaseBuff): + ... + 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`, etc. +Conditionals for `check`/`trigger` are checked when the buffs are gathered by the handler methods for the respective operations. `Tick` +conditionals are checked each tick. -Finally, if your handler has `autopause` enabled, any buffs with truthy `playtime` value will automatically pause +#### Helper Methods + +Buff instances have a number of helper methods you can access either on the buff itself or wherever the buff is instances (typically through +handler getters) + +- `remove`/`dispel`: Allows you to remove or dispel the buff. Calls `at_remove`/`at_dispel`, depending on optional arguments. +- `pause`/`unpause`: Pauses and unpauses the buff. Calls `at_pause`/`at_unpause`. +- `reset`: Resets the buff's start to the current time; same as "refreshing" it. + +#### Playtime Duration + +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. 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? - -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 {buffkey: instance}. Buffs are only instanced as long as is necessary to -run methods on them. +> **Note**: 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. diff --git a/evennia/contrib/game_systems/buffs/buff.py b/evennia/contrib/game_systems/buffs/buff.py index 3c6b525ee6..f1c3212644 100644 --- a/evennia/contrib/game_systems/buffs/buff.py +++ b/evennia/contrib/game_systems/buffs/buff.py @@ -5,9 +5,10 @@ A buff is a timed object, attached to a game entity, that modifies values, trigg 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. +a sample property class to show how to automatically check modifiers, some sample buffs to learn from, +and a command which applies buffs. -## Quick Start +## Installation Assign the handler to a property on the object, like so. ```python @@ -15,39 +16,17 @@ Assign the handler to a property on the object, like so. def buffs(self) -> BuffHandler: return BuffHandler(self)``` -You may then call the handler to add or manipulate buffs. +## Using the Handler -### Customization +To make use of the handler, you will need: -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. +- Some buffs to add. You can create these by extending the `BaseBuff` class from this module. You can see some examples in `samplebuffs.py`. +- A way to add buffs to the handler. You can see a basic example of this in the `CmdBuff` command in this module. -> 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 +### Applying 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. 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. +optional arguments to customize the buff's duration, stacks, and so on. ```python self.buffs.add(StrengthBuff) # A single stack of StrengthBuff with normal duration @@ -55,17 +34,11 @@ self.buffs.add(DexBuff, stacks=3, duration=60) # Three stacks of DexBuff, with 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` (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 +### 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 +the `stat` string). For example: ```python # The method we call to damage ourselves @@ -74,167 +47,61 @@ def take_damage(self, source, damage): self.db.health -= _damage ``` -#### Trigger +### Trigger Call the handler `trigger(triggerstring)` method wherever you want an event call. This 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 at_trigger(self, trigger, *args, **kwargs) - self.owner.take_damage(100) +def Detonate(BaseBuff): + ... + triggers = ['take_damage'] + def at_trigger(self, trigger, *args, **kwargs) + self.owner.take_damage(100) + self.remove() + +def Character(Character): + ... + def take_damage(self, source, damage): + self.buffs.trigger('take_damage') + self.db.health -= _damage ``` -And then call `handler.trigger('take_damage')` in the method you use to take damage. +### Tick -#### Tick +Ticking a buff happens automatically once applied, as long as the buff's `tickrate` is more than 0. -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 `at_tick` -method. Once you add it to the handler, it starts ticking! +```python +def Poison(BaseBuff): + ... + tickrate = 5 + 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 + ) + ) +``` ## Buffs -But wait! You still have to actually create the buffs you're going to be applying. +A buff is a class which contains a bunch of immutable data about itself - such as tickrate, triggers, refresh rules, and +so on - and which merges mutable data in from the cache when called. -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. +Buffs are always instanced when they are called for a method. To access a buff's properties and methods, you should do so through +this instance, rather than directly manipulating the buff cache on the object. You can modify a buff's cache through various handler +methods instead. -### Basics +You can see all the features of the `BaseBuff` class below, or browse `samplebuffs.py` to see how to create some common buffs. Buffs have +many attributes and hook methods you can overload to create complex, interrelated buffs. -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. (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)] -``` - -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 `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 at_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. -```python -# this buff will tick 6 times between application and cleanup. -duration = 30 -tickrate = 5 -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 `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 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 -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`, 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. 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? - -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 {buffkey: 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 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). 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 -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 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 import Command from evennia.server import signals from evennia.utils import utils, search from evennia.typeclasses.attributes import AttributeProperty @@ -553,8 +420,10 @@ class BuffHandler(object): context = {} b = {} _context = dict(context) + if buff.cache: + b = dict(buff.cache) if to_cache: - b = dict(to_cache) + b.update(dict(to_cache)) if stacks < 1: stacks = min(1, buff.stacks) @@ -609,6 +478,7 @@ class BuffHandler(object): if b["duration"] > -1: utils.delay(b["duration"], self.cleanup, persistent=True) + # region removers 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 @@ -642,6 +512,8 @@ class BuffHandler(object): del self.buffcache[key] elif stacks: self.buffcache[key]["stacks"] -= stacks + if self.buffcache[key]["stacks"] <= 0: + del self.buffcache[key] def remove_by_type( self, @@ -665,6 +537,50 @@ class BuffHandler(object): return None self._remove_via_dict(_remove, loud, dispel, expire, context) + def remove_by_stat( + self, + stat, + loud=True, + dispel=False, + expire=False, + context=None, + ): + """Removes all buffs modifying the specified stat from this object. + + Args: + stat: The stat string 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_stat(stat) + if not _remove: + return None + self._remove_via_dict(_remove, loud, dispel, expire, context) + + def remove_by_trigger( + self, + trigger, + loud=True, + dispel=False, + expire=False, + context=None, + ): + """Removes all buffs with the specified trigger from this object. + + Args: + trigger: The stat string 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_trigger(trigger) + if not _remove: + return None + self._remove_via_dict(_remove, loud, dispel, expire, context) + def remove_by_source( self, source, @@ -673,7 +589,7 @@ class BuffHandler(object): expire=False, context=None, ): - """Removes all buffs from the specified source from this object. Functionally similar to remove, but takes a source instead. + """Removes all buffs from the specified source from this object. Args: source: The source to search for @@ -716,6 +632,8 @@ class BuffHandler(object): cache = self.all self._remove_via_dict(cache, loud, dispel, expire, context) + # endregion + # region getters 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. @@ -731,6 +649,8 @@ class BuffHandler(object): 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) + if not _cache: + return None return {k: buff["ref"](self, k, buff) for k, buff in _cache.items()} def get_by_type(self, buff: BaseBuff, to_filter=None): @@ -742,52 +662,36 @@ class BuffHandler(object): 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 + if not _cache: + return None 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): + def get_by_stat(self, stat: str, 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) - } + buffs = {k: buff for k, buff in _cache.items() for m in buff.mods if m.stat == stat} return buffs - def get_by_trigger(self, trigger: str, context=None, to_filter=None): + def get_by_trigger(self, trigger: str, 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 not _cache: + return None + buffs = {k: buff for k, buff in _cache.items() if trigger in buff.triggers} return buffs def get_by_source(self, source, to_filter=None): @@ -799,6 +703,8 @@ class BuffHandler(object): 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 + if not _cache: + return None buffs = {k: buff for k, buff in _cache.items() if buff.source == source} return buffs @@ -812,12 +718,16 @@ class BuffHandler(object): 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 _cache: + return None 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 + # endregion + 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. @@ -825,7 +735,7 @@ class BuffHandler(object): 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 + context: (optional) A dictionary you wish to pass to the at_pre_check/at_post_check and conditional methods as kwargs trigger: (optional) Trigger buffs with the `stat` string as well. (default: False) Returns the value modified by relevant buffs.""" @@ -835,12 +745,16 @@ class BuffHandler(object): # Find all buffs and traits related to the specified stat. if not context: context = {} - applied = self.get_by_stat(stat, context) + applied = self.get_by_stat(stat) if not applied: return value for buff in applied.values(): buff.at_pre_check(**context) + applied = { + k: buff for k, buff in applied.items() if buff.conditional(**context) if not buff.paused + } + # The final result final = self._calculate_mods(value, stat, applied) @@ -865,17 +779,24 @@ class BuffHandler(object): 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: + _effects = self.get_by_trigger(trigger) + if not _effects: return None if not context: context = {} + _to_trigger = { + k: buff + for k, buff in _effects.items() + if buff.conditional(**context) + if not buff.paused + if trigger in buff.triggers + } + # Trigger all buffs whose trigger matches the trigger string - for buff in _effects.values(): + for buff in _to_trigger.values(): buff: BaseBuff - if trigger in buff.triggers and not buff.paused: - buff.at_trigger(trigger, **context) + buff.at_trigger(trigger, **context) 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'. @@ -945,7 +866,7 @@ class BuffHandler(object): instance: BaseBuff = buff["ref"](self, key, buff) instance.at_unpause(**context) utils.delay(buff["duration"], cleanup_buffs, self, persistent=True) - if buff["ref"].ticking: + if instance.ticking: utils.delay( tickrate, tick_buff, handler=self, buffkey=key, initial=False, persistent=True ) @@ -1028,6 +949,8 @@ class BuffHandler(object): """Removes buffs within the provided dictionary from this handler. Used for remove methods besides the basic remove.""" if not context: context = {} + if not buffs: + return for k, instance in buffs.items(): instance: BaseBuff if loud: @@ -1051,6 +974,48 @@ class BuffableProperty(AttributeProperty): return _value +class CmdBuff(Command): + """ + Buff a target. + + Usage: + buff + + Applies the specified buff to the target. All buffs are defined in the bufflist dictionary on this command. + """ + + key = "buff" + aliases = ["buff"] + help_category = "builder" + + bufflist = {"foo": BaseBuff} + + def parse(self): + self.args = self.args.split() + + def func(self): + caller = self.caller + target = None + now = time.time() + + if self.args: + target = caller.search(self.args[0]) + caller.ndb.target = target + elif caller.ndb.target: + target = caller.ndb.target + else: + caller.msg("You need to pick a target to buff.") + return + + if self.args[1] not in self.bufflist.keys(): + caller.msg("You must pick a valid buff.") + return + + if target: + target.buffs.add(self.bufflist[self.args[1]], source=caller) + pass + + def cleanup_buffs(handler: BuffHandler): """Cleans up all expired buffs from a handler.""" _remove = handler.expired @@ -1069,6 +1034,8 @@ def tick_buff(handler: BuffHandler, buffkey: str, context=None, initial=True): # Cache a reference and find the buff on the object if buffkey not in handler.buffcache.keys(): return + if not context: + context = {} # Instantiate the buff and tickrate buff: BaseBuff = handler.get(buffkey) diff --git a/evennia/contrib/game_systems/buffs/samplebuffs.py b/evennia/contrib/game_systems/buffs/samplebuffs.py index 7a05b1ec02..457e647805 100644 --- a/evennia/contrib/game_systems/buffs/samplebuffs.py +++ b/evennia/contrib/game_systems/buffs/samplebuffs.py @@ -131,7 +131,7 @@ class StatBuff(BaseBuff): 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"]) + modgen = list(self.cache.get("modgen")) if modgen: self.mods = [Mod(*modgen)] msg = "" diff --git a/evennia/contrib/game_systems/buffs/tests.py b/evennia/contrib/game_systems/buffs/tests.py index c16ea7f5c0..6c02d40be8 100644 --- a/evennia/contrib/game_systems/buffs/tests.py +++ b/evennia/contrib/game_systems/buffs/tests.py @@ -130,6 +130,10 @@ class TestBuffsAndHandler(EvenniaTest): # remove handler.remove("tmb") self.assertFalse(self.testobj.db.buffs.get("tmb")) + # remove stacks + handler.add(_TestModBuff, stacks=3) + handler.remove("tmb", stacks=3) + self.assertFalse(self.testobj.db.buffs.get("tmb")) # remove by type handler.add(_TestModBuff) handler.remove_by_type(_TestModBuff) @@ -149,7 +153,7 @@ class TestBuffsAndHandler(EvenniaTest): # remove all handler.add(_TestModBuff) handler.clear() - self.assertFalse(self.testobj.db.buffs.get("tmb")) + self.assertFalse(self.testobj.buffs.all) def test_getters(self): """tests all built-in getters""" @@ -172,8 +176,6 @@ class TestBuffsAndHandler(EvenniaTest): 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)) From a95dea471f1a6ac98c46754747ad11c3704e9251 Mon Sep 17 00:00:00 2001 From: Tegiminis Date: Sun, 24 Jul 2022 23:19:36 -0700 Subject: [PATCH 05/18] readme review, made non-unique/refresh namespace less prone to collision --- evennia/contrib/game_systems/buffs/README.md | 137 ++++++++++--------- evennia/contrib/game_systems/buffs/buff.py | 3 +- 2 files changed, 74 insertions(+), 66 deletions(-) diff --git a/evennia/contrib/game_systems/buffs/README.md b/evennia/contrib/game_systems/buffs/README.md index eb0b81d2ea..cddccff25d 100644 --- a/evennia/contrib/game_systems/buffs/README.md +++ b/evennia/contrib/game_systems/buffs/README.md @@ -5,12 +5,13 @@ Contribution by Tegiminis 2022 A buff is a timed object, attached to a game entity. It is capable of modifying values, triggering code, or both. It is a common design pattern in RPGs, particularly action games. -This contrib offers you: -- A buff handler to apply to your objects (`BuffHandler`). -- A buff class to extend from to create your own buffs (`BaseBuff`). -- A sample property class to show how to automatically check modifiers (`BuffableProperty`). -- A command which applies buffs (`CmdBuff`). -- Some sample buffs to learn from (`samplebuffs.py`). +Features: + +- `BuffHandler`: A buff handler to apply to your objects. +- `BaseBuff`: A buff class to extend from to create your own buffs. +- `BuffableProperty`: A sample property class to show how to automatically check modifiers. +- `CmdBuff`: A command which applies buffs. +- `samplebuffs.py`: Some sample buffs to learn from. ## Quick Start Assign the handler to a property on the object, like so. @@ -21,28 +22,30 @@ def buffs(self) -> BuffHandler: return BuffHandler(self) ``` -You may then call the handler to add or manipulate buffs. +You may then call the handler to add or manipulate buffs like so: `object.buffs. See **Using the Handler**. ### 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. +- `dbkey`: The string you wish to use as the attribute 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 playtime buffs when its owning object is unpuppeted. -> **Note**: 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 +> **Note**: If you enable autopausing, you MUST initialize the property in your owning object's +> `at_init` hook. 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) -``` +class BuffableObject(Object): + @lazy_property + def perks(self) -> BuffHandler: + return BuffHandler(self, dbkey='perks', autopause=True) -And initialize it by adding `self.perks` to the object's `at_init`. + def at_init(self): + self.perks +``` ## Using the Handler @@ -50,9 +53,9 @@ Here's how to make use of your new handler. ### Apply a Buff -Call the handler `add` method. This requires a class reference, and also contains a number of +Call the handler's `add` method. This requires a class reference, and also contains a number of 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 +in the buff's cache by passing a dictionary through the `to_cache` optional argument. This will not overwrite the normal values on the cache. ```python @@ -63,9 +66,12 @@ self.buffs.add(ReflectBuff, to_cache={'reflect': 0.5}) # A single stack of Refl 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` (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) +- `unique` (default: True) determines if this buff is unique; that is, only one of it exists on the object. -If both are `False`, the buff creates a random (rather than buff + source) key each time it is applied, and will never refresh. +The combination of these two booleans creates one of three kinds of keys: +- `Unique is True, Refresh is True/False`: The buff's default key. +- `Unique is False, Refresh is True`: The default key mixed with the applier's dbref. This makes the buff "unique-per-player", so you can refresh through reapplication. +- `Unique is False, Refresh is False`: The default key mixed with a randomized number. ### Get Buffs @@ -75,24 +81,24 @@ buffs after application, they are very useful. The handler's `check`/`trigger` m `get(key)` is the most basic getter. It returns a single buff instance, or `None` if the buff doesn't exist on the handler. It is also the only getter that returns a single buff instance, rather than a dictionary. -Grouped getters, listed below, return a dictionary of values in the format `{buffkey: instance}`. If you want to iterate over all these buffs, +Group getters, listed below, return a dictionary of values in the format `{buffkey: instance}`. If you want to iterate over all of these buffs, you should do so via the `dict.values()` method. - `get_all()` returns all buffs on this handler. You can also use the `handler.all` property. - `get_by_type(BuffClass)` returns buffs of the specified type. -- `get_by_stat(stat)` returns buffs with a `Mod` object of the specified `stat` string in their `mods` list. Used by `handler.check(stat)`. -- `get_by_trigger(trigger)` returns buffs with the specified trigger in their `triggers` list. Used by `handler.trigger(key)`. +- `get_by_stat(stat)` returns buffs with a `Mod` object of the specified `stat` string in their `mods` list. +- `get_by_trigger(string)` returns buffs with the specified string in their `triggers` list. - `get_by_source(Object)` returns buffs applied by the specified `source` object. - `get_by_cachevalue(key, value)` returns buffs with the matching `key: value` pair in their cache. `value` is optional. -All grouped getters besides `get_all()` can "slice" an existing dictionary through the optional `to_filter` argument. +All group getters besides `get_all()` can "slice" an existing dictionary through the optional `to_filter` argument. ```python dict1 = handler.get_by_type(Burned) # This finds all "Burned" buffs on the handler dict2 = handler.get_by_source(self, to_filter=dict1) # This filters dict1 to find buffs with the matching source ``` -> **Note**: Most of these getters also have an associated handler property. For example, `handler.effects` returns all buffs which can be triggered, which +> **Note**: Most of these getters also have an associated handler property. For example, `handler.effects` returns all buffs that can be triggered, which > is then iterated over by the `get_by_trigger` method. ### Remove Buffs @@ -103,7 +109,7 @@ There are also a number of remover methods. Generally speaking, these follow the - `clear()` removes all buffs. - `remove_by_type(BuffClass)` removes buffs of the specified type. - `remove_by_stat(stat)` removes buffs with a `Mod` object of the specified `stat` string in their `mods` list. -- `remove_by_trigger(trigger)` removes buffs with the specified trigger in their `triggers` list. +- `remove_by_trigger(string)` removes buffs with the specified string in their `triggers` list. - `remove_by_source(Object)` removes buffs applied by the specified source - `remove_by_cachevalue(key, value)` removes buffs with the matching `key: value` pair in their cache. `value` is optional. @@ -112,12 +118,13 @@ getters listed above. ```python to_remove = handler.get_by_trigger(trigger) # Finds all buffs with the specified trigger -for buff in to_remove.values(): buff.remove() # Removes all buffs in the to_remove dictionary via helper methods +for buff in to_remove.values(): # Removes all buffs in the to_remove dictionary via helper methods + buff.remove() ``` ### Check Modifiers -Call the handler `check(value, stat)` method wherever you want to see the modified value. +Call the handler `check(value, stat)` method when you want to see the modified value. This will return the `value`, modified by any relevant buffs on the handler's owner (identified by the `stat` string). @@ -130,21 +137,20 @@ def take_damage(self, source, damage): self.db.health -= _damage ``` -This method call the `at_pre_check` and `at_post_check` methods at the relevant points in the process. You can use to this make +This method calls the `at_pre_check` and `at_post_check` methods at the relevant points in the process. You can use to this make buffs that are reactive to being checked; for example, removing themselves, altering their values, or interacting with the game state. > **Note**: You can also trigger relevant buffs at the same time as you check them by ensuring the optional argument `trigger` is True in the `check` method. ### Trigger Buffs -Call the handler `trigger(string)` method wherever you want an event call. This -will call the `at_trigger` hook method on all buffs with the relevant trigger. +Call the handler's `trigger(string)` method when you want an event call. This will call the `at_trigger` hook method on all buffs with the relevant trigger `string`. 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 that might look like this: ```python -def Detonate(BaseBuff): +class Detonate(BaseBuff): ... triggers = ['take_damage'] def at_trigger(self, trigger, *args, **kwargs) @@ -163,7 +169,7 @@ doing so on an event trigger, they do so on a periodic tick. A common use case f or a heal over time. ```python -def Poison(BaseBuff): +class Poison(BaseBuff): ... tickrate = 5 def at_tick(self, initial=True, *args, **kwargs): @@ -176,7 +182,7 @@ def Poison(BaseBuff): ) ``` -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` +To make a buff ticking, 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! > **Note**: Ticking buffs always tick on initial application, when `initial` is `True`. If you don't want your hook to fire at that time, @@ -186,7 +192,7 @@ method. Once you add it to the handler, it starts ticking! 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 +Context is an important concept for this handler. Every method which checks, triggers, or ticks a buff passes this 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. @@ -202,12 +208,13 @@ def take_damage(attacker, damage): ``` Now we use the values that context passes to the buff kwargs to customize our logic. ```python -def ThornsBuff(BaseBuff): +class ThornsBuff(BaseBuff): ... triggers = ['taken_damage'] # This is the hook method on our thorns buff def at_trigger(self, trigger, attacker=None, damage=0, **kwargs): - if not attacker: return + if not attacker: + return attacker.db.health -= damage * 0.2 ``` Apply the buff, take damage, and watch the thorns buff do its work! @@ -222,21 +229,21 @@ However, there are a lot of individual moving parts to a buff. Here's a step-thr Regardless of any other functionality, all buffs have the following class attributes: - They have customizable `key`, `name`, and `flavor` strings. -- They have a `duration`, and automatically clean up at the end of it. Use -1 for infinite duration, and 0 to cleanup immediately. (default: -1) -- They can stack, if `maxstacks` is not equal to 1. If it's 0, the buff stacks forever. (default: 1) -- They can be `unique`, which determines if they have a unique namespace or not. (default: True) -- They can `refresh`, which resets the duration when stacked or reapplied. (default: True) -- They can be `playtime` buffs, where duration only counts down during active play. (default: False) +- They have a `duration` (float), and automatically clean-up at the end. Use -1 for infinite duration, and 0 to clean-up immediately. (default: -1) +- They can stack, if `maxstacks` (int) is not equal to 1. If it's 0, the buff stacks forever. (default: 1) +- They can be `unique` (bool), which determines if they have a unique namespace or not. (default: True) +- They can `refresh` (bool), which resets the duration when stacked or reapplied. (default: True) +- They can be `playtime` (bool) buffs, where duration only counts down during active play. (default: False) They also always store some useful mutable information about themselves in the cache: -- The `ref` class, which is the buff class path we use to construct the buff. -- The `start` timestamp of when the buff was applied. -- Their `source`, if specified; this allows you to track who or what applied the buff. -- The `prevtick` timestamp of the previous time this buff ticked. -- The current `duration`. This can vary from the class duration, as you might apply buffs with variable durations, or alter them. -- The number of `stacks` they have. -- Whether they are `paused` or not. Paused buffs do not clean up, modify values, tick, or fire any hook methods. +- `ref` (class): The buff class path we use to construct the buff. +- `start` (float): The timestamp of when the buff was applied. +- `source` (Object): If specified; this allows you to track who or what applied the buff. +- `prevtick` (float): The timestamp of the previous tick. +- `duration` (float): The cached duration. This can vary from the class duration, depending on if the duration has been modified (paused, extended, shortened, etc). +- `stacks` (int): How many stacks they have. +- `paused` (bool): Paused buffs do not clean up, modify values, tick, or fire any hook methods. You can always access the raw cache dictionary through the `cache` attribute on an instanced buff. This is grabbed when you get the buff through a handler method, so it may not always reflect recent changes you've made, depending on how you structure your buff calls. All of the above @@ -257,13 +264,13 @@ Mod objects consist of only four values, assigned by the constructor in this ord The most basic way to add a Mod to a buff is to do so in the buff class definition, like this: ```python -def DamageBuff(BaseBuff): +class 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. +never permanently change a stat modified by a buff. To remove the modification, simply remove the buff from the object. > **Note**: You can add your own modifier types by overloading the `_calculate_mods` method, which contains the basic modifier application logic. @@ -272,7 +279,7 @@ never permanently change a stat modified by a trait buff. To remove the modifica An advanced way to do mods is to generate them when the buff is initialized. This lets you create mods on the fly that are reactive to the game state. ```python -def GeneratedStatBuff(BaseBuff): +class GeneratedStatBuff(BaseBuff): ... def __init__(self, handler, buffkey, cache={}) -> None: super().__init__(handler, buffkey, cache) @@ -286,12 +293,12 @@ def GeneratedStatBuff(BaseBuff): 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 `at_trigger` methods. Buffs can have multiple triggers, and you can tell which trigger was fired by the `trigger` -argument in the method. +When the handler's `trigger` method is called, it searches all buffs on the handler for any with a matchingtrigger, +then calls their `at_trigger` hooks. Buffs can have multiple triggers, and you can tell which trigger was used by +the `trigger` argument in the hook. ```python -def AmplifyBuff(BaseBuff): +class AmplifyBuff(BaseBuff): triggers = ['damage', 'heal'] def at_trigger(self, trigger, **kwargs): @@ -301,11 +308,11 @@ def AmplifyBuff(BaseBuff): ### 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. +A buff which ticks isn't much different than one which triggers. You're still executing arbitrary hooks on +the buff class. To tick, the buff must have a `tickrate` of 1 or higher. ```python -def Poison(BaseBuff): +class Poison(BaseBuff): ... # this buff will tick 6 times between application and cleanup. duration = 30 @@ -313,7 +320,7 @@ def Poison(BaseBuff): def at_tick(self, initial, **kwargs): self.owner.take_damage(10) ``` -> **Note**: The buff always ticks once when applied. For this **first tick only**, `initial` will be True in the `at_tick` hook method. +> **Note**: The buff always ticks once when applied. For this **first tick only**, `initial` will be True in the `at_tick` hook method. `initial` will be False on subsequent ticks. Ticks utilize a persistent delay, so they should be pickleable. As long as you are not adding new properties to your buff class, this shouldn't be a concern. If you **are** adding new properties, try to ensure they do not end up with a circular code path to their object or handler, as this will cause pickling errors. @@ -329,10 +336,11 @@ as it returns a "truthy" value, the buff will apply itself. This is useful for m example, if you want a buff that makes the player take more damage when they are on fire: ```python -def FireSick(BaseBuff): +class FireSick(BaseBuff): ... def conditional(self, *args, **kwargs): - if self.owner.buffs.get_by_type(FireBuff): return True + if self.owner.buffs.get_by_type(FireBuff): + return True return False ``` @@ -341,8 +349,7 @@ conditionals are checked each tick. #### Helper Methods -Buff instances have a number of helper methods you can access either on the buff itself or wherever the buff is instances (typically through -handler getters) +Buff instances have a number of helper methods. - `remove`/`dispel`: Allows you to remove or dispel the buff. Calls `at_remove`/`at_dispel`, depending on optional arguments. - `pause`/`unpause`: Pauses and unpauses the buff. Calls `at_pause`/`at_unpause`. @@ -355,4 +362,4 @@ and unpause when the object the handler is attached to is puppetted or unpuppett although if you have less than 1 second of tick duration remaining, it will round up to 1s. > **Note**: 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. +> to your object's `at_pre/post_puppet/unpuppet` hooks. \ No newline at end of file diff --git a/evennia/contrib/game_systems/buffs/buff.py b/evennia/contrib/game_systems/buffs/buff.py index f1c3212644..a3c8ddf903 100644 --- a/evennia/contrib/game_systems/buffs/buff.py +++ b/evennia/contrib/game_systems/buffs/buff.py @@ -447,7 +447,8 @@ class BuffHandler(object): if source: mix = str(source.dbref).replace("#", "") elif not (buff.unique or buff.refresh) or not source: - mix = str(random() * 10000) + mix = "_ufrf" + str(int((random() * 999999) * 100000)) + buffkey = buff.key if buff.unique is True else buff.key + mix # Rules for applying over an existing buff From 65123c10c5d89210a20a6fed39d2b82bb54a6607 Mon Sep 17 00:00:00 2001 From: Tegiminis Date: Mon, 25 Jul 2022 12:01:36 -0700 Subject: [PATCH 06/18] Update README.md --- evennia/contrib/game_systems/buffs/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/evennia/contrib/game_systems/buffs/README.md b/evennia/contrib/game_systems/buffs/README.md index cddccff25d..aa2729db58 100644 --- a/evennia/contrib/game_systems/buffs/README.md +++ b/evennia/contrib/game_systems/buffs/README.md @@ -22,7 +22,7 @@ def buffs(self) -> BuffHandler: return BuffHandler(self) ``` -You may then call the handler to add or manipulate buffs like so: `object.buffs. See **Using the Handler**. +You may then call the handler to add or manipulate buffs like so: `object.buffs`. See **Using the Handler**. ### Customization @@ -362,4 +362,4 @@ and unpause when the object the handler is attached to is puppetted or unpuppett although if you have less than 1 second of tick duration remaining, it will round up to 1s. > **Note**: 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. \ No newline at end of file +> to your object's `at_pre/post_puppet/unpuppet` hooks. From 18edbce62445185463581cfcb0f50efa500fb7fb Mon Sep 17 00:00:00 2001 From: Andrew Bastien Date: Mon, 25 Jul 2022 17:16:00 -0400 Subject: [PATCH 07/18] adding at_server_init to at_server_startstop --- .../server/conf/at_server_startstop.py | 6 +++ evennia/server/server.py | 44 +++++++++++++------ evennia/settings_default.py | 2 + 3 files changed, 38 insertions(+), 14 deletions(-) diff --git a/evennia/game_template/server/conf/at_server_startstop.py b/evennia/game_template/server/conf/at_server_startstop.py index 98c29fa28a..52ffa660d1 100644 --- a/evennia/game_template/server/conf/at_server_startstop.py +++ b/evennia/game_template/server/conf/at_server_startstop.py @@ -7,6 +7,7 @@ allows for customizing the server operation as desired. This module must contain at least these global functions: +at_server_init() at_server_start() at_server_stop() at_server_reload_start() @@ -16,6 +17,11 @@ at_server_cold_stop() """ +def at_server_init(): + """ + This is called first as the server is starting up, regardless of how. + """ + pass def at_server_start(): """ diff --git a/evennia/server/server.py b/evennia/server/server.py index 4739d43ff3..90b2bf4972 100644 --- a/evennia/server/server.py +++ b/evennia/server/server.py @@ -46,8 +46,8 @@ _SA = object.__setattr__ # a file with a flag telling the server to restart after shutdown or not. SERVER_RESTART = os.path.join(settings.GAME_DIR, "server", "server.restart") -# module containing hook methods called during start_stop -SERVER_STARTSTOP_MODULE = mod_import(settings.AT_SERVER_STARTSTOP_MODULE) +# modules containing hook methods called during start_stop +SERVER_STARTSTOP_MODULES = [mod_import(m) for m in make_iter(settings.AT_SERVER_STARTSTOP_MODULE) if isinstance(m, str)] # modules containing plugin services SERVER_SERVICES_PLUGIN_MODULES = make_iter(settings.SERVER_SERVICES_PLUGIN_MODULES) @@ -413,6 +413,8 @@ class Evennia: for typeclass_db in TypedObject.__subclasses__() ] + self.at_server_init() + # call correct server hook based on start file value if mode == "reload": logger.log_msg("Server successfully reloaded.") @@ -525,14 +527,23 @@ class Evennia: # server start/stop hooks + def at_server_init(self): + """ + This is called first when the server is starting, before any other hooks, regardless of how it's starting. + """ + for m in SERVER_STARTSTOP_MODULES: + if hasattr(m, "at_server_init"): + m.at_server_init() + def at_server_start(self): """ This is called every time the server starts up, regardless of how it was shut down. """ - if SERVER_STARTSTOP_MODULE: - SERVER_STARTSTOP_MODULE.at_server_start() + for m in SERVER_STARTSTOP_MODULES: + if hasattr(m, "at_server_start"): + m.at_server_start() def at_server_stop(self): """ @@ -540,16 +551,18 @@ class Evennia: of it is fore a reload, reset or shutdown. """ - if SERVER_STARTSTOP_MODULE: - SERVER_STARTSTOP_MODULE.at_server_stop() + for m in SERVER_STARTSTOP_MODULES: + if hasattr(m, "at_server_stop"): + m.at_server_stop() def at_server_reload_start(self): """ This is called only when server starts back up after a reload. """ - if SERVER_STARTSTOP_MODULE: - SERVER_STARTSTOP_MODULE.at_server_reload_start() + for m in SERVER_STARTSTOP_MODULES: + if hasattr(m, "at_server_reload_start"): + m.at_server_reload_start() def at_post_portal_sync(self, mode): """ @@ -589,8 +602,9 @@ class Evennia: This is called only time the server stops before a reload. """ - if SERVER_STARTSTOP_MODULE: - SERVER_STARTSTOP_MODULE.at_server_reload_stop() + for m in SERVER_STARTSTOP_MODULES: + if hasattr(m, "at_server_reload_stop"): + m.at_server_reload_stop() def at_server_cold_start(self): """ @@ -618,16 +632,18 @@ class Evennia: if character: character.delete() guest.delete() - if SERVER_STARTSTOP_MODULE: - SERVER_STARTSTOP_MODULE.at_server_cold_start() + for m in SERVER_STARTSTOP_MODULES: + if hasattr(m, "at_server_cold_start"): + m.at_server_cold_start() def at_server_cold_stop(self): """ This is called only when the server goes down due to a shutdown or reset. """ - if SERVER_STARTSTOP_MODULE: - SERVER_STARTSTOP_MODULE.at_server_cold_stop() + for m in SERVER_STARTSTOP_MODULES: + if hasattr(m, "at_server_cold_stop"): + m.at_server_cold_stop() # ------------------------------------------------------------ diff --git a/evennia/settings_default.py b/evennia/settings_default.py index 964b255db8..68d1d87f05 100644 --- a/evennia/settings_default.py +++ b/evennia/settings_default.py @@ -389,6 +389,8 @@ AT_INITIAL_SETUP_HOOK_MODULE = "server.conf.at_initial_setup" # Module containing your custom at_server_start(), at_server_reload() and # at_server_stop() methods. These methods will be called every time # the server starts, reloads and resets/stops respectively. +# Now supports a list of python paths or a single string. +# If it's a list, each module's hooks will be called by list order. AT_SERVER_STARTSTOP_MODULE = "server.conf.at_server_startstop" # List of one or more module paths to modules containing a function start_ # plugin_services(application). This module will be called with the main From 5fa92d8b26996fecf378da6a5c79c19ef99788a8 Mon Sep 17 00:00:00 2001 From: Tegiminis Date: Tue, 26 Jul 2022 11:26:36 -0700 Subject: [PATCH 08/18] fixed refresh rule for unique buffs --- evennia/contrib/game_systems/buffs/buff.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/evennia/contrib/game_systems/buffs/buff.py b/evennia/contrib/game_systems/buffs/buff.py index a3c8ddf903..58bdcd1e8b 100644 --- a/evennia/contrib/game_systems/buffs/buff.py +++ b/evennia/contrib/game_systems/buffs/buff.py @@ -459,6 +459,9 @@ class BuffHandler(object): b["stacks"] = min(existing["stacks"] + stacks, buff.maxstacks) elif buff.maxstacks < 1: b["stacks"] = existing["stacks"] + stacks + # refresh rule for uniques + if not buff.refresh: + b["duration"] = existing["duration"] # 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) From 19b498626d82516a6f2f32ceddfe16bfa2fb2313 Mon Sep 17 00:00:00 2001 From: Tegiminis Date: Wed, 27 Jul 2022 10:58:07 -0700 Subject: [PATCH 09/18] added has() method, fixed None checks on properties --- evennia/contrib/game_systems/buffs/buff.py | 80 ++++++++++++++++----- evennia/contrib/game_systems/buffs/tests.py | 4 ++ 2 files changed, 66 insertions(+), 18 deletions(-) diff --git a/evennia/contrib/game_systems/buffs/buff.py b/evennia/contrib/game_systems/buffs/buff.py index 58bdcd1e8b..efaacba8fd 100644 --- a/evennia/contrib/game_systems/buffs/buff.py +++ b/evennia/contrib/game_systems/buffs/buff.py @@ -337,46 +337,63 @@ class BuffHandler(object): @property def traits(self): """All buffs on this handler that modify a stat.""" - _t = {k: buff for k, buff in self.get_all().items() if buff.mods} + _t = None + _cache = self.all + if _cache: + _t = {k: buff for k, buff in _cache.items() if buff.mods} return _t @property def effects(self): """All buffs on this handler that trigger off an event.""" - _e = {k: buff for k, buff in self.get_all().items() if buff.triggers} + _e = None + _cache = self.all + if _cache: + _e = {k: buff for k, buff in _cache.items() if buff.triggers} return _e @property def playtime(self): """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} + _pt = None + _cache = self.all + if _cache: + _pt = {k: buff for k, buff in _cache.items() if buff.playtime} return _pt @property def paused(self): """All buffs on this handler that are paused.""" - _p = {k: buff for k, buff in self.get_all().items() if buff.paused} + _p = None + _cache = self.all + if _cache: + _p = {k: buff for k, buff in _cache.items() if buff.paused} return _p @property def expired(self): """All buffs on this handler that have expired (no duration or no stacks).""" + _e = None _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) + if _cache: + _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: buff for k, buff in self.get_all().items() if buff.visible} + _v = None + _cache = self.all + if _cache: + _v = {k: buff for k, buff in _cache.items() if buff.visible} return _v @property @@ -732,6 +749,26 @@ class BuffHandler(object): # endregion + def has(self, buff=None) -> bool: + """Checks if the specified buff type or key exists on the handler. + + Args: + buff: The buff to search for. This can be a string (the key) or a class reference (the buff type) + + Returns a bool. If no buff and no key is specified, returns False.""" + if not (isinstance(buff, type) or isinstance(buff, str)): + raise TypeError + + if isinstance(buff, str): + for k in self.buffcache.keys(): + if k == buff: + return True + if isinstance(buff, type): + for b in self.buffcache.values(): + if b.get("ref") == buff: + return True + return False + 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. @@ -811,7 +848,7 @@ class BuffHandler(object): """ if key in self.buffcache.keys(): # Mark the buff as paused - buff = dict(self.buffcache[key]) + buff = dict(self.buffcache.get(key)) if buff["paused"]: return if not context: @@ -849,7 +886,7 @@ class BuffHandler(object): 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.buffcache[key]) + buff = dict(self.buffcache.get(key)) if not buff["paused"]: return if not context: @@ -884,12 +921,15 @@ class BuffHandler(object): value: The value you want the new duration to be""" if key in self.buffcache.keys(): self.buffcache[key]["duration"] = value + return 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} - + _cache = self.visible + if not _cache: + return None + _flavor = {k: (buff.name, buff.flavor) for k, buff in _cache.items()} return _flavor def cleanup(self): @@ -912,6 +952,8 @@ class BuffHandler(object): if sender != self.owner: return buffs = self.playtime + if not buffs: + return for buff in buffs.values(): buff.pause() @@ -920,6 +962,8 @@ class BuffHandler(object): if sender != self.owner: return buffs = self.playtime + if not buffs: + return for buff in buffs.values(): buff.unpause() pass diff --git a/evennia/contrib/game_systems/buffs/tests.py b/evennia/contrib/game_systems/buffs/tests.py index 6c02d40be8..cb7753aaaf 100644 --- a/evennia/contrib/game_systems/buffs/tests.py +++ b/evennia/contrib/game_systems/buffs/tests.py @@ -127,6 +127,10 @@ class TestBuffsAndHandler(EvenniaTest): 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")) + # has + self.assertTrue(handler.has(_TestModBuff)) + self.assertTrue(handler.has("tmb")) + self.assertFalse(handler.has(_EmptyBuff)) # remove handler.remove("tmb") self.assertFalse(self.testobj.db.buffs.get("tmb")) From a30e1524052d3f7259f86cdc368341c977d46a4e Mon Sep 17 00:00:00 2001 From: Tegiminis Date: Wed, 27 Jul 2022 11:37:39 -0700 Subject: [PATCH 10/18] removed none checks (lol), fixed getter to return empty dict instead of None --- evennia/contrib/game_systems/buffs/buff.py | 68 +++++++--------------- 1 file changed, 22 insertions(+), 46 deletions(-) diff --git a/evennia/contrib/game_systems/buffs/buff.py b/evennia/contrib/game_systems/buffs/buff.py index efaacba8fd..a65afb39f2 100644 --- a/evennia/contrib/game_systems/buffs/buff.py +++ b/evennia/contrib/game_systems/buffs/buff.py @@ -337,63 +337,51 @@ class BuffHandler(object): @property def traits(self): """All buffs on this handler that modify a stat.""" - _t = None _cache = self.all - if _cache: - _t = {k: buff for k, buff in _cache.items() if buff.mods} + _t = {k: buff for k, buff in _cache.items() if buff.mods} return _t @property def effects(self): """All buffs on this handler that trigger off an event.""" - _e = None _cache = self.all - if _cache: - _e = {k: buff for k, buff in _cache.items() if buff.triggers} + _e = {k: buff for k, buff in _cache.items() if buff.triggers} return _e @property def playtime(self): """All buffs on this handler that only count down during active playtime.""" - _pt = None _cache = self.all - if _cache: - _pt = {k: buff for k, buff in _cache.items() if buff.playtime} + _pt = {k: buff for k, buff in _cache.items() if buff.playtime} return _pt @property def paused(self): """All buffs on this handler that are paused.""" - _p = None _cache = self.all - if _cache: - _p = {k: buff for k, buff in _cache.items() if buff.paused} + _p = {k: buff for k, buff in _cache.items() if buff.paused} return _p @property def expired(self): """All buffs on this handler that have expired (no duration or no stacks).""" - _e = None _cache = self.all - if _cache: - _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) + _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 = None _cache = self.all - if _cache: - _v = {k: buff for k, buff in _cache.items() if buff.visible} + _v = {k: buff for k, buff in _cache.items() if buff.visible} return _v @property @@ -516,7 +504,7 @@ class BuffHandler(object): if not context: context = {} if key not in self.buffcache: - return None + return buff: BaseBuff = self.buffcache[key] instance: BaseBuff = buff["ref"](self, key, buff) @@ -555,7 +543,7 @@ class BuffHandler(object): """ _remove = self.get_by_type(bufftype) if not _remove: - return None + return self._remove_via_dict(_remove, loud, dispel, expire, context) def remove_by_stat( @@ -577,7 +565,7 @@ class BuffHandler(object): """ _remove = self.get_by_stat(stat) if not _remove: - return None + return self._remove_via_dict(_remove, loud, dispel, expire, context) def remove_by_trigger( @@ -599,7 +587,7 @@ class BuffHandler(object): """ _remove = self.get_by_trigger(trigger) if not _remove: - return None + return self._remove_via_dict(_remove, loud, dispel, expire, context) def remove_by_source( @@ -621,7 +609,7 @@ class BuffHandler(object): """ _remove = self.get_by_source(source) if not _remove: - return None + return self._remove_via_dict(_remove, loud, dispel, expire, context) def remove_by_cachevalue( @@ -645,7 +633,7 @@ class BuffHandler(object): """ _remove = self.get_by_cachevalue(key, value) if not _remove: - return None + return self._remove_via_dict(_remove, loud, dispel, expire, context) def clear(self, loud=True, dispel=False, expire=False, context=None): @@ -671,7 +659,7 @@ class BuffHandler(object): """Returns a dictionary of instanced buffs (all of them) on this handler in the format {buffkey: instance}""" _cache = dict(self.buffcache) if not _cache: - return None + return {} return {k: buff["ref"](self, k, buff) for k, buff in _cache.items()} def get_by_type(self, buff: BaseBuff, to_filter=None): @@ -683,8 +671,6 @@ class BuffHandler(object): 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 - if not _cache: - return None return {k: _buff for k, _buff in _cache.items() if isinstance(_buff, buff)} def get_by_stat(self, stat: str, to_filter=None): @@ -696,8 +682,6 @@ class BuffHandler(object): 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 buffs = {k: buff for k, buff in _cache.items() for m in buff.mods if m.stat == stat} return buffs @@ -710,8 +694,6 @@ class BuffHandler(object): 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 _cache: - return None buffs = {k: buff for k, buff in _cache.items() if trigger in buff.triggers} return buffs @@ -724,8 +706,6 @@ class BuffHandler(object): 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 - if not _cache: - return None buffs = {k: buff for k, buff in _cache.items() if buff.source == source} return buffs @@ -739,8 +719,6 @@ class BuffHandler(object): 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 _cache: - return None if not value: buffs = {k: buff for k, buff in _cache.items() if buff.cache.get(key)} elif value: @@ -822,7 +800,7 @@ class BuffHandler(object): self.cleanup() _effects = self.get_by_trigger(trigger) if not _effects: - return None + return if not context: context = {} @@ -927,8 +905,6 @@ class BuffHandler(object): """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() _cache = self.visible - if not _cache: - return None _flavor = {k: (buff.name, buff.flavor) for k, buff in _cache.items()} return _flavor From 2a1b2cf3cb26eefa18f967976196c1a5182cdf1f Mon Sep 17 00:00:00 2001 From: Andrew Bastien Date: Tue, 2 Aug 2022 10:44:25 -0400 Subject: [PATCH 11/18] PEP8 compliance improvement. --- evennia/server/server.py | 45 ++++++++++++++++++++-------------------- 1 file changed, 23 insertions(+), 22 deletions(-) diff --git a/evennia/server/server.py b/evennia/server/server.py index 90b2bf4972..ee597e3360 100644 --- a/evennia/server/server.py +++ b/evennia/server/server.py @@ -47,7 +47,8 @@ _SA = object.__setattr__ SERVER_RESTART = os.path.join(settings.GAME_DIR, "server", "server.restart") # modules containing hook methods called during start_stop -SERVER_STARTSTOP_MODULES = [mod_import(m) for m in make_iter(settings.AT_SERVER_STARTSTOP_MODULE) if isinstance(m, str)] +SERVER_STARTSTOP_MODULES = [mod_import(mod) for mod in make_iter(settings.AT_SERVER_STARTSTOP_MODULE) + if isinstance(mod, str)] # modules containing plugin services SERVER_SERVICES_PLUGIN_MODULES = make_iter(settings.SERVER_SERVICES_PLUGIN_MODULES) @@ -531,9 +532,9 @@ class Evennia: """ This is called first when the server is starting, before any other hooks, regardless of how it's starting. """ - for m in SERVER_STARTSTOP_MODULES: - if hasattr(m, "at_server_init"): - m.at_server_init() + for mod in SERVER_STARTSTOP_MODULES: + if hasattr(mod, "at_server_init"): + mod.at_server_init() def at_server_start(self): """ @@ -541,9 +542,9 @@ class Evennia: how it was shut down. """ - for m in SERVER_STARTSTOP_MODULES: - if hasattr(m, "at_server_start"): - m.at_server_start() + for mod in SERVER_STARTSTOP_MODULES: + if hasattr(mod, "at_server_start"): + mod.at_server_start() def at_server_stop(self): """ @@ -551,18 +552,18 @@ class Evennia: of it is fore a reload, reset or shutdown. """ - for m in SERVER_STARTSTOP_MODULES: - if hasattr(m, "at_server_stop"): - m.at_server_stop() + for mod in SERVER_STARTSTOP_MODULES: + if hasattr(mod, "at_server_stop"): + mod.at_server_stop() def at_server_reload_start(self): """ This is called only when server starts back up after a reload. """ - for m in SERVER_STARTSTOP_MODULES: - if hasattr(m, "at_server_reload_start"): - m.at_server_reload_start() + for mod in SERVER_STARTSTOP_MODULES: + if hasattr(mod, "at_server_reload_start"): + mod.at_server_reload_start() def at_post_portal_sync(self, mode): """ @@ -602,9 +603,9 @@ class Evennia: This is called only time the server stops before a reload. """ - for m in SERVER_STARTSTOP_MODULES: - if hasattr(m, "at_server_reload_stop"): - m.at_server_reload_stop() + for mod in SERVER_STARTSTOP_MODULES: + if hasattr(mod, "at_server_reload_stop"): + mod.at_server_reload_stop() def at_server_cold_start(self): """ @@ -632,18 +633,18 @@ class Evennia: if character: character.delete() guest.delete() - for m in SERVER_STARTSTOP_MODULES: - if hasattr(m, "at_server_cold_start"): - m.at_server_cold_start() + for mod in SERVER_STARTSTOP_MODULES: + if hasattr(mod, "at_server_cold_start"): + mod.at_server_cold_start() def at_server_cold_stop(self): """ This is called only when the server goes down due to a shutdown or reset. """ - for m in SERVER_STARTSTOP_MODULES: - if hasattr(m, "at_server_cold_stop"): - m.at_server_cold_stop() + for mod in SERVER_STARTSTOP_MODULES: + if hasattr(mod, "at_server_cold_stop"): + mod.at_server_cold_stop() # ------------------------------------------------------------ From 07ebb4a60b7cf8c5ae7b2d0311f196ad67485699 Mon Sep 17 00:00:00 2001 From: Alessandro Ogier Date: Wed, 27 Jul 2022 19:01:32 +0200 Subject: [PATCH 12/18] case sensitive set dict keys --- evennia/commands/default/building.py | 25 ++++++++++++++++++++- evennia/commands/default/tests.py | 33 ++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/evennia/commands/default/building.py b/evennia/commands/default/building.py index ccfe33f5b0..cc2e5d24bf 100644 --- a/evennia/commands/default/building.py +++ b/evennia/commands/default/building.py @@ -35,6 +35,8 @@ COMMAND_DEFAULT_CLASS = class_from_module(settings.COMMAND_DEFAULT_CLASS) _FUNCPARSER = None _ATTRFUNCPARSER = None +_KEY_REGEX = re.compile(r"(?P.*?)(?P(\[.*\]\ *)+)?$") + # limit symbol import for API __all__ = ( "ObjManipCommand", @@ -126,7 +128,28 @@ class ObjManipCommand(COMMAND_DEFAULT_CLASS): aliases = [alias.strip() for alias in aliases.split(";") if alias.strip()] if "/" in objdef: objdef, attrs = [part.strip() for part in objdef.split("/", 1)] - attrs = [part.strip().lower() for part in attrs.split("/") if part.strip()] + _attrs = [] + + # Should an attribute key is specified, ie. we're working + # on a dict, what we want is to lowercase attribute name + # as usual but to preserve dict key case as one would + # expect: + # + # set box/MyAttr = {'FooBar': 1} + # Created attribute box/myattr [category:None] = {'FooBar': 1} + # set box/MyAttr['FooBar'] = 2 + # Modified attribute box/myattr [category:None] = {'FooBar': 2} + for match in ( + match + for part in map(str.strip, attrs.split("/")) + if part and (match := _KEY_REGEX.match(part.strip())) + ): + attr = match.group("attr").lower() + # reappend untouched key, if present + if match.group("key"): + attr += match.group("key") + _attrs.append(attr) + attrs = _attrs # store data obj_defs[iside].append({"name": objdef, "option": option, "aliases": aliases}) obj_attrs[iside].append({"name": objdef, "attrs": attrs}) diff --git a/evennia/commands/default/tests.py b/evennia/commands/default/tests.py index 32f4a882f5..3d677e978e 100644 --- a/evennia/commands/default/tests.py +++ b/evennia/commands/default/tests.py @@ -957,6 +957,39 @@ class TestBuilding(BaseEvenniaCommandTest): "{'one': 99, 'three': 3, '+': 42, '+1': 33}", ) + # dict - case sensitive keys + + self.call( + building.CmdSetAttribute(), + "Obj/test_case = {'FooBar': 1}", + "Created attribute Obj/test_case [category:None] = {'FooBar': 1}", + ) + self.call( + building.CmdSetAttribute(), + "Obj/test_case['FooBar'] = 2", + "Modified attribute Obj/test_case [category:None] = {'FooBar': 2}", + ) + self.call( + building.CmdSetAttribute(), + "Obj/test_case", + "Attribute Obj/test_case [category:None] = {'FooBar': 2}", + ) + self.call( + building.CmdSetAttribute(), + "Obj/test_case['FooBar'] = {'BarBaz': 1}", + "Modified attribute Obj/test_case [category:None] = {'FooBar': {'BarBaz': 1}}", + ) + self.call( + building.CmdSetAttribute(), + "Obj/test_case['FooBar']['BarBaz'] = 2", + "Modified attribute Obj/test_case [category:None] = {'FooBar': {'BarBaz': 2}}", + ) + self.call( + building.CmdSetAttribute(), + "Obj/test_case", + "Attribute Obj/test_case [category:None] = {'FooBar': {'BarBaz': 2}}", + ) + # tuple self.call( building.CmdSetAttribute(), From 7dfbc12d7b67a22eaaf0000eeaa7b236020c058f Mon Sep 17 00:00:00 2001 From: Tegiminis Date: Tue, 2 Aug 2022 12:00:44 -0700 Subject: [PATCH 13/18] changed module location, mocked out delay calls --- .../{game_systems => rpg}/buffs/README.md | 0 .../{game_systems => rpg}/buffs/__init__.py | 0 .../{game_systems => rpg}/buffs/buff.py | 4 +- .../buffs/samplebuffs.py | 0 .../{game_systems => rpg}/buffs/tests.py | 37 ++++++++++++++++--- 5 files changed, 35 insertions(+), 6 deletions(-) rename evennia/contrib/{game_systems => rpg}/buffs/README.md (100%) rename evennia/contrib/{game_systems => rpg}/buffs/__init__.py (100%) rename evennia/contrib/{game_systems => rpg}/buffs/buff.py (99%) rename evennia/contrib/{game_systems => rpg}/buffs/samplebuffs.py (100%) rename evennia/contrib/{game_systems => rpg}/buffs/tests.py (91%) diff --git a/evennia/contrib/game_systems/buffs/README.md b/evennia/contrib/rpg/buffs/README.md similarity index 100% rename from evennia/contrib/game_systems/buffs/README.md rename to evennia/contrib/rpg/buffs/README.md diff --git a/evennia/contrib/game_systems/buffs/__init__.py b/evennia/contrib/rpg/buffs/__init__.py similarity index 100% rename from evennia/contrib/game_systems/buffs/__init__.py rename to evennia/contrib/rpg/buffs/__init__.py diff --git a/evennia/contrib/game_systems/buffs/buff.py b/evennia/contrib/rpg/buffs/buff.py similarity index 99% rename from evennia/contrib/game_systems/buffs/buff.py rename to evennia/contrib/rpg/buffs/buff.py index a65afb39f2..01640366f5 100644 --- a/evennia/contrib/game_systems/buffs/buff.py +++ b/evennia/contrib/rpg/buffs/buff.py @@ -294,7 +294,7 @@ class Mod: self.perstack = perstack -class BuffHandler(object): +class BuffHandler: ownerref = None dbkey = "buffs" @@ -734,6 +734,8 @@ class BuffHandler(object): buff: The buff to search for. This can be a string (the key) or a class reference (the buff type) Returns a bool. If no buff and no key is specified, returns False.""" + if not buff: + return False if not (isinstance(buff, type) or isinstance(buff, str)): raise TypeError diff --git a/evennia/contrib/game_systems/buffs/samplebuffs.py b/evennia/contrib/rpg/buffs/samplebuffs.py similarity index 100% rename from evennia/contrib/game_systems/buffs/samplebuffs.py rename to evennia/contrib/rpg/buffs/samplebuffs.py diff --git a/evennia/contrib/game_systems/buffs/tests.py b/evennia/contrib/rpg/buffs/tests.py similarity index 91% rename from evennia/contrib/game_systems/buffs/tests.py rename to evennia/contrib/rpg/buffs/tests.py index cb7753aaaf..824888b361 100644 --- a/evennia/contrib/game_systems/buffs/tests.py +++ b/evennia/contrib/rpg/buffs/tests.py @@ -1,14 +1,16 @@ -# in a module tests.py somewhere i your game dir -from unittest.mock import Mock, patch +""" +Tests for the buff system contrib +""" +from unittest.mock import Mock, call, 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 +from evennia.contrib.rpg.buffs import buff + class _EmptyBuff(BaseBuff): pass @@ -117,6 +119,7 @@ class TestBuffsAndHandler(EvenniaTest): del self.testobj super().tearDown() + @patch("evennia.contrib.rpg.buffs.buff.utils.delay", new=Mock()) def test_addremove(self): """tests adding and removing buffs""" # setup @@ -159,6 +162,7 @@ class TestBuffsAndHandler(EvenniaTest): handler.clear() self.assertFalse(self.testobj.buffs.all) + @patch("evennia.contrib.rpg.buffs.buff.utils.delay", new=Mock()) def test_getters(self): """tests all built-in getters""" # setup @@ -184,6 +188,7 @@ class TestBuffsAndHandler(EvenniaTest): self.assertTrue("ttb" in handler.get_by_cachevalue("ttbcache")) self.assertTrue("ttb" in handler.get_by_cachevalue("ttbcache", True)) + @patch("evennia.contrib.rpg.buffs.buff.utils.delay", new=Mock()) def test_details(self): """tests that buff details like name and flavor are correct""" handler: BuffHandler = self.testobj.buffs @@ -192,6 +197,7 @@ class TestBuffsAndHandler(EvenniaTest): self.assertEqual(handler.get("tmb").flavor, "modderbuff") self.assertEqual(handler.get("ttb").name, "ttb") + @patch("evennia.contrib.rpg.buffs.buff.utils.delay", new=Mock()) def test_modify(self): """tests to ensure that values are modified correctly, and stack across mods""" # setup @@ -226,6 +232,7 @@ class TestBuffsAndHandler(EvenniaTest): self.assertEqual(handler.check(_stat1, "stat1"), 30) self.assertEqual(handler.check(_stat2, "stat2"), 20) + @patch("evennia.contrib.rpg.buffs.buff.utils.delay", new=Mock()) def test_trigger(self): """tests to ensure triggers correctly fire""" # setup @@ -242,6 +249,7 @@ class TestBuffsAndHandler(EvenniaTest): self.assertTrue(self.testobj.db.triggertest1) self.assertTrue(self.testobj.db.triggertest2) + @patch("evennia.contrib.rpg.buffs.buff.utils.delay", new=Mock()) def test_context_conditional(self): """tests to ensure context is passed to buffs, and also tests conditionals""" # setup @@ -269,6 +277,7 @@ class TestBuffsAndHandler(EvenniaTest): self.assertEqual(self.testobj.db.att, self.obj2) self.assertEqual(self.testobj.db.dmg, 5) + @patch("evennia.contrib.rpg.buffs.buff.utils.delay", new=Mock()) def test_complex(self): """tests a complex mod (conditionals, multiple triggers/mods)""" # setup @@ -310,11 +319,26 @@ class TestBuffsAndHandler(EvenniaTest): handler.check(self.testobj.db.comtwo, "com2", context=self.testobj.db.comtext), 100 ) - def test_timing(self): + @patch("evennia.contrib.rpg.buffs.buff.utils.delay") + def test_timing(self, mock_delay: Mock): """tests timing-related features, such as ticking and duration""" # setup handler: BuffHandler = self.testobj.buffs + mock_delay.side_effect = [None, handler.cleanup] handler.add(_TestTimeBuff) + calls = [ + call( + 1, + buff.tick_buff, + handler=handler, + buffkey="ttib", + context={}, + initial=False, + persistent=True, + ), + call(5, handler.cleanup, persistent=True), + ] + mock_delay.assert_has_calls(calls) self.testobj.db.timetest, self.testobj.db.ticktest = 1, False # test duration and ticking _instance = handler.get("ttib") @@ -328,6 +352,7 @@ class TestBuffsAndHandler(EvenniaTest): handler.cleanup() self.assertFalse(handler.get("ttib"), None) + @patch("evennia.contrib.rpg.buffs.buff.utils.delay", new=Mock()) def test_buffableproperty(self): """tests buffable properties""" # setup @@ -336,6 +361,7 @@ class TestBuffsAndHandler(EvenniaTest): self.testobj.buffs.remove("tmb") self.assertEqual(self.testobj.stat1, 10) + @patch("evennia.contrib.rpg.buffs.buff.utils.delay", new=Mock()) def test_stresstest(self): """tests large amounts of buffs, and related removal methods""" # setup @@ -349,6 +375,7 @@ class TestBuffsAndHandler(EvenniaTest): self.testobj.buffs.clear() self.assertFalse(self.testobj.buffs.all) + @patch("evennia.contrib.rpg.buffs.buff.utils.delay", new=Mock()) def test_modgen(self): """test generating mods on the fly""" # setup From 03425df66929d6b5847bc8e9d8c014aa2dd30e53 Mon Sep 17 00:00:00 2001 From: Griatch Date: Tue, 2 Aug 2022 21:46:16 +0200 Subject: [PATCH 14/18] Better phrasing in settings, update CHANGELOG --- CHANGELOG.md | 3 +++ evennia/settings_default.py | 16 ++++++++-------- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 569963f01e..2d15be0167 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -181,6 +181,9 @@ Up requirements to Django 4.0+, Twisted 22+, Python 3.9 or 3.10 to better match how search works elsewhere (volund) - The `.at_traverse` hook now receives a `exit_obj` kwarg, linking back to the exit triggering the hook (volund) +- Contrib `buffs` for managing temporary and permanent RPG status buffs effects (tegiminis) +- New `at_server_init()` hook called before all other startup hooks for all + startup modes. Used for more generic overriding (volund) ## Evennia 0.9.5 diff --git a/evennia/settings_default.py b/evennia/settings_default.py index 68d1d87f05..abb7bf64d6 100644 --- a/evennia/settings_default.py +++ b/evennia/settings_default.py @@ -12,12 +12,12 @@ value - which may change as Evennia is developed. This way you can always be sure of what you have changed and what is default behaviour. """ -from django.contrib.messages import constants as messages -from django.urls import reverse_lazy - import os import sys +from django.contrib.messages import constants as messages +from django.urls import reverse_lazy + ###################################################################### # Evennia base server config ###################################################################### @@ -386,11 +386,11 @@ INITIAL_SETUP_MODULE = "evennia.server.initial_setup" # the server's initial setup sequence (the very first startup of the system). # The check will fail quietly if module doesn't exist or fails to load. AT_INITIAL_SETUP_HOOK_MODULE = "server.conf.at_initial_setup" -# Module containing your custom at_server_start(), at_server_reload() and -# at_server_stop() methods. These methods will be called every time -# the server starts, reloads and resets/stops respectively. -# Now supports a list of python paths or a single string. -# If it's a list, each module's hooks will be called by list order. +# Module(s) containing custom at_server_init(), at_server_start(), +# at_server_reload() and at_server_stop() methods. These methods will be called +# every time the server starts, reloads and resets/stops +# respectively. Can be given as a single path or a list of paths. If a list, +# each module's hooks will be called in list order. AT_SERVER_STARTSTOP_MODULE = "server.conf.at_server_startstop" # List of one or more module paths to modules containing a function start_ # plugin_services(application). This module will be called with the main From 7d23147359899345e8abc71ccd6e52e7e8f3e431 Mon Sep 17 00:00:00 2001 From: Alessandro Ogier Date: Wed, 3 Aug 2022 18:55:28 +0200 Subject: [PATCH 15/18] enable dependencies cache in CI --- .github/workflows/github_action_build_docs.yml | 3 ++- .github/workflows/github_action_test_suite.yml | 6 +++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/workflows/github_action_build_docs.yml b/.github/workflows/github_action_build_docs.yml index 5fbe8e8bd6..2dce47873f 100644 --- a/.github/workflows/github_action_build_docs.yml +++ b/.github/workflows/github_action_build_docs.yml @@ -30,9 +30,10 @@ jobs: uses: actions/checkout@v2 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v2 + uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} + cache: pip - name: Install doc-building dependencies run: | diff --git a/.github/workflows/github_action_test_suite.yml b/.github/workflows/github_action_test_suite.yml index a8d7919cc5..c4923be185 100644 --- a/.github/workflows/github_action_test_suite.yml +++ b/.github/workflows/github_action_test_suite.yml @@ -92,9 +92,13 @@ jobs: run: docker ps -a - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v2 + uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} + cache: pip + cache-dependency-path: | + requirements.txt + requirements_extra.txt - name: Install package dependencies run: | From 6463d087ea54beafc5482e3ab7ae7f5787476cbc Mon Sep 17 00:00:00 2001 From: Alessandro Ogier Date: Wed, 3 Aug 2022 19:06:03 +0200 Subject: [PATCH 16/18] only cover 3.10/sqlite --- .github/workflows/github_action_test_suite.yml | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/.github/workflows/github_action_test_suite.yml b/.github/workflows/github_action_test_suite.yml index a8d7919cc5..041a8fa603 100644 --- a/.github/workflows/github_action_test_suite.yml +++ b/.github/workflows/github_action_test_suite.yml @@ -118,9 +118,10 @@ jobs: evennia migrate evennia collectstatic --noinput - - name: Run test suite + - name: Run test suite with coverage + if: matrix.TESTING_DB == 'sqlite3' && matrix.python-version == '3.10' + working-directory: testing_mygame run: | - cd testing_mygame coverage run \ --source=../evennia \ --omit=*/migrations/*,*/urls.py,*/test*.py,*.sh,*.txt,*.md,*.pyc,*.service \ @@ -132,6 +133,17 @@ jobs: evennia coverage xml + - name: Run test suite + if: matrix.TESTING_DB != 'sqlite3' || matrix.python-version != '3.10' + working-directory: testing_mygame + run: | + evennia test \ + --settings=settings \ + --keepdb \ + --parallel 4 \ + --timing \ + evennia + # we only want to run coverall/codacy once, so we only do it for one of the matrix combinations # it's also not critical if pushing to either service fails (happens for PRs since env is not # available outside of the evennia org) From 1ab14d33e4062d392e4ac98db79a8cf714d95201 Mon Sep 17 00:00:00 2001 From: Griatch Date: Wed, 3 Aug 2022 22:31:10 +0200 Subject: [PATCH 17/18] Fix typo in xyzgrid error message --- evennia/contrib/grid/xyzgrid/commands.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/evennia/contrib/grid/xyzgrid/commands.py b/evennia/contrib/grid/xyzgrid/commands.py index a670c5ce67..fe90da8e40 100644 --- a/evennia/contrib/grid/xyzgrid/commands.py +++ b/evennia/contrib/grid/xyzgrid/commands.py @@ -8,14 +8,14 @@ the commands with XYZ-aware equivalents. """ from collections import namedtuple + from django.conf import settings -from evennia import InterruptCommand -from evennia import default_cmds, CmdSet +from evennia import CmdSet, InterruptCommand, default_cmds from evennia.commands.default import building -from evennia.contrib.grid.xyzgrid.xyzroom import XYZRoom from evennia.contrib.grid.xyzgrid.xyzgrid import get_xyzgrid +from evennia.contrib.grid.xyzgrid.xyzroom import XYZRoom from evennia.utils import ansi -from evennia.utils.utils import list_to_string, class_from_module, delay +from evennia.utils.utils import class_from_module, delay, list_to_string COMMAND_DEFAULT_CLASS = class_from_module(settings.COMMAND_DEFAULT_CLASS) @@ -159,7 +159,7 @@ class CmdXYZOpen(building.CmdOpen): try: self.destination = XYZRoom.objects.get_xyz(xyz=(X, Y, Z)) except XYZRoom.DoesNotExist: - self.caller.msg("Found no target XYZRoom at ({X},{Y},{Y}).") + self.caller.msg(f"Found no target XYZRoom at ({X},{Y},{Z}).") raise InterruptCommand else: # regular search query From 7c1d11f474025f8541aae4f0a3b38e89dc990ac6 Mon Sep 17 00:00:00 2001 From: Griatch Date: Wed, 3 Aug 2022 23:01:09 +0200 Subject: [PATCH 18/18] Fix bug in xyzgrid search parser --- evennia/contrib/grid/xyzgrid/commands.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/evennia/contrib/grid/xyzgrid/commands.py b/evennia/contrib/grid/xyzgrid/commands.py index fe90da8e40..bb6d65061a 100644 --- a/evennia/contrib/grid/xyzgrid/commands.py +++ b/evennia/contrib/grid/xyzgrid/commands.py @@ -149,7 +149,8 @@ class CmdXYZOpen(building.CmdOpen): if all(char in self.rhs for char in ("(", ")", ",")): # search by (X,Y) or (X,Y,Z) - X, Y, *Z = self.rhs.split(",", 2) + inp = self.rhs.strip("()") + X, Y, *Z = inp.split(",", 2) if not Z: self.caller.msg("A full (X,Y,Z) coordinate must be given for the destination.") raise InterruptCommand