diff --git a/evennia/contrib/rpg/buffs/README.md b/evennia/contrib/rpg/buffs/README.md index aa2729db58..34f7ea94ef 100644 --- a/evennia/contrib/rpg/buffs/README.md +++ b/evennia/contrib/rpg/buffs/README.md @@ -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 diff --git a/evennia/contrib/rpg/buffs/buff.py b/evennia/contrib/rpg/buffs/buff.py index 3f008f4d19..98e3b165a9 100644 --- a/evennia/contrib/rpg/buffs/buff.py +++ b/evennia/contrib/rpg/buffs/buff.py @@ -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): diff --git a/evennia/contrib/rpg/buffs/tests.py b/evennia/contrib/rpg/buffs/tests.py index 824888b361..04a1fd038f 100644 --- a/evennia/contrib/rpg/buffs/tests.py +++ b/evennia/contrib/rpg/buffs/tests.py @@ -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):