added missing div references to readme (+2 squashed commit)

Squashed commit:

[b8a311b07] readme and test updates to support new features

[70c12b74e] added "div" default modifier, altered how mods are calculated to enable view_modifiers, split modifier application into separate method
This commit is contained in:
Tegiminis 2022-08-05 14:41:49 -07:00
parent 673c3e5f03
commit adea728e25
3 changed files with 137 additions and 20 deletions

View file

@ -142,6 +142,44 @@ buffs that are reactive to being checked; for example, removing themselves, alte
> **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.
Modifiers are calculated additively - that is, all modifiers of the same type are added together before being applied. They are then
applied through the following formula.
```python
(base + total_add) / max(1, 1.0 + total_div) * max(0, 1.0 + total_mult)
```
#### Multiplicative Buffs (Advanced)
Multiply/divide modifiers in this buff system are additive by default. This means that two +50% modifiers will equal a +100% modifier. But what if you want to apply mods multiplicatively?
First, you should carefully consider if you truly want multiplicative modifiers. Here's some things to consider.
- They are unintuitive to the average user, as two +50% damage buffs equal +125% instead of +100%.
- They lead to "power explosion", where stacking buffs in the right way can turn characters into unstoppable forces
Doing purely-additive multipliers allows you to better control the balance of your game. Conversely, doing multiplicative multipliers enables very fun build-crafting where smart usage of buffs and skills can turn you into a one-shot powerhouse. Each has its place.
The best design practice for multiplicative buffs is to divide your multipliers into "tiers", where each tier is applied separately. You can easily do this with multiple `check` calls.
```python
damage = damage
damage = handler.check(damage, 'damage')
damage = handler.check(damage, 'empower')
damage = handler.check(damage, 'radiant')
damage = handler.check(damage, 'overpower')
```
#### Buff Strength Priority (Advanced)
Sometimes you only want to apply the strongest modifier to a stat. This is supported by the optional `strongest` bool arg in the handler's check method
```python
def take_damage(self, source, damage):
_damage = self.buffs.check(damage, 'taken_damage', strongest=True)
self.db.health -= _damage
```
### Trigger Buffs
Call the handler's `trigger(string)` method when you want an event call. This will call the `at_trigger` hook method on all buffs with the relevant trigger `string`.
@ -219,6 +257,15 @@ class ThornsBuff(BaseBuff):
```
Apply the buff, take damage, and watch the thorns buff do its work!
### Viewing
There are two helper methods on the handler that allow you to get useful buff information back.
- `view`: Returns a dictionary of tuples in the format `{buffkey: (buff.name, buff.flavor)}`. Finds all buffs by default, but optionally accepts a dictionary of buffs to filter as well. Useful for basic buff readouts.
- `view_modifiers(stat)`: Returns a nested dictionary of information on modifiers that affect the specified stat. The first layer is the modifier type (`add/mult/div`) and the second layer is the value type (`total/strongest`). Does not return the buffs that cause these modifiers, just the modifiers themselves (akin to using `handler.check` but without actually modifying a value). Useful for stat sheets.
You can also create your own custom viewing methods through the various handler getters, which will always return the entire buff object.
## Creating New Buffs
Creating a new buff is very easy: extend `BaseBuff` into a new class, and fill in all the relevant buff details.
@ -230,6 +277,7 @@ Regardless of any other functionality, all buffs have the following class attrib
- They have customizable `key`, `name`, and `flavor` strings.
- They have a `duration` (float), and automatically clean-up at the end. Use -1 for infinite duration, and 0 to clean-up immediately. (default: -1)
- They have a `tickrate` (float), and automatically tick if it is greater than 1 (default: 0)
- They can stack, if `maxstacks` (int) is not equal to 1. If it's 0, the buff stacks forever. (default: 1)
- They can be `unique` (bool), which determines if they have a unique namespace or not. (default: True)
- They can `refresh` (bool), which resets the duration when stacked or reapplied. (default: True)
@ -249,6 +297,13 @@ You can always access the raw cache dictionary through the `cache` attribute on
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`).
Buffs also have a few useful properties:
- `owner`: The object this buff is attached to
- `ticknum`: How many ticks the buff has gone through
- `timeleft`: How much time is remaining on the buff
- `ticking`/`stacking`: If this buff ticks/stacks (checks `tickrate` and `maxstacks`)
### Modifiers
Mods are stored in the `mods` list attribute. Buffs which have one or more Mod objects in them can modify stats. You can use the handler method to check all
@ -257,7 +312,7 @@ mods of a specific stat string and apply their modifications to the value; howev
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)
- `mod`: The modifier. Defaults are `add` (addition/subtraction), `mult` (multiply), and `div` (divide). Modifiers are calculated additively (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)
@ -354,6 +409,7 @@ Buff instances have a number of helper methods.
- `remove`/`dispel`: Allows you to remove or dispel the buff. Calls `at_remove`/`at_dispel`, depending on optional arguments.
- `pause`/`unpause`: Pauses and unpauses the buff. Calls `at_pause`/`at_unpause`.
- `reset`: Resets the buff's start to the current time; same as "refreshing" it.
- `alter_cache`: Updates the buff's cache with the `{key:value}` pairs in the provided dictionary. Can overwrite default values, so be careful!
#### Playtime Duration

View file

@ -98,7 +98,6 @@ You can see all the features of the `BaseBuff` class below, or browse `samplebuf
many attributes and hook methods you can overload to create complex, interrelated buffs.
"""
from random import random
import time
from evennia import Command
@ -235,8 +234,11 @@ class BaseBuff:
self.start = time.time()
self.handler.buffcache[self.buffkey]["start"] = time.time()
def alter_cache(self, to_cache: dict = None):
"""Alters this buff's cache, both internally (this instance) and on the handler's buff cache."""
def alter_cache(self, to_cache: dict):
"""Alters this buff's cache, both internally (this instance) and on the handler's buff cache.
Args:
to_cache: The dictionary of values you want to add to the cache"""
if not isinstance(to_cache, dict):
raise TypeError
_cache = dict(self.handler.buffcache[self.buffkey])
@ -331,6 +333,7 @@ class BuffHandler:
self.dbkey = dbkey
self.autopause = autopause
if autopause:
self._validate_state()
signals.SIGNAL_OBJECT_POST_UNPUPPET.connect(self._pause_playtime)
signals.SIGNAL_OBJECT_POST_PUPPET.connect(self._unpause_playtime)
@ -769,7 +772,9 @@ class BuffHandler:
return True
return False
def check(self, value: float, stat: str, loud=True, context=None, trigger=False):
def check(
self, value: float, stat: str, loud=True, context=None, trigger=False, strongest=False
):
"""Finds all buffs and perks related to a stat and applies their effects.
Args:
@ -778,6 +783,7 @@ class BuffHandler:
loud: (optional) Call the buff's at_post_check method after checking (default: True)
context: (optional) A dictionary you wish to pass to the at_pre_check/at_post_check and conditional methods as kwargs
trigger: (optional) Trigger buffs with the `stat` string as well. (default: False)
strongest: (optional) Applies only the strongest mods of the corresponding stat value (default: False)
Returns the value modified by relevant buffs."""
# Buff cleanup to make sure all buffs are valid before processing
@ -789,15 +795,21 @@ class BuffHandler:
applied = self.get_by_stat(stat)
if not applied:
return value
# Run pre-check hooks on related buffs
for buff in applied.values():
buff.at_pre_check(**context)
# Sift out buffs that won't be applying their mods (paused, conditional)
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)
# The mod totals
calc = self._calculate_mods(stat, applied)
# The calculated final value
final = self._apply_mods(value, calc, strongest=strongest)
# Run the "after check" functions on all relevant buffs
for buff in applied.values():
@ -999,29 +1011,58 @@ class BuffHandler:
buff.unpause()
pass
def _calculate_mods(self, value, stat: str, buffs: dict):
"""Calculates a return value from a base value, a stat string, and a dictionary of instanced buffs with associated mods.
def _calculate_mods(self, stat: str, buffs: dict):
"""Calculates the total value of applicable mods.
Args:
value: The base value to modify
stat: The string identifier to search mods for
buffs: The dictionary of buffs to apply"""
buffs: The dictionary of buffs to calculate mods from
Returns a nested dictionary. The first layer's keys represent the type of modifier ('add' and 'mult'),
and the second layer's keys represent the type of value ('total' and 'strongest')."""
# The base return dictionary. If you update how modifiers are calculated, make sure to update this too, or you will get key errors!
calculated = {
"add": {"total": 0, "strongest": 0},
"mult": {"total": 0, "strongest": 0},
"div": {"total": 0, "strongest": 0},
}
if not buffs:
return value
add = 0
mult = 0
return calculated
for buff in buffs.values():
for mod in buff.mods:
buff: BaseBuff
mod: Mod
if mod.stat == stat:
if mod.modifier == "add":
add += mod.value + ((buff.stacks) * mod.perstack)
if mod.modifier == "mult":
mult += mod.value + ((buff.stacks) * mod.perstack)
_modval = mod.value + ((buff.stacks) * mod.perstack)
calculated[mod.modifier]["total"] += _modval
if _modval > calculated[mod.modifier]["strongest"]:
calculated[mod.modifier]["strongest"] = _modval
return calculated
final = (value + add) * max(0, 1.0 + mult)
def _apply_mods(self, value, calc: dict, strongest=False):
"""Applies modifiers to a value.
Args:
value: The value to modify
calc: The dictionary of calculated modifier values (see _calculate_mods)
strongest: (optional) Applies only the strongest mods of the corresponding stat value (default: False)
Returns value modified by the relevant mods."""
final = value
if strongest:
final = (
(value + calc["add"]["strongest"])
/ max(1, 1.0 + calc["div"]["strongest"])
* max(0, 1.0 + calc["mult"]["strongest"])
)
else:
final = (
(value + calc["add"]["total"])
/ max(1, 1.0 + calc["div"]["total"])
* max(0, 1.0 + calc["mult"]["total"])
)
return final
def _remove_via_dict(self, buffs: dict, loud=True, dispel=False, expire=False, context=None):

View file

@ -16,6 +16,13 @@ class _EmptyBuff(BaseBuff):
pass
class _TestDivBuff(BaseBuff):
key = "tdb"
name = "tdb"
flavor = "divverbuff"
mods = [Mod("stat1", "div", 1)]
class _TestModBuff(BaseBuff):
key = "tmb"
name = "tmb"
@ -190,12 +197,19 @@ class TestBuffsAndHandler(EvenniaTest):
@patch("evennia.contrib.rpg.buffs.buff.utils.delay", new=Mock())
def test_details(self):
"""tests that buff details like name and flavor are correct"""
"""tests that buff details like name and flavor are correct; also test modifier viewing"""
handler: BuffHandler = self.testobj.buffs
handler.add(_TestModBuff)
handler.add(_TestTrigBuff)
self.assertEqual(handler.get("tmb").flavor, "modderbuff")
self.assertEqual(handler.get("ttb").name, "ttb")
mods = handler.view_modifiers("stat1")
_testmods = {
"add": {"total": 15, "strongest": 15},
"mult": {"total": 0, "strongest": 0},
"div": {"total": 0, "strongest": 0},
}
self.assertDictEqual(mods, _testmods)
@patch("evennia.contrib.rpg.buffs.buff.utils.delay", new=Mock())
def test_modify(self):
@ -228,9 +242,15 @@ class TestBuffsAndHandler(EvenniaTest):
handler.add(_TestModBuff2)
self.assertEqual(handler.check(_stat1, "stat1"), 100)
self.assertEqual(handler.check(_stat2, "stat2"), 30)
# apply only the strongest value
self.assertEqual(handler.check(_stat1, "stat1", strongest=True), 80)
# removing mod properly reduces value, doesn't affect other mods
handler.remove_by_type(_TestModBuff)
self.assertEqual(handler.check(_stat1, "stat1"), 30)
self.assertEqual(handler.check(_stat2, "stat2"), 20)
# divider mod test
handler.add(_TestDivBuff)
self.assertEqual(handler.check(_stat1, "stat1"), 15)
@patch("evennia.contrib.rpg.buffs.buff.utils.delay", new=Mock())
def test_trigger(self):