From c15f46045d1d75fcad758984e464f0e19ae051d3 Mon Sep 17 00:00:00 2001 From: Tegiminis Date: Sun, 24 Jul 2022 04:15:12 -0700 Subject: [PATCH] fixing goofs exposed by conditional rework, readme tweaks (+8 squashed commit) Squashed commit: [7d0ff84f5] more readme changes... i can't stop... [8259163dc] added new removers for parity with getters, altered conditional logic [d1db0e4a2] added getter/remover section [0bec38d51] misc fixes [614df9883] adding test for stack removal, fix to stack removal logic [77149aaaf] third readme edit, buff module docstring edit, tweak to modgen samplebuff [ca992fd1c] editing buff section of readme [f33eec3d8] first edit of readme --- evennia/contrib/game_systems/buffs/README.md | 257 +++++++++--- evennia/contrib/game_systems/buffs/buff.py | 395 ++++++++---------- .../contrib/game_systems/buffs/samplebuffs.py | 2 +- evennia/contrib/game_systems/buffs/tests.py | 8 +- 4 files changed, 375 insertions(+), 287 deletions(-) diff --git a/evennia/contrib/game_systems/buffs/README.md b/evennia/contrib/game_systems/buffs/README.md index a1147c6ca0..eb0b81d2ea 100644 --- a/evennia/contrib/game_systems/buffs/README.md +++ b/evennia/contrib/game_systems/buffs/README.md @@ -2,11 +2,15 @@ Contribution by Tegiminis 2022 -A buff is a timed object, attached to a game entity, that modifies values, triggers -code, or both. It is a common design pattern in RPGs, particularly action games. +A buff is a timed object, attached to a game entity. It is capable of modifying values, triggering code, or both. +It is a common design pattern in RPGs, particularly action games. -This contrib gives you a buff handler to apply to your objects, a buff class to extend them, -and a sample property class to show how to automatically check modifiers. +This contrib offers you: +- A buff handler to apply to your objects (`BuffHandler`). +- A buff class to extend from to create your own buffs (`BaseBuff`). +- A sample property class to show how to automatically check modifiers (`BuffableProperty`). +- A command which applies buffs (`CmdBuff`). +- Some sample buffs to learn from (`samplebuffs.py`). ## Quick Start Assign the handler to a property on the object, like so. @@ -25,7 +29,7 @@ If you want to customize the handler, you can feed the constructor two arguments - `dbkey`: The string you wish to use as a key for the buff database. Defaults to "buffs". This allows you to keep separate buff pools - for example, "buffs" and "perks". - `autopause`: If you want this handler to automatically pause certain buffs when its owning object is unpuppeted. -> IMPORTANT: If you enable autopausing, you MUST initialize the property in your object's +> **Note**: If you enable autopausing, you MUST initialize the property in your object's > `at_init` hook to cache. Otherwise, a hot reload can cause playtime buffs to not update properly > on puppet/unpuppet. You have been warned! @@ -33,27 +37,27 @@ Let's say you want another handler for an object, `perks`, which has a separate respects playtime buffs. You'd assign this new property as so: ```python - @lazy_property - def perks(self) -> BuffHandler: - return BuffHandler(self, dbkey='perks', autopause=True) +@lazy_property +def perks(self) -> BuffHandler: + return BuffHandler(self, dbkey='perks', autopause=True) ``` -And add `self.perks` to the object's `at_init`. +And initialize it by adding `self.perks` to the object's `at_init`. -### Using the Handler +## Using the Handler -To actually make use of the handler, you still have to do some leg work. +Here's how to make use of your new handler. -#### Apply a Buff +### Apply a Buff -Call the handler `add(BuffClass)` method. This requires a class reference, and also contains a number of +Call the handler `add` method. This requires a class reference, and also contains a number of optional arguments to customize the buff's duration, stacks, and so on. You can also store any arbitrary value in the buff's cache by passing a dictionary through the `to_cache` argument. This will not overwrite the normal values on the cache. ```python -self.buffs.add(StrengthBuff) # A single stack of StrengthBuff with normal duration -self.buffs.add(DexBuff, stacks=3, duration=60) # Three stacks of DexBuff, with a duration of 60 seconds +self.buffs.add(StrengthBuff) # A single stack of StrengthBuff with normal duration +self.buffs.add(DexBuff, stacks=3, duration=60) # Three stacks of DexBuff, with a duration of 60 seconds self.buffs.add(ReflectBuff, to_cache={'reflect': 0.5}) # A single stack of ReflectBuff, with an extra cache value ``` @@ -61,13 +65,63 @@ Two important attributes on the buff are checked when the buff is applied: `refr - `refresh` (default: True) determines if a buff's timer is refreshed when it is reapplied. - `unique` (default: True) determines if the buff uses the buff's normal key (True) or one created with the key and the applier's dbref (False) -#### Modify +If both are `False`, the buff creates a random (rather than buff + source) key each time it is applied, and will never refresh. + +### Get Buffs + +The handler has several getter methods which return instanced buffs. You won't need to use these for basic functionality, but if you want to manipulate +buffs after application, they are very useful. The handler's `check`/`trigger` methods utilize some of these getters, while others are just for developer convenience. + +`get(key)` is the most basic getter. It returns a single buff instance, or `None` if the buff doesn't exist on the handler. It is also the only getter +that returns a single buff instance, rather than a dictionary. + +Grouped getters, listed below, return a dictionary of values in the format `{buffkey: instance}`. If you want to iterate over all these buffs, +you should do so via the `dict.values()` method. + +- `get_all()` returns all buffs on this handler. You can also use the `handler.all` property. +- `get_by_type(BuffClass)` returns buffs of the specified type. +- `get_by_stat(stat)` returns buffs with a `Mod` object of the specified `stat` string in their `mods` list. Used by `handler.check(stat)`. +- `get_by_trigger(trigger)` returns buffs with the specified trigger in their `triggers` list. Used by `handler.trigger(key)`. +- `get_by_source(Object)` returns buffs applied by the specified `source` object. +- `get_by_cachevalue(key, value)` returns buffs with the matching `key: value` pair in their cache. `value` is optional. + +All grouped getters besides `get_all()` can "slice" an existing dictionary through the optional `to_filter` argument. + +```python +dict1 = handler.get_by_type(Burned) # This finds all "Burned" buffs on the handler +dict2 = handler.get_by_source(self, to_filter=dict1) # This filters dict1 to find buffs with the matching source +``` + +> **Note**: Most of these getters also have an associated handler property. For example, `handler.effects` returns all buffs which can be triggered, which +> is then iterated over by the `get_by_trigger` method. + +### Remove Buffs + +There are also a number of remover methods. Generally speaking, these follow the same format as the getters. + +- `remove(key)` removes the buff with the specified key. +- `clear()` removes all buffs. +- `remove_by_type(BuffClass)` removes buffs of the specified type. +- `remove_by_stat(stat)` removes buffs with a `Mod` object of the specified `stat` string in their `mods` list. +- `remove_by_trigger(trigger)` removes buffs with the specified trigger in their `triggers` list. +- `remove_by_source(Object)` removes buffs applied by the specified source +- `remove_by_cachevalue(key, value)` removes buffs with the matching `key: value` pair in their cache. `value` is optional. + +You can also remove a buff by calling the instance's `remove` helper method. You can do this on the dictionaries returned by the +getters listed above. + +```python +to_remove = handler.get_by_trigger(trigger) # Finds all buffs with the specified trigger +for buff in to_remove.values(): buff.remove() # Removes all buffs in the to_remove dictionary via helper methods +``` + +### Check Modifiers Call the handler `check(value, stat)` method wherever you want to see the modified value. -This will return the value, modified by and relevant buffs on the handler's owner (identified by +This will return the `value`, modified by any relevant buffs on the handler's owner (identified by the `stat` string). -For example, let's say you want to modify how much damage you take. That might look something like this +For example, let's say you want to modify how much damage you take. That might look something like this: ```python # The method we call to damage ourselves @@ -76,37 +130,64 @@ def take_damage(self, source, damage): self.db.health -= _damage ``` -#### Trigger +This method call the `at_pre_check` and `at_post_check` methods at the relevant points in the process. You can use to this make +buffs that are reactive to being checked; for example, removing themselves, altering their values, or interacting with the game state. -Call the handler `trigger(triggerstring)` method wherever you want an event call. This +> **Note**: You can also trigger relevant buffs at the same time as you check them by ensuring the optional argument `trigger` is True in the `check` method. + +### Trigger Buffs + +Call the handler `trigger(string)` method wherever you want an event call. This will call the `at_trigger` hook method on all buffs with the relevant trigger. For example, let's say you want to trigger a buff to "detonate" when you hit your target with an attack. -You'd write a buff with at least the following stats: +You'd write a buff that might look like this: ```python -triggers = ['take_damage'] -def at_trigger(self, trigger, *args, **kwargs) - self.owner.take_damage(100) +def Detonate(BaseBuff): + ... + triggers = ['take_damage'] + def at_trigger(self, trigger, *args, **kwargs) + self.owner.take_damage(100) + self.remove() ``` And then call `handler.trigger('take_damage')` in the method you use to take damage. -#### Tick +> **Note** You could also do this through mods and `at_post_check` if you like, depending on how to want to add the damage. + +### Ticking Ticking buffs are slightly special. They are similar to trigger buffs in that they run code, but instead of -doing so on an event trigger, they do so are a periodic tick. A common use case for a buff like this is a poison, +doing so on an event trigger, they do so on a periodic tick. A common use case for a buff like this is a poison, or a heal over time. +```python +def Poison(BaseBuff): + ... + tickrate = 5 + def at_tick(self, initial=True, *args, **kwargs): + _dmg = self.dmg * self.stacks + if not initial: + self.owner.location.msg_contents( + "Poison courses through {actor}'s body, dealing {damage} damage.".format( + actor=self.owner.named, damage=_dmg + ) + ) +``` + All you need to do to make a buff tick is ensure the `tickrate` is 1 or higher, and it has code in its `at_tick` method. Once you add it to the handler, it starts ticking! +> **Note**: Ticking buffs always tick on initial application, when `initial` is `True`. If you don't want your hook to fire at that time, +> make sure to check the value of `initial` in your `at_tick` method. + ### Context -You may have noticed that almost every important handler method optionally accepts a `context` dictionary. +Every important handler method optionally accepts a `context` dictionary. Context is an important concept for this handler. Every method which modifies, triggers, or checks a buff passes this -dictionary (default: empty) to the buff hook methods as keyword arguments (**kwargs). It is used for nothing else. This allows you to make those +dictionary (default: empty) to the buff hook methods as keyword arguments (`**kwargs`). It is used for nothing else. This allows you to make those methods "event-aware" by storing relevant data in the dictionary you feed to the method. For example, let's say you want a "thorns" buff which damages enemies that attack you. Let's take our `take_damage` method @@ -131,26 +212,40 @@ def ThornsBuff(BaseBuff): ``` Apply the buff, take damage, and watch the thorns buff do its work! -## Buffs +## Creating New Buffs -But wait! You still have to actually create the buffs you're going to be applying. - -Creating a buff is very easy: extend `BaseBuff` into a new class, and fill in all the relevant buff details. -However, there are a lot of individual moving parts to a buff! Here's a step-through of the important stuff. +Creating a new buff is very easy: extend `BaseBuff` into a new class, and fill in all the relevant buff details. +However, there are a lot of individual moving parts to a buff. Here's a step-through of the important stuff. ### Basics -Regardless of any mods or hook methods, all buffs have the following qualities: +Regardless of any other functionality, all buffs have the following class attributes: - They have customizable `key`, `name`, and `flavor` strings. -- They can stack, if `maxstacks` is not equal to 1. If it's 0, the buff stacks forever. -- They have a `duration`, and automatically clean up at the end of it (-1 for infinite duration, 0 to cleanup immediately). +- They have a `duration`, and automatically clean up at the end of it. Use -1 for infinite duration, and 0 to cleanup immediately. (default: -1) +- They can stack, if `maxstacks` is not equal to 1. If it's 0, the buff stacks forever. (default: 1) +- They can be `unique`, which determines if they have a unique namespace or not. (default: True) +- They can `refresh`, which resets the duration when stacked or reapplied. (default: True) +- They can be `playtime` buffs, where duration only counts down during active play. (default: False) + +They also always store some useful mutable information about themselves in the cache: + +- The `ref` class, which is the buff class path we use to construct the buff. +- The `start` timestamp of when the buff was applied. +- Their `source`, if specified; this allows you to track who or what applied the buff. +- The `prevtick` timestamp of the previous time this buff ticked. +- The current `duration`. This can vary from the class duration, as you might apply buffs with variable durations, or alter them. +- The number of `stacks` they have. +- Whether they are `paused` or not. Paused buffs do not clean up, modify values, tick, or fire any hook methods. + +You can always access the raw cache dictionary through the `cache` attribute on an instanced buff. This is grabbed when you get the buff through +a handler method, so it may not always reflect recent changes you've made, depending on how you structure your buff calls. All of the above +mutable information can be found in this cache, as well as any arbitrary information you pass through the handler `add` method (via `to_cache`). ### Modifiers -Buffs which have one or more Mod objects in them can modify stats. You can use the handler method to check all -mods of a specific stat string and apply their modifications to the value; however, you are encouraged to use -`check` in a getter/setter, for easy access. +Mods are stored in the `mods` list attribute. Buffs which have one or more Mod objects in them can modify stats. You can use the handler method to check all +mods of a specific stat string and apply their modifications to the value; however, you are encouraged to use `check` in a getter/setter, for easy access. Mod objects consist of only four values, assigned by the constructor in this order: @@ -159,24 +254,40 @@ Mod objects consist of only four values, assigned by the constructor in this ord - `value`: How much value the modifier gives regardless of stacks - `perstack`: How much value the modifier grants per stack, INCLUDING the first. (default: 0) -To add a mod to a buff, you do so in the buff definition, like this: +The most basic way to add a Mod to a buff is to do so in the buff class definition, like this: + ```python def DamageBuff(BaseBuff): mods = [Mod('damage', 'add', 10)] ``` -No mods applied to the value are permanent in any way. All calculations are done at -runtime, and the mod values are never stored anywhere except on the buff in question. In -other words: you don't need to track the origin of particular stat mods, and you -will never permanently change a stat modified by a trait buff. To remove the modification, simply -remove the buff off the object. +No mods applied to the value are permanent in any way. All calculations are done at runtime, and the mod values are never stored +anywhere except on the buff in question. In other words: you don't need to track the origin of particular stat mods, and you will +never permanently change a stat modified by a trait buff. To remove the modification, simply remove the buff off the object. + +> **Note**: You can add your own modifier types by overloading the `_calculate_mods` method, which contains the basic modifier application logic. + +#### Generating Mods (Advanced) + +An advanced way to do mods is to generate them when the buff is initialized. This lets you create mods on the fly that are reactive to the game state. + +```python +def GeneratedStatBuff(BaseBuff): + ... + def __init__(self, handler, buffkey, cache={}) -> None: + super().__init__(handler, buffkey, cache) + # Finds our "modgen" cache value, and generates a mod from it + modgen = list(self.cache.get("modgen")) + if modgen: + self.mods = [Mod(*modgen)] +``` ### Triggers Buffs which have one or more strings in the `triggers` attribute can be triggered by events. When the handler `trigger` method is called, it searches all buffs on the handler for any with a matching -trigger, then calls their `at_trigger` methods. You can tell which trigger is the one it fired with by the `trigger` +trigger, then calls their `at_trigger` methods. Buffs can have multiple triggers, and you can tell which trigger was fired by the `trigger` argument in the method. ```python @@ -192,6 +303,7 @@ def AmplifyBuff(BaseBuff): A buff with ticking isn't much different than one which triggers. You're still executing arbitrary code off the buff class. The main thing is you need to have a `tickrate` higher than 1. + ```python def Poison(BaseBuff): ... @@ -201,39 +313,46 @@ def Poison(BaseBuff): def at_tick(self, initial, **kwargs): self.owner.take_damage(10) ``` -It's important to note the buff always ticks once when applied. For this first tick only, `initial` will be True -in the `at_tick` hook method. +> **Note**: The buff always ticks once when applied. For this **first tick only**, `initial` will be True in the `at_tick` hook method. + +Ticks utilize a persistent delay, so they should be pickleable. As long as you are not adding new properties to your buff class, this shouldn't be a concern. +If you **are** adding new properties, try to ensure they do not end up with a circular code path to their object or handler, as this will cause pickling errors. ### Extras -Buffs have a grab-bag of extra functionality to make your life easier! +Buffs have a grab-bag of extra functionality to let you add complexity to your designs. -You can restrict whether or not the buff will check or trigger through defining the `conditional` hook. As long +#### Conditionals + +You can restrict whether or not the buff will `check`, `trigger`, or `tick` through defining the `conditional` hook. As long as it returns a "truthy" value, the buff will apply itself. This is useful for making buffs dependent on game state - for -example, if you want a buff that makes the player take more damage when they are on fire. +example, if you want a buff that makes the player take more damage when they are on fire: ```python -def conditional(self, *args, **kwargs): - if self.owner.buffs.get_by_type(FireBuff): return True - return False +def FireSick(BaseBuff): + ... + def conditional(self, *args, **kwargs): + if self.owner.buffs.get_by_type(FireBuff): return True + return False ``` -There are a number of helper methods. If you have a buff instance - for example, because you got the buff with -`handler.get(key)` - you can `pause`, `unpause`, `remove`, `dispel`, etc. +Conditionals for `check`/`trigger` are checked when the buffs are gathered by the handler methods for the respective operations. `Tick` +conditionals are checked each tick. -Finally, if your handler has `autopause` enabled, any buffs with truthy `playtime` value will automatically pause +#### Helper Methods + +Buff instances have a number of helper methods you can access either on the buff itself or wherever the buff is instances (typically through +handler getters) + +- `remove`/`dispel`: Allows you to remove or dispel the buff. Calls `at_remove`/`at_dispel`, depending on optional arguments. +- `pause`/`unpause`: Pauses and unpauses the buff. Calls `at_pause`/`at_unpause`. +- `reset`: Resets the buff's start to the current time; same as "refreshing" it. + +#### Playtime Duration + +If your handler has `autopause` enabled, any buffs with truthy `playtime` value will automatically pause and unpause when the object the handler is attached to is puppetted or unpuppetted. This even works with ticking buffs, although if you have less than 1 second of tick duration remaining, it will round up to 1s. -If you want more control over this process, you can comment out the signal subscriptions on the handler and move the autopause logic -to your object's at_pre/post_puppet/unpuppet hooks. - -### How Does It Work? - -Buffs are stored in two parts alongside each other in the cache: a reference to the buff class, and as mutable data. -You can technically store any information you like in the cache; by default, it's all the basic timing and event -information necessary for the system to run. When the buff is instanced, this cache is fed to the constructor - -When you use the handler to get a buff, you get an instanced version of that buff created from these two parts, or -a dictionary of these buffs in the format of {buffkey: instance}. Buffs are only instanced as long as is necessary to -run methods on them. +> **Note**: If you want more control over this process, you can comment out the signal subscriptions on the handler and move the autopause logic +> to your object's at_pre/post_puppet/unpuppet hooks. diff --git a/evennia/contrib/game_systems/buffs/buff.py b/evennia/contrib/game_systems/buffs/buff.py index 3c6b525ee6..f1c3212644 100644 --- a/evennia/contrib/game_systems/buffs/buff.py +++ b/evennia/contrib/game_systems/buffs/buff.py @@ -5,9 +5,10 @@ A buff is a timed object, attached to a game entity, that modifies values, trigg code, or both. It is a common design pattern in RPGs, particularly action games. This contrib gives you a buff handler to apply to your objects, a buff class to extend them, -and a sample property class to show how to automatically check modifiers. +a sample property class to show how to automatically check modifiers, some sample buffs to learn from, +and a command which applies buffs. -## Quick Start +## Installation Assign the handler to a property on the object, like so. ```python @@ -15,39 +16,17 @@ Assign the handler to a property on the object, like so. def buffs(self) -> BuffHandler: return BuffHandler(self)``` -You may then call the handler to add or manipulate buffs. +## Using the Handler -### Customization +To make use of the handler, you will need: -If you want to customize the handler, you can feed the constructor two arguments: -- `dbkey`: The string you wish to use as a key for the buff database. Defaults to "buffs". This allows you to keep separate buff pools - for example, "buffs" and "perks". -- `autopause`: If you want this handler to automatically pause certain buffs when its owning object is unpuppeted. +- Some buffs to add. You can create these by extending the `BaseBuff` class from this module. You can see some examples in `samplebuffs.py`. +- A way to add buffs to the handler. You can see a basic example of this in the `CmdBuff` command in this module. -> IMPORTANT: If you enable autopausing, you MUST initialize the property in your object's -> `at_init` hook to cache. Otherwise, a hot reload can cause playtime buffs to not update properly -> on puppet/unpuppet. You have been warned! - -Let's say you want another handler for an object, `perks`, which has a separate database and -respects playtime buffs. You'd assign this new property as so: - -```python - @lazy_property - def perks(self) -> BuffHandler: - return BuffHandler(self, dbkey='perks', autopause=True) -``` - -And add `self.perks` to the object's `at_init`. - -### Using the Handler - -To actually make use of the handler, you still have to do some leg work. - -#### Apply a Buff +### Applying a Buff Call the handler `add(BuffClass)` method. This requires a class reference, and also contains a number of -optional arguments to customize the buff's duration, stacks, and so on. You can also store any arbitrary value -in the buff's cache by passing a dictionary through the `to_cache` argument. This will not overwrite the normal -values on the cache. +optional arguments to customize the buff's duration, stacks, and so on. ```python self.buffs.add(StrengthBuff) # A single stack of StrengthBuff with normal duration @@ -55,17 +34,11 @@ self.buffs.add(DexBuff, stacks=3, duration=60) # Three stacks of DexBuff, with self.buffs.add(ReflectBuff, to_cache={'reflect': 0.5}) # A single stack of ReflectBuff, with an extra cache value ``` -Two important attributes on the buff are checked when the buff is applied: `refresh` and `unique`. -- `refresh` (default: True) determines if a buff's timer is refreshed when it is reapplied. -- `unique` (default: True) determines if the buff uses the buff's normal key (True) or one created with the key and the applier's dbref (False) - -#### Modify +### Modify Call the handler `check(value, stat)` method wherever you want to see the modified value. This will return the value, modified by and relevant buffs on the handler's owner (identified by -the `stat` string). - -For example, let's say you want to modify how much damage you take. That might look something like this +the `stat` string). For example: ```python # The method we call to damage ourselves @@ -74,167 +47,61 @@ def take_damage(self, source, damage): self.db.health -= _damage ``` -#### Trigger +### Trigger Call the handler `trigger(triggerstring)` method wherever you want an event call. This will call the `at_trigger` hook method on all buffs with the relevant trigger. -For example, let's say you want to trigger a buff to "detonate" when you hit your target with an attack. -You'd write a buff with at least the following stats: - ```python -triggers = ['take_damage'] -def at_trigger(self, trigger, *args, **kwargs) - self.owner.take_damage(100) +def Detonate(BaseBuff): + ... + triggers = ['take_damage'] + def at_trigger(self, trigger, *args, **kwargs) + self.owner.take_damage(100) + self.remove() + +def Character(Character): + ... + def take_damage(self, source, damage): + self.buffs.trigger('take_damage') + self.db.health -= _damage ``` -And then call `handler.trigger('take_damage')` in the method you use to take damage. +### Tick -#### Tick +Ticking a buff happens automatically once applied, as long as the buff's `tickrate` is more than 0. -Ticking buffs are slightly special. They are similar to trigger buffs in that they run code, but instead of -doing so on an event trigger, they do so are a periodic tick. A common use case for a buff like this is a poison, -or a heal over time. - -All you need to do to make a buff tick is ensure the `tickrate` is 1 or higher, and it has code in its `at_tick` -method. Once you add it to the handler, it starts ticking! +```python +def Poison(BaseBuff): + ... + tickrate = 5 + def at_tick(self, initial=True, *args, **kwargs): + _dmg = self.dmg * self.stacks + if not initial: + self.owner.location.msg_contents( + "Poison courses through {actor}'s body, dealing {damage} damage.".format( + actor=self.owner.named, damage=_dmg + ) + ) +``` ## Buffs -But wait! You still have to actually create the buffs you're going to be applying. +A buff is a class which contains a bunch of immutable data about itself - such as tickrate, triggers, refresh rules, and +so on - and which merges mutable data in from the cache when called. -Creating a buff is very easy: extend BaseBuff into a new class, and fill in all the relevant buff details. -However, there are a lot of individual moving parts to a buff! Here's a step-through of the important stuff. +Buffs are always instanced when they are called for a method. To access a buff's properties and methods, you should do so through +this instance, rather than directly manipulating the buff cache on the object. You can modify a buff's cache through various handler +methods instead. -### Basics +You can see all the features of the `BaseBuff` class below, or browse `samplebuffs.py` to see how to create some common buffs. Buffs have +many attributes and hook methods you can overload to create complex, interrelated buffs. -Regardless of any mods or hook methods, all buffs have the following qualities: - -- They have customizable `key`, `name`, and `flavor` strings. -- They can stack, if `maxstacks` is not equal to 1. If it's 0, the buff stacks forever. -- They have a `duration`, and automatically clean up at the end of it (-1 for infinite duration, 0 to cleanup immediately). - -### Modifiers - -Buffs which have one or more Mod objects in them can modify stats. You can use the handler method to check all -mods of a specific stat string and apply their modifications to the value; however, you are encouraged to use -`check` in a getter/setter, for easy access. - -Mod objects consist of only four values, assigned by the constructor in this order: - -- `stat`: The stat you want to modify. When `check` is called, this string is used to find all the mods that are to be collected. -- `mod`: The modifier. Defaults are 'add' and 'mult'. Modifiers are calculated additively, and in standard arithmetic order (see `_calculate_mods` for more) -- `value`: How much value the modifier gives regardless of stacks -- `perstack`: How much value the modifier grants per stack, INCLUDING the first. (default: 0) - -To add a mod to a buff, you do so in the buff definition, like this: -```python -def DamageBuff(BaseBuff): - mods = [Mod('damage', 'add', 10)] -``` - -No mods applied to the value are permanent in any way. All calculations are done at -runtime, and the mod values are never stored anywhere except on the buff in question. In -other words: you don't need to track the origin of particular stat mods, and you -will never permanently change a stat modified by a trait buff. To remove the modification, simply -remove the buff off the object. - -### Triggers - -Buffs which have one or more strings in the `triggers` attribute can be triggered by events. - -When the handler `trigger` method is called, it searches all buffs on the handler for any with a matching -trigger, then calls their `at_trigger` methods. You can tell which trigger is the one it fired with by the `trigger` -argument in the method. - -```python -def AmplifyBuff(BaseBuff): - triggers = ['damage', 'heal'] - - def at_trigger(self, trigger, **kwargs): - if trigger == 'damage': print('Damage trigger called!') - if trigger == 'heal': print('Heal trigger called!') -``` - -### Ticking - -A buff with ticking isn't much different than one which triggers. You're still executing arbitrary code off -the buff class. The main thing is you need to have a `tickrate` higher than 1. -```python -# this buff will tick 6 times between application and cleanup. -duration = 30 -tickrate = 5 -def at_tick(self, initial, **kwargs): - self.owner.take_damage(10) -``` -It's important to note the buff always ticks once when applied. For this first tick only, `initial` will be True -in the `at_tick` hook method. - -### Extras - -Buffs have a grab-bag of extra functionality to make your life easier! - -You can restrict whether or not the buff will check or trigger through defining the `conditional` hook. As long -as it returns a "truthy" value, the buff will apply itself. This is useful for making buffs dependent on game state - for -example, if you want a buff that makes the player take more damage when they are on fire. - -```python -def conditional(self, *args, **kwargs): - if self.owner.buffs.get_by_type(FireBuff): return True - return False -``` - -There are a number of helper methods. If you have a buff instance - for example, because you got the buff with -`handler.get(key)` - you can `pause`, `unpause`, `remove`, `dispel`, etc. - -Finally, if your handler has `autopause` enabled, any buffs with truthy `playtime` value will automatically pause -and unpause when the object the handler is attached to is puppetted or unpuppetted. This even works with ticking buffs, -although if you have less than 1 second of tick duration remaining, it will round up to 1s. - -If you want more control over this process, you can comment out the signal subscriptions on the handler and move the autopause logic -to your object's at_pre/post_puppet/unpuppet hooks. - -### How Does It Work? - -Buffs are stored in two parts alongside each other in the cache: a reference to the buff class, and as mutable data. -You can technically store any information you like in the cache; by default, it's all the basic timing and event -information necessary for the system to run. When the buff is instanced, this cache is fed to the constructor - -When you use the handler to get a buff, you get an instanced version of that buff created from these two parts, or -a dictionary of these buffs in the format of {buffkey: instance}. Buffs are only instanced as long as is necessary to -run methods on them. - -## Context - -You may have noticed that almost every important handler method optionally accepts a `context` dictionary. - -Context is an important concept for this handler. Every method which modifies, triggers, or checks a buff passes this -dictionary (default: empty) to the buff hook methods as keyword arguments (**kwargs). It is used for nothing else. This allows you to make those -methods "event-aware" by storing relevant data in the dictionary you feed to the method. - -For example, let's say you want a "thorns" buff which damages enemies that attack you. Let's take our `take_damage` method -and add a context to the mix. - -``` -def take_damage(attacker, damage): - context = {'attacker': attacker, 'damage': damage} - _damage = self.buffs.check(damage, 'taken_damage', context=context) - self.buffs.trigger('taken_damage', context=context) - self.db.health -= _damage -``` -Now we use the values that context passes to the buff kwargs to customize our logic. -``` -triggers = ['taken_damage'] -# This is the hook method on our thorns buff -def at_trigger(self, trigger, attacker=None, damage=0, **kwargs): - if not attacker: return - attacker.db.health -= damage * 0.2 -``` -Apply the buff, take damage, and watch the thorns buff do its work! """ from random import random import time +from evennia import Command from evennia.server import signals from evennia.utils import utils, search from evennia.typeclasses.attributes import AttributeProperty @@ -553,8 +420,10 @@ class BuffHandler(object): context = {} b = {} _context = dict(context) + if buff.cache: + b = dict(buff.cache) if to_cache: - b = dict(to_cache) + b.update(dict(to_cache)) if stacks < 1: stacks = min(1, buff.stacks) @@ -609,6 +478,7 @@ class BuffHandler(object): if b["duration"] > -1: utils.delay(b["duration"], self.cleanup, persistent=True) + # region removers def remove(self, key, stacks=0, loud=True, dispel=False, expire=False, context=None): """Remove a buff or effect with matching key from this object. Normally calls at_remove, calls at_expire if the buff expired naturally, and optionally calls at_dispel. Can also @@ -642,6 +512,8 @@ class BuffHandler(object): del self.buffcache[key] elif stacks: self.buffcache[key]["stacks"] -= stacks + if self.buffcache[key]["stacks"] <= 0: + del self.buffcache[key] def remove_by_type( self, @@ -665,6 +537,50 @@ class BuffHandler(object): return None self._remove_via_dict(_remove, loud, dispel, expire, context) + def remove_by_stat( + self, + stat, + loud=True, + dispel=False, + expire=False, + context=None, + ): + """Removes all buffs modifying the specified stat from this object. + + Args: + stat: The stat string to search for + loud: (optional) Calls at_remove when True. (default: True) + dispel: (optional) Calls at_dispel when True. (default: False) + expire: (optional) Calls at_expire when True. (default: False) + context: (optional) A dictionary you wish to pass to the at_remove/at_dispel/at_expire method as kwargs + """ + _remove = self.get_by_stat(stat) + if not _remove: + return None + self._remove_via_dict(_remove, loud, dispel, expire, context) + + def remove_by_trigger( + self, + trigger, + loud=True, + dispel=False, + expire=False, + context=None, + ): + """Removes all buffs with the specified trigger from this object. + + Args: + trigger: The stat string to search for + loud: (optional) Calls at_remove when True. (default: True) + dispel: (optional) Calls at_dispel when True. (default: False) + expire: (optional) Calls at_expire when True. (default: False) + context: (optional) A dictionary you wish to pass to the at_remove/at_dispel/at_expire method as kwargs + """ + _remove = self.get_by_trigger(trigger) + if not _remove: + return None + self._remove_via_dict(_remove, loud, dispel, expire, context) + def remove_by_source( self, source, @@ -673,7 +589,7 @@ class BuffHandler(object): expire=False, context=None, ): - """Removes all buffs from the specified source from this object. Functionally similar to remove, but takes a source instead. + """Removes all buffs from the specified source from this object. Args: source: The source to search for @@ -716,6 +632,8 @@ class BuffHandler(object): cache = self.all self._remove_via_dict(cache, loud, dispel, expire, context) + # endregion + # region getters def get(self, key: str): """If the specified key is on this handler, return the instanced buff. Otherwise return None. You should delete this when you're done with it, so that garbage collection doesn't have to. @@ -731,6 +649,8 @@ class BuffHandler(object): def get_all(self): """Returns a dictionary of instanced buffs (all of them) on this handler in the format {buffkey: instance}""" _cache = dict(self.buffcache) + if not _cache: + return None return {k: buff["ref"](self, k, buff) for k, buff in _cache.items()} def get_by_type(self, buff: BaseBuff, to_filter=None): @@ -742,52 +662,36 @@ class BuffHandler(object): Returns a dictionary of instanced buffs of the specified type in the format {buffkey: instance}.""" _cache = self.get_all() if not to_filter else to_filter + if not _cache: + return None return {k: _buff for k, _buff in _cache.items() if isinstance(_buff, buff)} - def get_by_stat(self, stat: str, context=None, to_filter=None): + def get_by_stat(self, stat: str, to_filter=None): """Finds all buffs which contain a Mod object that modifies the specified stat. Args: stat: The string identifier to find relevant mods - context: (optional) A dictionary you wish to pass to the conditional method as kwargs to_filter: (optional) A dictionary you wish to slice. If not provided, uses the whole buffcache. Returns a dictionary of instanced buffs which modify the specified stat in the format {buffkey: instance}.""" _cache = self.traits if not to_filter else to_filter if not _cache: return None - if not context: - context = {} - - buffs = { - k: buff - for k, buff in _cache.items() - for m in buff.mods - if m.stat == stat - if not buff.paused - if buff.conditional(**context) - } + buffs = {k: buff for k, buff in _cache.items() for m in buff.mods if m.stat == stat} return buffs - def get_by_trigger(self, trigger: str, context=None, to_filter=None): + def get_by_trigger(self, trigger: str, to_filter=None): """Finds all buffs with the matching string in their triggers. Args: trigger: The string identifier to find relevant buffs - context: (optional) A dictionary you wish to pass to the conditional method as kwargs to_filter: (optional) A dictionary you wish to slice. If not provided, uses the whole buffcache. Returns a dictionary of instanced buffs which fire off the designated trigger, in the format {buffkey: instance}.""" _cache = self.effects if not to_filter else to_filter - if not context: - context = {} - buffs = { - k: buff - for k, buff in _cache.items() - if trigger in buff.triggers - if not buff.paused - if buff.conditional(**context) - } + if not _cache: + return None + buffs = {k: buff for k, buff in _cache.items() if trigger in buff.triggers} return buffs def get_by_source(self, source, to_filter=None): @@ -799,6 +703,8 @@ class BuffHandler(object): Returns a dictionary of instanced buffs which came from the provided source, in the format {buffkey: instance}.""" _cache = self.all if not to_filter else to_filter + if not _cache: + return None buffs = {k: buff for k, buff in _cache.items() if buff.source == source} return buffs @@ -812,12 +718,16 @@ class BuffHandler(object): Returns a dictionary of instanced buffs with cache values matching the specified value, in the format {buffkey: instance}.""" _cache = self.all if not to_filter else to_filter + if not _cache: + return None if not value: buffs = {k: buff for k, buff in _cache.items() if buff.cache.get(key)} elif value: buffs = {k: buff for k, buff in _cache.items() if buff.cache.get(key) == value} return buffs + # endregion + def check(self, value: float, stat: str, loud=True, context=None, trigger=False): """Finds all buffs and perks related to a stat and applies their effects. @@ -825,7 +735,7 @@ class BuffHandler(object): value: The value you intend to modify stat: The string that designates which stat buffs you want loud: (optional) Call the buff's at_post_check method after checking (default: True) - context: (optional) A dictionary you wish to pass to the at_pre_check/at_post_check methods as kwargs + context: (optional) A dictionary you wish to pass to the at_pre_check/at_post_check and conditional methods as kwargs trigger: (optional) Trigger buffs with the `stat` string as well. (default: False) Returns the value modified by relevant buffs.""" @@ -835,12 +745,16 @@ class BuffHandler(object): # Find all buffs and traits related to the specified stat. if not context: context = {} - applied = self.get_by_stat(stat, context) + applied = self.get_by_stat(stat) if not applied: return value for buff in applied.values(): buff.at_pre_check(**context) + applied = { + k: buff for k, buff in applied.items() if buff.conditional(**context) if not buff.paused + } + # The final result final = self._calculate_mods(value, stat, applied) @@ -865,17 +779,24 @@ class BuffHandler(object): context: (optional) A dictionary you wish to pass to the at_trigger method as kwargs """ self.cleanup() - _effects = self.get_by_trigger(trigger, context) - if _effects is None: + _effects = self.get_by_trigger(trigger) + if not _effects: return None if not context: context = {} + _to_trigger = { + k: buff + for k, buff in _effects.items() + if buff.conditional(**context) + if not buff.paused + if trigger in buff.triggers + } + # Trigger all buffs whose trigger matches the trigger string - for buff in _effects.values(): + for buff in _to_trigger.values(): buff: BaseBuff - if trigger in buff.triggers and not buff.paused: - buff.at_trigger(trigger, **context) + buff.at_trigger(trigger, **context) def pause(self, key: str, context=None): """Pauses the buff. This excludes it from being checked for mods, triggered, or cleaned up. Used to make buffs 'playtime' instead of 'realtime'. @@ -945,7 +866,7 @@ class BuffHandler(object): instance: BaseBuff = buff["ref"](self, key, buff) instance.at_unpause(**context) utils.delay(buff["duration"], cleanup_buffs, self, persistent=True) - if buff["ref"].ticking: + if instance.ticking: utils.delay( tickrate, tick_buff, handler=self, buffkey=key, initial=False, persistent=True ) @@ -1028,6 +949,8 @@ class BuffHandler(object): """Removes buffs within the provided dictionary from this handler. Used for remove methods besides the basic remove.""" if not context: context = {} + if not buffs: + return for k, instance in buffs.items(): instance: BaseBuff if loud: @@ -1051,6 +974,48 @@ class BuffableProperty(AttributeProperty): return _value +class CmdBuff(Command): + """ + Buff a target. + + Usage: + buff + + Applies the specified buff to the target. All buffs are defined in the bufflist dictionary on this command. + """ + + key = "buff" + aliases = ["buff"] + help_category = "builder" + + bufflist = {"foo": BaseBuff} + + def parse(self): + self.args = self.args.split() + + def func(self): + caller = self.caller + target = None + now = time.time() + + if self.args: + target = caller.search(self.args[0]) + caller.ndb.target = target + elif caller.ndb.target: + target = caller.ndb.target + else: + caller.msg("You need to pick a target to buff.") + return + + if self.args[1] not in self.bufflist.keys(): + caller.msg("You must pick a valid buff.") + return + + if target: + target.buffs.add(self.bufflist[self.args[1]], source=caller) + pass + + def cleanup_buffs(handler: BuffHandler): """Cleans up all expired buffs from a handler.""" _remove = handler.expired @@ -1069,6 +1034,8 @@ def tick_buff(handler: BuffHandler, buffkey: str, context=None, initial=True): # Cache a reference and find the buff on the object if buffkey not in handler.buffcache.keys(): return + if not context: + context = {} # Instantiate the buff and tickrate buff: BaseBuff = handler.get(buffkey) diff --git a/evennia/contrib/game_systems/buffs/samplebuffs.py b/evennia/contrib/game_systems/buffs/samplebuffs.py index 7a05b1ec02..457e647805 100644 --- a/evennia/contrib/game_systems/buffs/samplebuffs.py +++ b/evennia/contrib/game_systems/buffs/samplebuffs.py @@ -131,7 +131,7 @@ class StatBuff(BaseBuff): def __init__(self, handler, buffkey, cache={}) -> None: super().__init__(handler, buffkey, cache) # Finds our "modgen" cache value, which we pass on application - modgen = list(self.cache["modgen"]) + modgen = list(self.cache.get("modgen")) if modgen: self.mods = [Mod(*modgen)] msg = "" diff --git a/evennia/contrib/game_systems/buffs/tests.py b/evennia/contrib/game_systems/buffs/tests.py index c16ea7f5c0..6c02d40be8 100644 --- a/evennia/contrib/game_systems/buffs/tests.py +++ b/evennia/contrib/game_systems/buffs/tests.py @@ -130,6 +130,10 @@ class TestBuffsAndHandler(EvenniaTest): # remove handler.remove("tmb") self.assertFalse(self.testobj.db.buffs.get("tmb")) + # remove stacks + handler.add(_TestModBuff, stacks=3) + handler.remove("tmb", stacks=3) + self.assertFalse(self.testobj.db.buffs.get("tmb")) # remove by type handler.add(_TestModBuff) handler.remove_by_type(_TestModBuff) @@ -149,7 +153,7 @@ class TestBuffsAndHandler(EvenniaTest): # remove all handler.add(_TestModBuff) handler.clear() - self.assertFalse(self.testobj.db.buffs.get("tmb")) + self.assertFalse(self.testobj.buffs.all) def test_getters(self): """tests all built-in getters""" @@ -172,8 +176,6 @@ class TestBuffsAndHandler(EvenniaTest): self.assertTrue("tmb" in handler.get_by_source(self.obj2)) self.assertFalse("ttb" in handler.get_by_source(self.obj2)) # cachevalue getter - self.assertFalse(handler.get("tmb").cache.get("ttbcache")) - self.assertFalse(handler.get("ttb").cache.get("testfalse")) self.assertFalse("tmb" in handler.get_by_cachevalue("ttbcache")) self.assertTrue("ttb" in handler.get_by_cachevalue("ttbcache")) self.assertTrue("ttb" in handler.get_by_cachevalue("ttbcache", True))