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
This commit is contained in:
Tegiminis 2022-07-24 04:15:12 -07:00
parent 0dad659935
commit c15f46045d
4 changed files with 375 additions and 287 deletions

View file

@ -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.

View file

@ -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 <target> <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)

View file

@ -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 = ""

View file

@ -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))