diff --git a/docs/source/Setup/Installation-Upgrade.md b/docs/source/Setup/Installation-Upgrade.md index 348378a989..50f952f0ad 100644 --- a/docs/source/Setup/Installation-Upgrade.md +++ b/docs/source/Setup/Installation-Upgrade.md @@ -1,13 +1,13 @@ # Upgrading an existing installation -## Evennia v0.9.5 to 1.0 +## Evennia v0.9.5 to 1.0 Prior to 1.0, all Evennia installs were [Git-installs](./Installation-Git.md). These instructions assume that you have a cloned `evennia` repo and use a virtualenv (best practices). - Make sure to stop Evennia 0.9.5 entirely with `evennia stop`. - `deactivate` to leave your active virtualenv. -- Make a _backup_ of your entire `mygame` folder, just to be sure! +- Make a _backup_ of your entire `mygame` folder, just to be sure! - Delete the old `evenv` folder, or rename it (in case you want to keep using 0.9.5 for a while). - Install Python 3.10 (recommended) or 3.9. Follow the [Git-installation](./Installation-Git.md) for your OS if needed. - If using virtualenv, make a _new_ one with `python3.10 -m venv evenv`, then activate with `source evenv/bin/activate` @@ -15,28 +15,31 @@ assume that you have a cloned `evennia` repo and use a virtualenv (best practice - `cd` into your `evennia/` folder (you want to see the `docs/`, `bin/` directories as well as a nested `evennia/` folder) - **Prior to 1.0 release only** - do `git checkout develop` to switch to the develop branch. After release, this will be found on the default master branch. -- `git pull` +- `git pull` - `pip install -e .` - If you want the optional extra libs, do `pip install -r requirements_extra.txt`. - Test that you can run the `evennia` command. -If you don't have anything you want to keep in your existing game dir, you can just start a new onew -using the normal [install instructions](./Installation.md). If you want to keep/convert your existing +If you don't have anything you want to keep in your existing game dir, you can just start a new onew +using the normal [install instructions](./Installation.md). If you want to keep/convert your existing game dir, continue below. - First, make a backup of your exising game dir! If you use version control, make sure to commit your current state. - `cd` to your existing 0.9.5-based game folder (like `mygame`.) -- If you have changed `mygame/web`, _rename_ the folder to `web_0.9.5`. If you didn't change anything (or don't have +- If you have changed `mygame/web`, _rename_ the folder to `web_0.9.5`. If you didn't change anything (or don't have anything you want to keep), you can _delete_ it entirely. - Copy `evennia/evennia/game_template/web` to `mygame/` (e.g. using `cp -Rf` or a file manager). This new `web` folder replaces the old one and has a very different structure. -- `evennia migrate` -- `evennia start` +- It's possible you need to replace/comment out import and calls to the deprecated +[`django.conf.urls`](https://docs.djangoproject.com/en/3.2/ref/urls/#url). The new way to call it is +[available here](https://docs.djangoproject.com/en/4.0/ref/urls/#django.urls.re_path). +- Run `evennia migrate` +- Run `evennia start` -If you made extensive work in your game dir, you may well find that you need to do some (hopefully minor) -changes to your code before it will start with Evennia 1.0. Some important points: +If you made extensive work in your game dir, you may well find that you need to do some (hopefully minor) +changes to your code before it will start with Evennia 1.0. Some important points: -- The `evennia/contrib/` folder changed structure - there are now categorized sub-folders, so you have to update +- The `evennia/contrib/` folder changed structure - there are now categorized sub-folders, so you have to update your imports. - Any `web` changes need to be moved back from your backup into the new structure of `web/` manually. -- See the [Evennia 1.0 Changelog](../Coding/Changelog.md) for all changes. \ No newline at end of file +- See the [Evennia 1.0 Changelog](../Coding/Changelog.md) for all changes. diff --git a/evennia/contrib/rpg/buffs/README.md b/evennia/contrib/rpg/buffs/README.md index aa2729db58..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. @@ -142,6 +144,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 +259,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,24 +279,45 @@ 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) - 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: +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`) + +#### 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 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. -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`). +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. + +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. + +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. ### Modifiers @@ -257,9 +327,9 @@ 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) +- `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: @@ -281,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: @@ -339,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 ``` @@ -354,6 +423,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 @@ -362,4 +432,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 01640366f5..c7f76154fd 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 @@ -117,15 +116,14 @@ 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? 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 @@ -134,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 @@ -144,6 +142,16 @@ class BaseBuff: return None return self.handler.owner + @property + def timeleft(self): + """Returns how much time this buff has left. If -1, it is permanent.""" + _tl = 0 + if not self.start: + _tl = self.duration + else: + _tl = max(-1, self.duration - (time.time() - self.start)) + return _tl + @property def ticking(self) -> bool: """Returns if this buff ticks or not (tickrate => 1)""" @@ -160,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. @@ -222,11 +231,28 @@ 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 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""" + 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 + 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 @@ -311,6 +337,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) @@ -425,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) @@ -438,6 +469,7 @@ class BuffHandler: "ref": buff, "start": time.time(), "duration": buff.duration, + "tickrate": buff.tickrate, "prevtick": time.time(), "paused": False, "stacks": stacks, @@ -749,7 +781,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: @@ -758,6 +792,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 @@ -769,15 +804,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(): @@ -840,23 +881,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. @@ -883,33 +926,60 @@ 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 set_duration(self, key, value): - """Sets the duration of the specified buff. + 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: - 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) -> 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.""" + 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() @@ -946,29 +1016,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): @@ -1065,10 +1164,10 @@ 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: + if (tr > time.time() - buff.prevtick and initial != True) or buff.paused: return # Only fire the at_tick methods if the conditional is truthy @@ -1078,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) @@ -1089,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 824888b361..0d6b0f36b5 100644 --- a/evennia/contrib/rpg/buffs/tests.py +++ b/evennia/contrib/rpg/buffs/tests.py @@ -13,9 +13,17 @@ from evennia.contrib.rpg.buffs import buff class _EmptyBuff(BaseBuff): + key = "empty" pass +class _TestDivBuff(BaseBuff): + key = "tdb" + name = "tdb" + flavor = "divverbuff" + mods = [Mod("stat1", "div", 1)] + + class _TestModBuff(BaseBuff): key = "tmb" name = "tmb" @@ -190,12 +198,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 +243,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): @@ -347,11 +368,22 @@ 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) + @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""" diff --git a/evennia/objects/objects.py b/evennia/objects/objects.py index 81cdad5557..b6eda9fc8a 100644 --- a/evennia/objects/objects.py +++ b/evennia/objects/objects.py @@ -1885,7 +1885,7 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase): now in. Args: - source_location (Object): Wwhere we came from. This may be `None`. + source_location (Object): Where we came from. This may be `None`. move_type (str): The type of move. "give", "traverse", etc. This is an arbitrary string provided to obj.move_to(). Useful for altering messages or altering logic depending diff --git a/evennia/typeclasses/attributes.py b/evennia/typeclasses/attributes.py index 4a00504bef..963877ff3e 100644 --- a/evennia/typeclasses/attributes.py +++ b/evennia/typeclasses/attributes.py @@ -202,6 +202,21 @@ class AttributeProperty: self._lockstring = lockstring self._autocreate = autocreate self._key = "" + + @property + def _default(self): + """ + Tries returning a new instance of default if callable. + + """ + if callable(self.__default): + return self.__default() + + return self.__default + + @_default.setter + def _default(self, value): + self.__default = value def __set_name__(self, cls, name): """