From 6d3fdc512f8dfabc006a3c5f1f8a8c2215c4ee30 Mon Sep 17 00:00:00 2001 From: Tegiminis Date: Fri, 5 Aug 2022 11:30:03 -0700 Subject: [PATCH 1/9] buff property that returns time left --- evennia/contrib/rpg/buffs/buff.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/evennia/contrib/rpg/buffs/buff.py b/evennia/contrib/rpg/buffs/buff.py index 01640366f5..b8ad197e35 100644 --- a/evennia/contrib/rpg/buffs/buff.py +++ b/evennia/contrib/rpg/buffs/buff.py @@ -144,6 +144,16 @@ class BaseBuff: return None return self.handler.owner + @property + def timeleft(self): + """Returns how much time this buff has left""" + _tl = 0 + if not self.start: + _tl = self.duration + else: + _tl = self.duration - (time.time() - self.start) + return _tl + @property def ticking(self) -> bool: """Returns if this buff ticks or not (tickrate => 1)""" From dce7fc8b1c34a45f66ead83100a57caf7ae8c39b Mon Sep 17 00:00:00 2001 From: Tegiminis Date: Fri, 5 Aug 2022 11:30:28 -0700 Subject: [PATCH 2/9] alter_cache helper method on buffs --- evennia/contrib/rpg/buffs/buff.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/evennia/contrib/rpg/buffs/buff.py b/evennia/contrib/rpg/buffs/buff.py index b8ad197e35..9e70403963 100644 --- a/evennia/contrib/rpg/buffs/buff.py +++ b/evennia/contrib/rpg/buffs/buff.py @@ -232,8 +232,18 @@ class BaseBuff: def reset(self): """Resets the buff start time as though it were just applied; functionally identical to a refresh""" + 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.""" + if not isinstance(to_cache, dict): + raise TypeError + _cache = dict(self.handler.buffcache[self.buffkey]) + _cache.update(to_cache) + self.cache = _cache + self.handler.buffcache[self.buffkey] = _cache + # endregion # region hook methods From 328c4e8fc99a20aaa07c81ede5a0523963befd0d Mon Sep 17 00:00:00 2001 From: Tegiminis Date: Fri, 5 Aug 2022 14:40:48 -0700 Subject: [PATCH 3/9] added view_modifiers, altered view to accept a to_filter arg --- evennia/contrib/rpg/buffs/buff.py | 39 ++++++++++++++++++++++++++++--- 1 file changed, 36 insertions(+), 3 deletions(-) diff --git a/evennia/contrib/rpg/buffs/buff.py b/evennia/contrib/rpg/buffs/buff.py index 9e70403963..3f008f4d19 100644 --- a/evennia/contrib/rpg/buffs/buff.py +++ b/evennia/contrib/rpg/buffs/buff.py @@ -923,13 +923,46 @@ class BuffHandler: self.buffcache[key]["duration"] = value return - def view(self) -> dict: - """Returns a buff flavor text as a dictionary of tuples in the format {key: (name, flavor)}. Common use for this is a buff readout of some kind.""" + def view(self, to_filter=None) -> dict: + """Returns a buff flavor text as a dictionary of tuples in the format {key: (name, flavor)}. Common use for this is a buff readout of some kind. + + Args: + to_filter: (optional) The dictionary of buffs to iterate over. If none is provided, returns all buffs (default: None)""" + if not isinstance(to_filter, dict): + raise TypeError self.cleanup() - _cache = self.visible + _cache = self.visible if not to_filter else to_filter _flavor = {k: (buff.name, buff.flavor) for k, buff in _cache.items()} return _flavor + def view_modifiers(self, stat: str, context=None): + """Checks all modifiers of the specified stat without actually applying them. Hits the conditional hook for relevant buffs. + + Args: + stat: The mod identifier string to search for + context: (optional) A dictionary you wish to pass to the conditional hooks as kwargs + + 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').""" + # Buff cleanup to make sure all buffs are valid before processing + self.cleanup() + + # Find all buffs and traits related to the specified stat. + if not context: + context = {} + applied = self.get_by_stat(stat) + if not applied: + return None + + # 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 + } + + # Calculate and return our values dictionary + calc = self._calculate_mods(stat, applied) + return calc + def cleanup(self): """Removes expired buffs, ensures pause state is respected.""" self._validate_state() From d93f4762a0516453b79cf163f74e22d4392e3176 Mon Sep 17 00:00:00 2001 From: Tegiminis Date: Fri, 5 Aug 2022 14:41:49 -0700 Subject: [PATCH 4/9] 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 --- evennia/contrib/rpg/buffs/README.md | 58 +++++++++++++++++++++- evennia/contrib/rpg/buffs/buff.py | 77 ++++++++++++++++++++++------- evennia/contrib/rpg/buffs/tests.py | 22 ++++++++- 3 files changed, 137 insertions(+), 20 deletions(-) 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): From e77b6d4d74c908441c1affcdee5b42800052de2a Mon Sep 17 00:00:00 2001 From: Tegiminis Date: Sun, 7 Aug 2022 18:07:28 -0700 Subject: [PATCH 5/9] readme update to support new cache interface (+3 squashed commit) Squashed commit: [872cb2621] added instance-cache link (thanks aogier!) added tests for cache link fix poison samplebuff fix min tickrate [6bd6ce3e6] fixed infinite-duration ticking buffs [2dbc0594d] tickrate improvements, init hook --- evennia/contrib/rpg/buffs/README.md | 39 +++++++++++++--------- evennia/contrib/rpg/buffs/buff.py | 42 ++++++++++++++---------- evennia/contrib/rpg/buffs/samplebuffs.py | 6 ++-- evennia/contrib/rpg/buffs/tests.py | 12 +++++++ 4 files changed, 63 insertions(+), 36 deletions(-) diff --git a/evennia/contrib/rpg/buffs/README.md b/evennia/contrib/rpg/buffs/README.md index 34f7ea94ef..62c76d1143 100644 --- a/evennia/contrib/rpg/buffs/README.md +++ b/evennia/contrib/rpg/buffs/README.md @@ -283,20 +283,6 @@ Regardless of any other functionality, all buffs have the following class attrib - They can `refresh` (bool), which resets the duration when stacked or reapplied. (default: True) - They can be `playtime` (bool) buffs, where duration only counts down during active play. (default: False) -They also always store some useful mutable information about themselves in the cache: - -- `ref` (class): The buff class path we use to construct the buff. -- `start` (float): The timestamp of when the buff was applied. -- `source` (Object): If specified; this allows you to track who or what applied the buff. -- `prevtick` (float): The timestamp of the previous tick. -- `duration` (float): The cached duration. This can vary from the class duration, depending on if the duration has been modified (paused, extended, shortened, etc). -- `stacks` (int): How many stacks they have. -- `paused` (bool): Paused buffs do not clean up, modify values, tick, or fire any hook methods. - -You can always access the raw cache dictionary through the `cache` attribute on an instanced buff. This is grabbed when you get the buff through -a handler method, so it may not always reflect recent changes you've made, depending on how you structure your buff calls. All of the above -mutable information can be found in this cache, as well as any arbitrary information you pass through the handler `add` method (via `to_cache`). - Buffs also have a few useful properties: - `owner`: The object this buff is attached to @@ -304,6 +290,29 @@ Buffs also have a few useful properties: - `timeleft`: How much time is remaining on the buff - `ticking`/`stacking`: If this buff ticks/stacks (checks `tickrate` and `maxstacks`) +#### Buff Cache (Advanced) + +Buffs always store some useful mutable information about themselves in the cache (what is stored on the owning object's database attribute). A buff's cache corresponds to `{buffkey: buffcache}`, where `buffcache` is a dictionary containing __at least__ the mutable information below.: + +- `ref` (class): The buff class path we use to construct the buff. +- `start` (float): The timestamp of when the buff was applied. +- `source` (Object): If specified; this allows you to track who or what applied the buff. +- `prevtick` (float): The timestamp of the previous tick. +- `duration` (float): The cached duration. This can vary from the class duration, depending on if the duration has been modified (paused, extended, shortened, etc). +- `tickrate` (float): The buff's tick rate. Cannot go below 0. Altering the tickrate on an applied buff will not cause it to start ticking if it wasn't ticking before. (pause and unpause to start/stop ticking on existing buffs) +- `stacks` (int): How many stacks they have. +- `paused` (bool): Paused buffs do not clean up, modify values, tick, or fire any hook methods. + +Sometimes you will want to dynamically update a buff's cache at runtime, such as changing a tickrate in a hook method, or altering a buff's duration. +You can do so by using the interface `buff.cachekey`. As long as the attribute name matches a key in the cache dictionary, +it will update the stored cache with the new value. + +> **Example**: You want to increase a buff's duration by 30 seconds. You use `buff.duration += 30`. This new duration is now reflected on both the instance and the cache. + +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`). + +> **Example**: You store `damage` as a value in the buff cache and use it for your poison buff. You want to increase it over time, so you use `buff.damage += 1` in the tick method. + ### 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 @@ -418,4 +427,4 @@ and unpause when the object the handler is attached to is puppetted or unpuppett although if you have less than 1 second of tick duration remaining, it will round up to 1s. > **Note**: If you want more control over this process, you can comment out the signal subscriptions on the handler and move the autopause logic -> to your object's `at_pre/post_puppet/unpuppet` hooks. +> to your object's `at_pre/post_puppet/unpuppet` hooks. \ No newline at end of file diff --git a/evennia/contrib/rpg/buffs/buff.py b/evennia/contrib/rpg/buffs/buff.py index 98e3b165a9..f360bf00d6 100644 --- a/evennia/contrib/rpg/buffs/buff.py +++ b/evennia/contrib/rpg/buffs/buff.py @@ -116,9 +116,8 @@ class BaseBuff: handler = None start = 0 - # Default buff duration; -1 or lower for permanent, 0 for "instant" (removed immediately) - duration = -1 + duration = -1 # Default buff duration; -1 for permanent, 0 for "instant", >0 normal playtime = False # Does this buff autopause when owning object is unpuppeted? refresh = True # Does the buff refresh its timer on application? @@ -133,7 +132,7 @@ class BaseBuff: @property def ticknum(self): """Returns how many ticks this buff has gone through as an integer.""" - x = (time.time() - self.start) / self.tickrate + x = (time.time() - self.start) / max(1, self.tickrate) return int(x) @property @@ -145,12 +144,12 @@ class BaseBuff: @property def timeleft(self): - """Returns how much time this buff has left""" + """Returns how much time this buff has left. If -1, it is permanent.""" _tl = 0 if not self.start: _tl = self.duration else: - _tl = self.duration - (time.time() - self.start) + _tl = max(-1, self.duration - (time.time() - self.start)) return _tl @property @@ -169,17 +168,18 @@ class BaseBuff: handler: The handler this buff is attached to buffkey: The key this buff uses on the cache cache: The cache dictionary (what you get if you use `handler.buffcache.get(key)`)""" - self.handler: BuffHandler = handler - self.buffkey = buffkey - # Cache assignment - self.cache = cache - # Default system cache values - self.start = self.cache.get("start") - self.duration = self.cache.get("duration") - self.prevtick = self.cache.get("prevtick") - self.paused = self.cache.get("paused") - self.stacks = self.cache.get("stacks") - self.source = self.cache.get("source") + required = {"handler": handler, "buffkey": buffkey, "cache": cache} + self.__dict__.update(cache) + self.__dict__.update(required) + # Init hook + self.at_init() + + def __setattr__(self, attr, value): + if attr in self.cache: + if attr == "tickrate": + value = max(0, value) + self.handler.buffcache[self.buffkey][attr] = value + super().__setattr__(attr, value) def conditional(self, *args, **kwargs): """Hook function for conditional evaluation. @@ -249,6 +249,10 @@ class BaseBuff: # endregion # region hook methods + def at_init(self, *args, **kwargs): + """Hook function called when this buff object is initialized.""" + pass + def at_apply(self, *args, **kwargs): """Hook function to run when this buff is applied to an object.""" pass @@ -461,6 +465,7 @@ class BuffHandler: "ref": buff, "start": time.time(), "duration": buff.duration, + "tickrate": buff.tickrate, "prevtick": time.time(), "paused": False, "stacks": stacks, @@ -1159,7 +1164,7 @@ def tick_buff(handler: BuffHandler, buffkey: str, context=None, initial=True): # Instantiate the buff and tickrate buff: BaseBuff = handler.get(buffkey) - tr = buff.tickrate + tr = max(1, buff.tickrate) # This stops the old ticking process if you refresh/stack the buff if tr > time.time() - buff.prevtick and initial != True: @@ -1172,7 +1177,7 @@ def tick_buff(handler: BuffHandler, buffkey: str, context=None, initial=True): buff.at_tick(initial, **context) # Tick this buff one last time, then remove - if buff.duration <= time.time() - buff.start: + if buff.duration > -1 and buff.duration <= time.time() - buff.start: if tr < time.time() - buff.prevtick: buff.at_tick(initial, **context) buff.remove(expire=True) @@ -1183,6 +1188,7 @@ def tick_buff(handler: BuffHandler, buffkey: str, context=None, initial=True): buff.at_tick(initial, **context) handler.buffcache[buffkey]["prevtick"] = time.time() + tr = max(1, buff.tickrate) # Recur this function at the tickrate interval, if it didn't stop/fail utils.delay( diff --git a/evennia/contrib/rpg/buffs/samplebuffs.py b/evennia/contrib/rpg/buffs/samplebuffs.py index 457e647805..60e3239275 100644 --- a/evennia/contrib/rpg/buffs/samplebuffs.py +++ b/evennia/contrib/rpg/buffs/samplebuffs.py @@ -84,13 +84,13 @@ class Poison(BaseBuff): def at_pause(self, *args, **kwargs): self.owner.db.prelogout_location.msg_contents( - "{actor} stops twitching, their flesh a deathly pallor.".format(actor=self.owner.named) + "{actor} stops twitching, their flesh a deathly pallor.".format(actor=self.owner) ) def at_unpause(self, *args, **kwargs): self.owner.location.msg_contents( "{actor} begins to twitch again, their cheeks flushing red with blood.".format( - actor=self.owner.named + actor=self.owner ) ) @@ -99,7 +99,7 @@ class Poison(BaseBuff): if not initial: self.owner.location.msg_contents( "Poison courses through {actor}'s body, dealing {damage} damage.".format( - actor=self.owner.named, damage=_dmg + actor=self.owner, damage=_dmg ) ) diff --git a/evennia/contrib/rpg/buffs/tests.py b/evennia/contrib/rpg/buffs/tests.py index 04a1fd038f..0fa115269a 100644 --- a/evennia/contrib/rpg/buffs/tests.py +++ b/evennia/contrib/rpg/buffs/tests.py @@ -13,6 +13,7 @@ from evennia.contrib.rpg.buffs import buff class _EmptyBuff(BaseBuff): + key = "empty" pass @@ -372,6 +373,17 @@ class TestBuffsAndHandler(EvenniaTest): handler.cleanup() self.assertFalse(handler.get("ttib"), None) + @patch("evennia.contrib.rpg.buffs.buff.utils.delay", new=Mock()) + def test_cacheattrlink(self): + """tests the link between the instance attribute and the cache attribute""" + # setup + handler: BuffHandler = self.testobj.buffs + handler.add(_EmptyBuff) + self.assertEqual(handler.buffcache["empty"]["duration"], -1) + empty: _EmptyBuff = handler.get("empty") + empty.duration = 30 + self.assertEqual(handler.buffcache["empty"]["duration"], 30) + @patch("evennia.contrib.rpg.buffs.buff.utils.delay", new=Mock()) def test_buffableproperty(self): """tests buffable properties""" From 87a990616f628a8431562cddb92d273bd1a5f3e8 Mon Sep 17 00:00:00 2001 From: Tegiminis Date: Tue, 9 Aug 2022 01:15:27 -0700 Subject: [PATCH 6/9] remove deprecated helper method change alter_cache to update_cache to better match what the method does (+1 squashed commits) Squashed commits: [23d61e14d] fix test --- evennia/contrib/rpg/buffs/buff.py | 14 ++------------ evennia/contrib/rpg/buffs/tests.py | 2 +- 2 files changed, 3 insertions(+), 13 deletions(-) diff --git a/evennia/contrib/rpg/buffs/buff.py b/evennia/contrib/rpg/buffs/buff.py index f360bf00d6..f21786763d 100644 --- a/evennia/contrib/rpg/buffs/buff.py +++ b/evennia/contrib/rpg/buffs/buff.py @@ -234,8 +234,8 @@ class BaseBuff: self.start = time.time() self.handler.buffcache[self.buffkey]["start"] = time.time() - def alter_cache(self, to_cache: dict): - """Alters this buff's cache, both internally (this instance) and on the handler's buff cache. + def update_cache(self, to_cache: dict): + """Updates this buff's cache using the given values, both internally (this instance) and on the handler. Args: to_cache: The dictionary of values you want to add to the cache""" @@ -930,16 +930,6 @@ class BuffHandler: ) return - def set_duration(self, key, value): - """Sets the duration of the specified buff. - - Args: - key: The key of the buff whose duration you want to set - value: The value you want the new duration to be""" - if key in self.buffcache.keys(): - self.buffcache[key]["duration"] = value - return - def view(self, to_filter=None) -> dict: """Returns a buff flavor text as a dictionary of tuples in the format {key: (name, flavor)}. Common use for this is a buff readout of some kind. diff --git a/evennia/contrib/rpg/buffs/tests.py b/evennia/contrib/rpg/buffs/tests.py index 0fa115269a..0d6b0f36b5 100644 --- a/evennia/contrib/rpg/buffs/tests.py +++ b/evennia/contrib/rpg/buffs/tests.py @@ -368,7 +368,7 @@ class TestBuffsAndHandler(EvenniaTest): _instance.at_tick() self.assertTrue(self.testobj.db.ticktest) # test duration modification and cleanup - handler.set_duration("ttib", 0) + _instance.duration = 0 self.assertEqual(handler.get("ttib").duration, 0) handler.cleanup() self.assertFalse(handler.get("ttib"), None) From 8397b9205b9a1185641391ca1523ec7fa9bf8f27 Mon Sep 17 00:00:00 2001 From: Tegiminis Date: Tue, 9 Aug 2022 03:50:39 -0700 Subject: [PATCH 7/9] can now pause infinite duration buffs --- evennia/contrib/rpg/buffs/buff.py | 42 ++++++++++++++++++------------- 1 file changed, 24 insertions(+), 18 deletions(-) diff --git a/evennia/contrib/rpg/buffs/buff.py b/evennia/contrib/rpg/buffs/buff.py index f21786763d..a4f76af508 100644 --- a/evennia/contrib/rpg/buffs/buff.py +++ b/evennia/contrib/rpg/buffs/buff.py @@ -877,23 +877,25 @@ class BuffHandler: start = buff["start"] # Start duration = buff["duration"] # Duration prevtick = buff["prevtick"] # Previous tick timestamp - tickrate = buff["ref"].tickrate # Buff's tick rate - - # Original buff ending, and new duration + tickrate = buff["tickrate"] # Buff's tick rate end = start + duration # End - newduration = end - current # New duration - # Apply the new duration - if newduration > 0: - buff["duration"] = newduration - if buff["ref"].ticking: - buff["tickleft"] = max(1, tickrate - (current - prevtick)) - self.buffcache[key] = buff - instance: BaseBuff = buff["ref"](self, key, buff) - instance.at_pause(**context) - else: - self.remove(key) - return + # Setting "tickleft" + if buff["ref"].ticking: + buff["tickleft"] = max(1, tickrate - (current - prevtick)) + + # Setting the new duration (if applicable) + if duration > -1: + newduration = end - current # New duration + if newduration > 0: + buff["duration"] = newduration + else: + self.remove(key) + + # Apply new cache info, call pause hook + self.buffcache[key] = buff + instance: BaseBuff = buff["ref"](self, key, buff) + instance.at_pause(**context) def unpause(self, key: str, context=None): """Unpauses a buff. This makes it visible to the various buff systems again. @@ -920,15 +922,19 @@ class BuffHandler: buff["start"] = current if buff["ref"].ticking: buff["prevtick"] = current - (tickrate - tickleft) + + # Apply new cache info, call hook self.buffcache[key] = buff instance: BaseBuff = buff["ref"](self, key, buff) instance.at_unpause(**context) - utils.delay(buff["duration"], cleanup_buffs, self, persistent=True) + + # Set up typical delays (cleanup/ticking) + if instance.duration > -1: + utils.delay(buff["duration"], cleanup_buffs, self, persistent=True) if instance.ticking: utils.delay( tickrate, tick_buff, handler=self, buffkey=key, initial=False, persistent=True ) - return def view(self, to_filter=None) -> dict: """Returns a buff flavor text as a dictionary of tuples in the format {key: (name, flavor)}. Common use for this is a buff readout of some kind. @@ -1157,7 +1163,7 @@ def tick_buff(handler: BuffHandler, buffkey: str, context=None, initial=True): tr = max(1, buff.tickrate) # This stops the old ticking process if you refresh/stack the buff - if tr > time.time() - buff.prevtick and initial != True: + if (tr > time.time() - buff.prevtick and initial != True) or buff.paused: return # Only fire the at_tick methods if the conditional is truthy From 7457e704d2ef1a6619d204b809197d83bf633e89 Mon Sep 17 00:00:00 2001 From: Tegiminis Date: Tue, 9 Aug 2022 04:04:37 -0700 Subject: [PATCH 8/9] comments --- evennia/contrib/rpg/buffs/buff.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/evennia/contrib/rpg/buffs/buff.py b/evennia/contrib/rpg/buffs/buff.py index a4f76af508..c7f76154fd 100644 --- a/evennia/contrib/rpg/buffs/buff.py +++ b/evennia/contrib/rpg/buffs/buff.py @@ -123,7 +123,7 @@ class BaseBuff: refresh = True # Does the buff refresh its timer on application? unique = True # Does the buff overwrite existing buffs with the same key on the same target? maxstacks = 1 # The maximum number of stacks the buff can have. If >1, this buff will stack. - stacks = 1 # If >1, used as the default when applying this buff + stacks = 1 # Used as the default when applying this buff if no or negative stacks were specified (min: 1) tickrate = 0 # How frequent does this buff tick, in seconds (cannot be lower than 1) mods = [] # List of mod objects. See Mod class below for more detail @@ -452,10 +452,14 @@ class BuffHandler: context = {} b = {} _context = dict(context) + + # Initial cache updating, starting with the class cache attribute and/or to_cache if buff.cache: b = dict(buff.cache) if to_cache: b.update(dict(to_cache)) + + # Guarantees we stack either at least 1 stack or whatever the class stacks attribute is if stacks < 1: stacks = min(1, buff.stacks) From 2f9786ae048bc68d35f891efe8f202657dac3c43 Mon Sep 17 00:00:00 2001 From: Tegiminis Date: Tue, 9 Aug 2022 11:49:33 -0700 Subject: [PATCH 9/9] i can't stop updating my readme!!! --- evennia/contrib/rpg/buffs/README.md | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/evennia/contrib/rpg/buffs/README.md b/evennia/contrib/rpg/buffs/README.md index 62c76d1143..8e082cede5 100644 --- a/evennia/contrib/rpg/buffs/README.md +++ b/evennia/contrib/rpg/buffs/README.md @@ -81,6 +81,8 @@ buffs after application, they are very useful. The handler's `check`/`trigger` m `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. +> **Note**: The handler method `has(buff)` allows you to check if a matching key (if a string) or buff class (if a class) is present on the handler cache, without actually instantiating the buff. You should use this method for basic "is this buff present?" checks. + Group getters, listed below, return a dictionary of values in the format `{buffkey: instance}`. If you want to iterate over all of these buffs, you should do so via the `dict.values()` method. @@ -292,24 +294,28 @@ Buffs also have a few useful properties: #### Buff Cache (Advanced) -Buffs always store some useful mutable information about themselves in the cache (what is stored on the owning object's database attribute). A buff's cache corresponds to `{buffkey: buffcache}`, where `buffcache` is a dictionary containing __at least__ the mutable information below.: +Buffs always store some useful mutable information about themselves in the cache (what is stored on the owning object's database attribute). A buff's cache corresponds to `{buffkey: buffcache}`, where `buffcache` is a dictionary containing __at least__ the information below: - `ref` (class): The buff class path we use to construct the buff. - `start` (float): The timestamp of when the buff was applied. - `source` (Object): If specified; this allows you to track who or what applied the buff. - `prevtick` (float): The timestamp of the previous tick. - `duration` (float): The cached duration. This can vary from the class duration, depending on if the duration has been modified (paused, extended, shortened, etc). -- `tickrate` (float): The buff's tick rate. Cannot go below 0. Altering the tickrate on an applied buff will not cause it to start ticking if it wasn't ticking before. (pause and unpause to start/stop ticking on existing buffs) +- `tickrate` (float): The buff's tick rate. Cannot go below 0. Altering the tickrate on an applied buff will not cause it to start ticking if it wasn't ticking before. (`pause` and `unpause` to start/stop ticking on existing buffs) - `stacks` (int): How many stacks they have. - `paused` (bool): Paused buffs do not clean up, modify values, tick, or fire any hook methods. Sometimes you will want to dynamically update a buff's cache at runtime, such as changing a tickrate in a hook method, or altering a buff's duration. -You can do so by using the interface `buff.cachekey`. As long as the attribute name matches a key in the cache dictionary, -it will update the stored cache with the new value. +You can do so by using the interface `buff.cachekey`. As long as the attribute name matches a key in the cache dictionary, it will update the stored +cache with the new value. + +If there is no matching key, it will do nothing. If you wish to add a new key to the cache, you must use the `buff.update_cache(dict)` method, +which will properly update the cache (including adding new keys) using the dictionary provided. > **Example**: You want to increase a buff's duration by 30 seconds. You use `buff.duration += 30`. This new duration is now reflected on both the instance and the cache. -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`). +The buff cache can also store arbitrary information. To do so, pass a dictionary through the handler `add` method (`handler.add(BuffClass, to_cache=dict)`), +set the `cache` dictionary attribute on your buff class, or use the aforementioned `buff.update_cache(dict)` method. > **Example**: You store `damage` as a value in the buff cache and use it for your poison buff. You want to increase it over time, so you use `buff.damage += 1` in the tick method. @@ -323,7 +329,7 @@ Mod objects consist of only four values, assigned by the constructor in this ord - `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` (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) +- `perstack`: How much value the modifier grants per stack, **INCLUDING** the first. (default: 0) The most basic way to add a Mod to a buff is to do so in the buff class definition, like this: @@ -345,8 +351,7 @@ An advanced way to do mods is to generate them when the buff is initialized. Thi ```python class GeneratedStatBuff(BaseBuff): ... - def __init__(self, handler, buffkey, cache={}) -> None: - super().__init__(handler, buffkey, cache) + def at_init(self, *args, **kwargs) -> None: # Finds our "modgen" cache value, and generates a mod from it modgen = list(self.cache.get("modgen")) if modgen: @@ -403,7 +408,7 @@ example, if you want a buff that makes the player take more damage when they are class FireSick(BaseBuff): ... def conditional(self, *args, **kwargs): - if self.owner.buffs.get_by_type(FireBuff): + if self.owner.buffs.has(FireBuff): return True return False ```