diff --git a/evennia/contrib/rpg/buffs/README.md b/evennia/contrib/rpg/buffs/README.md new file mode 100644 index 0000000000..aa2729db58 --- /dev/null +++ b/evennia/contrib/rpg/buffs/README.md @@ -0,0 +1,365 @@ +# Buffs + +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. + +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. + +```python +@lazy_property +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**. + +### Customization + +If you want to customize the handler, you can feed the constructor two arguments: +- `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 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 +class BuffableObject(Object): + @lazy_property + def perks(self) -> BuffHandler: + return BuffHandler(self, dbkey='perks', autopause=True) + + def at_init(self): + self.perks +``` + +## Using the Handler + +Here's how to make use of your new handler. + +### Apply a Buff + +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` optional 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 this buff is unique; that is, only one of it exists on the object. + +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 + +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. + +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. +- `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 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 that 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(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. + +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(): # Removes all buffs in the to_remove dictionary via helper methods + buff.remove() +``` + +### Check Modifiers + +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). + +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 +``` + +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'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 +class 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. + +> **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 on a periodic tick. A common use case for a buff like this is a poison, +or a heal over time. + +```python +class 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 + ) + ) +``` + +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, +> make sure to check the value of `initial` in your `at_tick` method. + +### Context + +Every important handler method optionally accepts a `context` dictionary. + +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. + +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 +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 + attacker.db.health -= damage * 0.2 +``` +Apply the buff, take damage, and watch the thorns buff do its work! + +## Creating New Buffs + +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 other functionality, all buffs have the following class attributes: + +- They have customizable `key`, `name`, and `flavor` strings. +- 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: + +- `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 +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 + +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: + +- `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) + +The most basic way to add a Mod to a buff is to do so in the buff class definition, like this: + +```python +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 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. + +#### 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 +class 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'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 +class 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 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 +class 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) +``` +> **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. + +### Extras + +Buffs have a grab-bag of extra functionality to let you add complexity to your designs. + +#### 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: + +```python +class FireSick(BaseBuff): + ... + def conditional(self, *args, **kwargs): + if self.owner.buffs.get_by_type(FireBuff): + return True + return False +``` + +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. + +#### Helper Methods + +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`. +- `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. + +> **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/rpg/buffs/__init__.py b/evennia/contrib/rpg/buffs/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/evennia/contrib/rpg/buffs/buff.py b/evennia/contrib/rpg/buffs/buff.py new file mode 100644 index 0000000000..01640366f5 --- /dev/null +++ b/evennia/contrib/rpg/buffs/buff.py @@ -0,0 +1,1102 @@ +""" +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, +a sample property class to show how to automatically check modifiers, some sample buffs to learn from, +and a command which applies buffs. + +## Installation +Assign the handler to a property on the object, like so. + +```python +@lazy_property +def buffs(self) -> BuffHandler: + return BuffHandler(self)``` + +## Using the Handler + +To make use of the handler, you will need: + +- 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. + +### 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. + +```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 +``` + +### 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: + +```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. + +```python +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 +``` + +### Tick + +Ticking a buff happens automatically once applied, as long as the buff's `tickrate` is more than 0. + +```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 + +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. + +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. + +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. + +""" + +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 + + +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. + + 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.""" + 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)""" + 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, 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.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 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, context=None): + """Helper method which removes this buff from its handler. Use dispel if you are dispelling it instead. + + 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 dispel(self, loud=True, delay=0, context=None): + """Helper method which dispels this buff (removes and calls at_dispel). + + 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 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 at_remove(self, *args, **kwargs): + """Hook function to run when this buff is removed from an object.""" + pass + + 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 at_expire(self, *args, **kwargs): + """Hook function to run when this buff expires from an object.""" + pass + + def at_pre_check(self, *args, **kwargs): + """Hook function to run before this buff's modifiers are checked.""" + pass + + 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.""" + pass + + 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 + + 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 + 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: + + 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) + + # region properties + @property + def owner(self): + """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 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.""" + _cache = self.all + _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.""" + _cache = self.all + _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.""" + _cache = self.all + _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.""" + _cache = self.all + _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).""" + _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.""" + _cache = self.all + _v = {k: buff for k, buff in _cache.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.""" + _a = self.get_all() + return _a + + # 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 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; 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 buff.cache: + b = dict(buff.cache) + if to_cache: + b.update(dict(to_cache)) + if stacks < 1: + stacks = min(1, buff.stacks) + + # Create the buff dict that holds a reference and all runtime information. + b.update( + { + "ref": buff, + "start": time.time(), + "duration": buff.duration, + "prevtick": time.time(), + "paused": False, + "stacks": stacks, + "source": source, + } + ) + + # Generate the buffkey from the object's dbref and the default buff key. + # This is the actual key the buff uses on the dictionary + buffkey = key + if not buffkey: + if source: + mix = str(source.dbref).replace("#", "") + elif not (buff.unique or buff.refresh) or not source: + 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 + 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 + # 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) + # Setting overloaded duration + if duration: + b["duration"] = duration + + # Apply the buff! + self.buffcache[buffkey] = b + + # Create the buff instance and run the on-application hook method + 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"], 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 + 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. + + Args: + 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 + + buff: BaseBuff = self.buffcache[key] + instance: BaseBuff = buff["ref"](self, key, buff) + + if loud: + if dispel: + instance.at_dispel(**context) + elif expire: + instance.at_expire(**context) + instance.at_remove(**context) + + del instance + if not stacks: + 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, + 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 + 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 + 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 + self._remove_via_dict(_remove, loud, dispel, expire, context) + + def remove_by_source( + self, + source, + loud=True, + dispel=False, + expire=False, + context=None, + ): + """Removes all buffs from the specified source from this object. + + 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 + self._remove_via_dict(_remove, loud, dispel, expire, context) + + 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. + + 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 + self._remove_via_dict(_remove, loud, dispel, expire, 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) + + # 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. + + 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) + if not _cache: + return {} + 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, 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 + 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 + 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, to_filter=None): + """Finds all buffs with the matching string in their triggers. + + Args: + trigger: The string identifier to find relevant buffs + 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 + 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): + """Find all buffs with the matching source. + + Args: + 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 + + # 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 buff: + return 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. + + 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 and conditional 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) + 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) + + # Run the "after check" functions on all relevant buffs + for buff in applied.values(): + buff: BaseBuff + 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 = 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) + if not _effects: + return + 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 _to_trigger.values(): + buff: BaseBuff + 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'. + + 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.get(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 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, 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.buffcache.get(key)) + if not buff["paused"]: + return + if not context: + context = {} + buff["paused"] = False + + # 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 instance.ticking: + utils.delay( + tickrate, tick_buff, handler=self, buffkey=key, initial=False, persistent=True + ) + return + + def set_duration(self, key, value): + """Sets the duration of the specified buff. + + 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 + 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() + _cache = self.visible + _flavor = {k: (buff.name, buff.flavor) for k, buff in _cache.items()} + return _flavor + + def cleanup(self): + """Removes expired buffs, ensures pause state is respected.""" + self._validate_state() + cleanup_buffs(self) + + # 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() + + def _pause_playtime(self, sender=owner, **kwargs): + """Pauses all playtime buffs when attached object is unpuppeted.""" + if sender != self.owner: + return + buffs = self.playtime + if not buffs: + return + 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 + if not buffs: + return + 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) * max(0, 1.0 + mult) + return final + + 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 = {} + if not buffs: + return + 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.""" + + def at_get(self, value, obj): + _value = obj.buffs.check(value, self._key) + 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 + 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 buffkey not in handler.buffcache.keys(): + return + if not context: + context = {} + + # Instantiate the buff and tickrate + 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 + + # 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, + buffkey=buffkey, + context=context, + initial=False, + persistent=True, + ) diff --git a/evennia/contrib/rpg/buffs/samplebuffs.py b/evennia/contrib/rpg/buffs/samplebuffs.py new file mode 100644 index 0000000000..457e647805 --- /dev/null +++ b/evennia/contrib/rpg/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.get("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/rpg/buffs/tests.py b/evennia/contrib/rpg/buffs/tests.py new file mode 100644 index 0000000000..824888b361 --- /dev/null +++ b/evennia/contrib/rpg/buffs/tests.py @@ -0,0 +1,393 @@ +""" +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 +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 + + +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 at_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 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"] + + 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 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" + maxstacks = 1 + tickrate = 1 + duration = 5 + mods = [Mod("timetest", "add", 665)] + + def at_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.testobj = create.create_object(BuffableObject, key="testobj") + + def tearDown(self): + """done after every test_* method below""" + self.testobj.buffs.clear() + 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 + handler: BuffHandler = self.testobj.buffs + # add + 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")) + # 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")) + # 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) + self.assertFalse(self.testobj.db.buffs.get("tmb")) + # remove by buff instance + handler.add(_TestModBuff) + 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.buffs.all) + + @patch("evennia.contrib.rpg.buffs.buff.utils.delay", new=Mock()) + def test_getters(self): + """tests all built-in getters""" + # setup + 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.assertTrue(isinstance(handler.get_by_stat("stat1")["tmb"], _TestModBuff)) + self.assertFalse(handler.get_by_stat("nullstat")) + # trigger getters + self.assertTrue("ttb" in handler.get_by_trigger("test1").keys()) + self.assertFalse("ttb" in handler.get_by_trigger("nulltrig").keys()) + # type getters + 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("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)) + + @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 + handler.add(_TestModBuff) + handler.add(_TestTrigBuff) + 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 + 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) + # 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) + + @patch("evennia.contrib.rpg.buffs.buff.utils.delay", new=Mock()) + def test_trigger(self): + """tests to ensure triggers correctly fire""" + # setup + handler: BuffHandler = self.testobj.buffs + handler.add(_TestTrigBuff) + # trigger buffs + 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) + + @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 + handler: BuffHandler = self.testobj.buffs + handler.add(_TestConBuff) + 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.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.testobj.db.att, None) + self.assertEqual(self.testobj.db.dmg, 0) + # test positive conditional + context passing + 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) + + @patch("evennia.contrib.rpg.buffs.buff.utils.delay", new=Mock()) + def test_complex(self): + """tests a complex mod (conditionals, multiple triggers/mods)""" + # setup + 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.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.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.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 + ) + + @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") + self.assertTrue(_instance.ticking) + self.assertEqual(_instance.duration, 5) + _instance.at_tick() + self.assertTrue(self.testobj.db.ticktest) + # test duration modification and cleanup + handler.set_duration("ttib", 0) + self.assertEqual(handler.get("ttib").duration, 0) + 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 + self.testobj.buffs.add(_TestModBuff) + self.assertEqual(self.testobj.stat1, 25) + 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 + 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) + + @patch("evennia.contrib.rpg.buffs.buff.utils.delay", new=Mock()) + 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" + )