Merge branch 'develop' into contrib/evadventure

This commit is contained in:
Griatch 2022-08-03 23:06:53 +02:00
commit e1439104e0
14 changed files with 2134 additions and 31 deletions

View file

@ -30,9 +30,10 @@ jobs:
uses: actions/checkout@v2
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v2
uses: actions/setup-python@v4
with:
python-version: ${{ matrix.python-version }}
cache: pip
- name: Install doc-building dependencies
run: |

View file

@ -92,9 +92,13 @@ jobs:
run: docker ps -a
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v2
uses: actions/setup-python@v4
with:
python-version: ${{ matrix.python-version }}
cache: pip
cache-dependency-path: |
requirements.txt
requirements_extra.txt
- name: Install package dependencies
run: |
@ -118,9 +122,10 @@ jobs:
evennia migrate
evennia collectstatic --noinput
- name: Run test suite
- name: Run test suite with coverage
if: matrix.TESTING_DB == 'sqlite3' && matrix.python-version == '3.10'
working-directory: testing_mygame
run: |
cd testing_mygame
coverage run \
--source=../evennia \
--omit=*/migrations/*,*/urls.py,*/test*.py,*.sh,*.txt,*.md,*.pyc,*.service \
@ -132,6 +137,17 @@ jobs:
evennia
coverage xml
- name: Run test suite
if: matrix.TESTING_DB != 'sqlite3' || matrix.python-version != '3.10'
working-directory: testing_mygame
run: |
evennia test \
--settings=settings \
--keepdb \
--parallel 4 \
--timing \
evennia
# we only want to run coverall/codacy once, so we only do it for one of the matrix combinations
# it's also not critical if pushing to either service fails (happens for PRs since env is not
# available outside of the evennia org)

View file

@ -189,6 +189,9 @@ Up requirements to Django 4.0+, Twisted 22+, Python 3.9 or 3.10
to better match how search works elsewhere (volund)
- The `.at_traverse` hook now receives a `exit_obj` kwarg, linking back to the
exit triggering the hook (volund)
- Contrib `buffs` for managing temporary and permanent RPG status buffs effects (tegiminis)
- New `at_server_init()` hook called before all other startup hooks for all
startup modes. Used for more generic overriding (volund)
## Evennia 0.9.5

View file

@ -35,6 +35,8 @@ COMMAND_DEFAULT_CLASS = class_from_module(settings.COMMAND_DEFAULT_CLASS)
_FUNCPARSER = None
_ATTRFUNCPARSER = None
_KEY_REGEX = re.compile(r"(?P<attr>.*?)(?P<key>(\[.*\]\ *)+)?$")
# limit symbol import for API
__all__ = (
"ObjManipCommand",
@ -126,7 +128,28 @@ class ObjManipCommand(COMMAND_DEFAULT_CLASS):
aliases = [alias.strip() for alias in aliases.split(";") if alias.strip()]
if "/" in objdef:
objdef, attrs = [part.strip() for part in objdef.split("/", 1)]
attrs = [part.strip().lower() for part in attrs.split("/") if part.strip()]
_attrs = []
# Should an attribute key is specified, ie. we're working
# on a dict, what we want is to lowercase attribute name
# as usual but to preserve dict key case as one would
# expect:
#
# set box/MyAttr = {'FooBar': 1}
# Created attribute box/myattr [category:None] = {'FooBar': 1}
# set box/MyAttr['FooBar'] = 2
# Modified attribute box/myattr [category:None] = {'FooBar': 2}
for match in (
match
for part in map(str.strip, attrs.split("/"))
if part and (match := _KEY_REGEX.match(part.strip()))
):
attr = match.group("attr").lower()
# reappend untouched key, if present
if match.group("key"):
attr += match.group("key")
_attrs.append(attr)
attrs = _attrs
# store data
obj_defs[iside].append({"name": objdef, "option": option, "aliases": aliases})
obj_attrs[iside].append({"name": objdef, "attrs": attrs})

View file

@ -957,6 +957,39 @@ class TestBuilding(BaseEvenniaCommandTest):
"{'one': 99, 'three': 3, '+': 42, '+1': 33}",
)
# dict - case sensitive keys
self.call(
building.CmdSetAttribute(),
"Obj/test_case = {'FooBar': 1}",
"Created attribute Obj/test_case [category:None] = {'FooBar': 1}",
)
self.call(
building.CmdSetAttribute(),
"Obj/test_case['FooBar'] = 2",
"Modified attribute Obj/test_case [category:None] = {'FooBar': 2}",
)
self.call(
building.CmdSetAttribute(),
"Obj/test_case",
"Attribute Obj/test_case [category:None] = {'FooBar': 2}",
)
self.call(
building.CmdSetAttribute(),
"Obj/test_case['FooBar'] = {'BarBaz': 1}",
"Modified attribute Obj/test_case [category:None] = {'FooBar': {'BarBaz': 1}}",
)
self.call(
building.CmdSetAttribute(),
"Obj/test_case['FooBar']['BarBaz'] = 2",
"Modified attribute Obj/test_case [category:None] = {'FooBar': {'BarBaz': 2}}",
)
self.call(
building.CmdSetAttribute(),
"Obj/test_case",
"Attribute Obj/test_case [category:None] = {'FooBar': {'BarBaz': 2}}",
)
# tuple
self.call(
building.CmdSetAttribute(),

View file

@ -8,14 +8,14 @@ the commands with XYZ-aware equivalents.
"""
from collections import namedtuple
from django.conf import settings
from evennia import InterruptCommand
from evennia import default_cmds, CmdSet
from evennia import CmdSet, InterruptCommand, default_cmds
from evennia.commands.default import building
from evennia.contrib.grid.xyzgrid.xyzroom import XYZRoom
from evennia.contrib.grid.xyzgrid.xyzgrid import get_xyzgrid
from evennia.contrib.grid.xyzgrid.xyzroom import XYZRoom
from evennia.utils import ansi
from evennia.utils.utils import list_to_string, class_from_module, delay
from evennia.utils.utils import class_from_module, delay, list_to_string
COMMAND_DEFAULT_CLASS = class_from_module(settings.COMMAND_DEFAULT_CLASS)
@ -149,7 +149,8 @@ class CmdXYZOpen(building.CmdOpen):
if all(char in self.rhs for char in ("(", ")", ",")):
# search by (X,Y) or (X,Y,Z)
X, Y, *Z = self.rhs.split(",", 2)
inp = self.rhs.strip("()")
X, Y, *Z = inp.split(",", 2)
if not Z:
self.caller.msg("A full (X,Y,Z) coordinate must be given for the destination.")
raise InterruptCommand
@ -159,7 +160,7 @@ class CmdXYZOpen(building.CmdOpen):
try:
self.destination = XYZRoom.objects.get_xyz(xyz=(X, Y, Z))
except XYZRoom.DoesNotExist:
self.caller.msg("Found no target XYZRoom at ({X},{Y},{Y}).")
self.caller.msg(f"Found no target XYZRoom at ({X},{Y},{Z}).")
raise InterruptCommand
else:
# regular search query

View file

@ -0,0 +1,365 @@
# Buffs
Contribution by Tegiminis 2022
A buff is a timed object, attached to a game entity. It is capable of modifying values, triggering code, or both.
It is a common design pattern in RPGs, particularly action games.
Features:
- `BuffHandler`: A buff handler to apply to your objects.
- `BaseBuff`: A buff class to extend from to create your own buffs.
- `BuffableProperty`: A sample property class to show how to automatically check modifiers.
- `CmdBuff`: A command which applies buffs.
- `samplebuffs.py`: Some sample buffs to learn from.
## Quick Start
Assign the handler to a property on the object, like so.
```python
@lazy_property
def buffs(self) -> BuffHandler:
return BuffHandler(self)
```
You may then call the handler to add or manipulate buffs like so: `object.buffs`. See **Using the Handler**.
### Customization
If you want to customize the handler, you can feed the constructor two arguments:
- `dbkey`: The string you wish to use as the attribute key for the buff database. Defaults to "buffs". This allows you to keep separate buff pools - for example, "buffs" and "perks".
- `autopause`: If you want this handler to automatically pause playtime buffs when its owning object is unpuppeted.
> **Note**: If you enable autopausing, you MUST initialize the property in your owning object's
> `at_init` hook. Otherwise, a hot reload can cause playtime buffs to not update properly
> on puppet/unpuppet. You have been warned!
Let's say you want another handler for an object, `perks`, which has a separate database and
respects playtime buffs. You'd assign this new property as so:
```python
class BuffableObject(Object):
@lazy_property
def perks(self) -> BuffHandler:
return BuffHandler(self, dbkey='perks', autopause=True)
def at_init(self):
self.perks
```
## Using the Handler
Here's how to make use of your new handler.
### Apply a Buff
Call the handler's `add` method. This requires a class reference, and also contains a number of
optional arguments to customize the buff's duration, stacks, and so on. You can also store any arbitrary value
in the buff's cache by passing a dictionary through the `to_cache` optional argument. This will not overwrite the normal
values on the cache.
```python
self.buffs.add(StrengthBuff) # A single stack of StrengthBuff with normal duration
self.buffs.add(DexBuff, stacks=3, duration=60) # Three stacks of DexBuff, with a duration of 60 seconds
self.buffs.add(ReflectBuff, to_cache={'reflect': 0.5}) # A single stack of ReflectBuff, with an extra cache value
```
Two important attributes on the buff are checked when the buff is applied: `refresh` and `unique`.
- `refresh` (default: True) determines if a buff's timer is refreshed when it is reapplied.
- `unique` (default: True) determines if this buff is unique; that is, only one of it exists on the object.
The combination of these two booleans creates one of three kinds of keys:
- `Unique is True, Refresh is True/False`: The buff's default key.
- `Unique is False, Refresh is True`: The default key mixed with the applier's dbref. This makes the buff "unique-per-player", so you can refresh through reapplication.
- `Unique is False, Refresh is False`: The default key mixed with a randomized number.
### Get Buffs
The handler has several getter methods which return instanced buffs. You won't need to use these for basic functionality, but if you want to manipulate
buffs after application, they are very useful. The handler's `check`/`trigger` methods utilize some of these getters, while others are just for developer convenience.
`get(key)` is the most basic getter. It returns a single buff instance, or `None` if the buff doesn't exist on the handler. It is also the only getter
that returns a single buff instance, rather than a dictionary.
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.
- `get_all()` returns all buffs on this handler. You can also use the `handler.all` property.
- `get_by_type(BuffClass)` returns buffs of the specified type.
- `get_by_stat(stat)` returns buffs with a `Mod` object of the specified `stat` string in their `mods` list.
- `get_by_trigger(string)` returns buffs with the specified string in their `triggers` list.
- `get_by_source(Object)` returns buffs applied by the specified `source` object.
- `get_by_cachevalue(key, value)` returns buffs with the matching `key: value` pair in their cache. `value` is optional.
All group getters besides `get_all()` can "slice" an existing dictionary through the optional `to_filter` argument.
```python
dict1 = handler.get_by_type(Burned) # This finds all "Burned" buffs on the handler
dict2 = handler.get_by_source(self, to_filter=dict1) # This filters dict1 to find buffs with the matching source
```
> **Note**: Most of these getters also have an associated handler property. For example, `handler.effects` returns all buffs that can be triggered, which
> is then iterated over by the `get_by_trigger` method.
### Remove Buffs
There are also a number of remover methods. Generally speaking, these follow the same format as the getters.
- `remove(key)` removes the buff with the specified key.
- `clear()` removes all buffs.
- `remove_by_type(BuffClass)` removes buffs of the specified type.
- `remove_by_stat(stat)` removes buffs with a `Mod` object of the specified `stat` string in their `mods` list.
- `remove_by_trigger(string)` removes buffs with the specified string in their `triggers` list.
- `remove_by_source(Object)` removes buffs applied by the specified source
- `remove_by_cachevalue(key, value)` removes buffs with the matching `key: value` pair in their cache. `value` is optional.
You can also remove a buff by calling the instance's `remove` helper method. You can do this on the dictionaries returned by the
getters listed above.
```python
to_remove = handler.get_by_trigger(trigger) # Finds all buffs with the specified trigger
for buff in to_remove.values(): # Removes all buffs in the to_remove dictionary via helper methods
buff.remove()
```
### Check Modifiers
Call the handler `check(value, stat)` method when you want to see the modified value.
This will return the `value`, modified by any relevant buffs on the handler's owner (identified by
the `stat` string).
For example, let's say you want to modify how much damage you take. That might look something like this:
```python
# The method we call to damage ourselves
def take_damage(self, source, damage):
_damage = self.buffs.check(damage, 'taken_damage')
self.db.health -= _damage
```
This method calls the `at_pre_check` and `at_post_check` methods at the relevant points in the process. You can use to this make
buffs that are reactive to being checked; for example, removing themselves, altering their values, or interacting with the game state.
> **Note**: You can also trigger relevant buffs at the same time as you check them by ensuring the optional argument `trigger` is True in the `check` method.
### Trigger Buffs
Call the handler'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`.
For example, let's say you want to trigger a buff to "detonate" when you hit your target with an attack.
You'd write a buff that might look like this:
```python
class Detonate(BaseBuff):
...
triggers = ['take_damage']
def at_trigger(self, trigger, *args, **kwargs)
self.owner.take_damage(100)
self.remove()
```
And then call `handler.trigger('take_damage')` in the method you use to take damage.
> **Note** You could also do this through mods and `at_post_check` if you like, depending on how to want to add the damage.
### Ticking
Ticking buffs are slightly special. They are similar to trigger buffs in that they run code, but instead of
doing so on an event trigger, they do so on a periodic tick. A common use case for a buff like this is a poison,
or a heal over time.
```python
class Poison(BaseBuff):
...
tickrate = 5
def at_tick(self, initial=True, *args, **kwargs):
_dmg = self.dmg * self.stacks
if not initial:
self.owner.location.msg_contents(
"Poison courses through {actor}'s body, dealing {damage} damage.".format(
actor=self.owner.named, damage=_dmg
)
)
```
To make a buff ticking, ensure the `tickrate` is 1 or higher, and it has code in its `at_tick`
method. Once you add it to the handler, it starts ticking!
> **Note**: Ticking buffs always tick on initial application, when `initial` is `True`. If you don't want your hook to fire at that time,
> make sure to check the value of `initial` in your `at_tick` method.
### Context
Every important handler method optionally accepts a `context` dictionary.
Context is an important concept for this handler. Every method which checks, triggers, or ticks a buff passes this
dictionary (default: empty) to the buff hook methods as keyword arguments (`**kwargs`). It is used for nothing else. This allows you to make those
methods "event-aware" by storing relevant data in the dictionary you feed to the method.
For example, let's say you want a "thorns" buff which damages enemies that attack you. Let's take our `take_damage` method
and add a context to the mix.
```python
def take_damage(attacker, damage):
context = {'attacker': attacker, 'damage': damage}
_damage = self.buffs.check(damage, 'taken_damage', context=context)
self.buffs.trigger('taken_damage', context=context)
self.db.health -= _damage
```
Now we use the values that context passes to the buff kwargs to customize our logic.
```python
class ThornsBuff(BaseBuff):
...
triggers = ['taken_damage']
# This is the hook method on our thorns buff
def at_trigger(self, trigger, attacker=None, damage=0, **kwargs):
if not attacker:
return
attacker.db.health -= damage * 0.2
```
Apply the buff, take damage, and watch the thorns buff do its work!
## Creating New Buffs
Creating a new buff is very easy: extend `BaseBuff` into a new class, and fill in all the relevant buff details.
However, there are a lot of individual moving parts to a buff. Here's a step-through of the important stuff.
### Basics
Regardless of any other functionality, all buffs have the following class attributes:
- 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 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:
- `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`).
### 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
mods of a specific stat string and apply their modifications to the value; however, you are encouraged to use `check` in a getter/setter, for easy access.
Mod objects consist of only four values, assigned by the constructor in this order:
- `stat`: The stat you want to modify. When `check` is called, this string is used to find all the mods that are to be collected.
- `mod`: The modifier. Defaults are 'add' and 'mult'. Modifiers are calculated additively, and in standard arithmetic order (see `_calculate_mods` for more)
- `value`: How much value the modifier gives regardless of stacks
- `perstack`: How much value the modifier grants per stack, INCLUDING the first. (default: 0)
The most basic way to add a Mod to a buff is to do so in the buff class definition, like this:
```python
class DamageBuff(BaseBuff):
mods = [Mod('damage', 'add', 10)]
```
No mods applied to the value are permanent in any way. All calculations are done at runtime, and the mod values are never stored
anywhere except on the buff in question. In other words: you don't need to track the origin of particular stat mods, and you will
never permanently change a stat modified by a buff. To remove the modification, simply remove the buff from the object.
> **Note**: You can add your own modifier types by overloading the `_calculate_mods` method, which contains the basic modifier application logic.
#### Generating Mods (Advanced)
An advanced way to do mods is to generate them when the buff is initialized. This lets you create mods on the fly that are reactive to the game state.
```python
class GeneratedStatBuff(BaseBuff):
...
def __init__(self, handler, buffkey, cache={}) -> None:
super().__init__(handler, buffkey, cache)
# Finds our "modgen" cache value, and generates a mod from it
modgen = list(self.cache.get("modgen"))
if modgen:
self.mods = [Mod(*modgen)]
```
### Triggers
Buffs which have one or more strings in the `triggers` attribute can be triggered by events.
When the handler's `trigger` method is called, it searches all buffs on the handler for any with a matchingtrigger,
then calls their `at_trigger` hooks. Buffs can have multiple triggers, and you can tell which trigger was used by
the `trigger` argument in the hook.
```python
class AmplifyBuff(BaseBuff):
triggers = ['damage', 'heal']
def at_trigger(self, trigger, **kwargs):
if trigger == 'damage': print('Damage trigger called!')
if trigger == 'heal': print('Heal trigger called!')
```
### Ticking
A buff which ticks isn't much different than one which triggers. You're still executing arbitrary hooks on
the buff class. To tick, the buff must have a `tickrate` of 1 or higher.
```python
class Poison(BaseBuff):
...
# this buff will tick 6 times between application and cleanup.
duration = 30
tickrate = 5
def at_tick(self, initial, **kwargs):
self.owner.take_damage(10)
```
> **Note**: The buff always ticks once when applied. For this **first tick only**, `initial` will be True in the `at_tick` hook method. `initial` will be False on subsequent ticks.
Ticks utilize a persistent delay, so they should be pickleable. As long as you are not adding new properties to your buff class, this shouldn't be a concern.
If you **are** adding new properties, try to ensure they do not end up with a circular code path to their object or handler, as this will cause pickling errors.
### Extras
Buffs have a grab-bag of extra functionality to let you add complexity to your designs.
#### Conditionals
You can restrict whether or not the buff will `check`, `trigger`, or `tick` through defining the `conditional` hook. As long
as it returns a "truthy" value, the buff will apply itself. This is useful for making buffs dependent on game state - for
example, if you want a buff that makes the player take more damage when they are on fire:
```python
class FireSick(BaseBuff):
...
def conditional(self, *args, **kwargs):
if self.owner.buffs.get_by_type(FireBuff):
return True
return False
```
Conditionals for `check`/`trigger` are checked when the buffs are gathered by the handler methods for the respective operations. `Tick`
conditionals are checked each tick.
#### Helper Methods
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.
#### Playtime Duration
If your handler has `autopause` enabled, any buffs with truthy `playtime` value will automatically pause
and unpause when the object the handler is attached to is puppetted or unpuppetted. This even works with ticking buffs,
although if you have less than 1 second of tick duration remaining, it will round up to 1s.
> **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.

View file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,141 @@
import random
from .buff import BaseBuff, Mod
class Exploit(BaseBuff):
key = "exploit"
name = "Exploit"
flavor = "You are learning your opponent's weaknesses."
duration = -1
maxstacks = 20
triggers = ["hit"]
stack_msg = {
1: "You begin to notice flaws in your opponent's defense.",
10: "You've begun to match the battle's rhythm.",
20: "You've found a gap in the guard!",
}
def conditional(self, *args, **kwargs):
if self.handler.get_by_type(Exploited):
return False
return True
def at_trigger(self, trigger: str, *args, **kwargs):
chance = self.stacks / 20
roll = random.random()
if chance > roll:
self.handler.add(Exploited)
self.owner.msg("An opportunity presents itself!")
elif chance < roll:
self.handler.add(Exploit)
if self.stacks in self.stack_msg:
self.owner.msg(self.stack_msg[self.stacks])
class Exploited(BaseBuff):
key = "exploited"
name = "Exploited"
flavor = "You have sensed your target's vulnerability, and are poised to strike."
duration = 30
mods = [Mod("damage", "add", 100)]
def at_post_check(self, *args, **kwargs):
self.owner.msg("You ruthlessly exploit your target's weakness!")
self.remove(quiet=True)
def at_remove(self, *args, **kwargs):
self.owner.msg("You have waited too long; the opportunity passes.")
class Leeching(BaseBuff):
key = "leeching"
name = "Leeching"
flavor = "Attacking this target fills you with vigor."
duration = 30
triggers = ["taken_damage"]
def at_trigger(self, trigger: str, attacker=None, damage=None, *args, **kwargs):
if not attacker or not damage:
return
attacker.msg("You have been healed for {heal} life!".format(heal=damage * 0.1))
class Poison(BaseBuff):
key = "poison"
name = "Poison"
flavor = "A poison wracks this body with painful spasms."
duration = 120
maxstacks = 5
tickrate = 5
dmg = 5
playtime = True
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)
)
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
)
)
def at_tick(self, initial=True, *args, **kwargs):
_dmg = self.dmg * self.stacks
if not initial:
self.owner.location.msg_contents(
"Poison courses through {actor}'s body, dealing {damage} damage.".format(
actor=self.owner.named, damage=_dmg
)
)
class Sated(BaseBuff):
key = "sated"
name = "Sated"
flavor = "You have eaten a great meal!"
duration = 180
maxstacks = 3
mods = [Mod("mood", "add", 15)]
class StatBuff(BaseBuff):
"""Customize the stat this buff affects by feeding a list in the order [stat, mod, base, perstack] to the cache argument when added"""
key = "statbuff"
name = "statbuff"
flavor = "This buff affects the following stats: {stats}"
maxstacks = 0
refresh = True
unique = False
cache = {"modgen": ["foo", "add", 0, 0]}
def __init__(self, handler, buffkey, cache={}) -> None:
super().__init__(handler, buffkey, cache)
# Finds our "modgen" cache value, which we pass on application
modgen = list(self.cache.get("modgen"))
if modgen:
self.mods = [Mod(*modgen)]
msg = ""
_msg = [mod.stat for mod in self.mods]
for stat in _msg:
msg += stat
self.flavor = self.flavor.format(stats=msg)

View file

@ -0,0 +1,393 @@
"""
Tests for the buff system contrib
"""
from unittest.mock import Mock, call, patch
from evennia import DefaultObject, create_object
from evennia.utils import create
from evennia.utils.utils import lazy_property
from .samplebuffs import StatBuff
from .buff import BaseBuff, Mod, BuffHandler, BuffableProperty
from evennia.utils.test_resources import EvenniaTest
from evennia.contrib.rpg.buffs import buff
class _EmptyBuff(BaseBuff):
pass
class _TestModBuff(BaseBuff):
key = "tmb"
name = "tmb"
flavor = "modderbuff"
maxstacks = 5
mods = [Mod("stat1", "add", 10, 5), Mod("stat2", "mult", 0.5)]
class _TestModBuff2(BaseBuff):
key = "tmb2"
name = "tmb2"
flavor = "modderbuff2"
maxstacks = 1
mods = [Mod("stat1", "mult", 1.0), Mod("stat1", "add", 10)]
class _TestTrigBuff(BaseBuff):
key = "ttb"
name = "ttb"
flavor = "triggerbuff"
triggers = ["test1", "test2"]
def at_trigger(self, trigger: str, *args, **kwargs):
if trigger == "test1":
self.owner.db.triggertest1 = True
if trigger == "test2":
self.owner.db.triggertest2 = True
class _TestConBuff(BaseBuff):
key = "tcb"
name = "tcb"
flavor = "condbuff"
triggers = ["condtest"]
def conditional(self, *args, **kwargs):
return self.owner.db.cond1
def at_trigger(self, trigger: str, attacker=None, defender=None, damage=0, *args, **kwargs):
defender.db.att, defender.db.dmg = attacker, damage
class _TestComplexBuff(BaseBuff):
key = "tcomb"
name = "complex"
flavor = "combuff"
triggers = ["comtest", "complextest"]
mods = [
Mod("com1", "add", 0, 10),
Mod("com1", "add", 15),
Mod("com1", "mult", 2.0),
Mod("com2", "add", 100),
]
def conditional(self, cond=False, *args, **kwargs):
return not cond
def at_trigger(self, trigger: str, *args, **kwargs):
if trigger == "comtest":
self.owner.db.comtext = {"cond": True}
else:
self.owner.db.comtext = {}
class _TestTimeBuff(BaseBuff):
key = "ttib"
name = "ttib"
flavor = "timerbuff"
maxstacks = 1
tickrate = 1
duration = 5
mods = [Mod("timetest", "add", 665)]
def at_tick(self, initial=True, *args, **kwargs):
self.owner.db.ticktest = True
class BuffableObject(DefaultObject):
stat1 = BuffableProperty(10)
@lazy_property
def buffs(self) -> BuffHandler:
return BuffHandler(self)
def at_init(self):
self.stat1, self.buffs
return super().at_init()
class TestBuffsAndHandler(EvenniaTest):
"This tests a number of things about buffs."
def setUp(self):
super().setUp()
self.testobj = create.create_object(BuffableObject, key="testobj")
def tearDown(self):
"""done after every test_* method below"""
self.testobj.buffs.clear()
del self.testobj
super().tearDown()
@patch("evennia.contrib.rpg.buffs.buff.utils.delay", new=Mock())
def test_addremove(self):
"""tests adding and removing buffs"""
# setup
handler: BuffHandler = self.testobj.buffs
# add
handler.add(_TestModBuff, to_cache={"cachetest": True})
handler.add(_TestTrigBuff)
self.assertEqual(self.testobj.db.buffs["tmb"]["ref"], _TestModBuff)
self.assertTrue(self.testobj.db.buffs["tmb"].get("cachetest"))
self.assertFalse(self.testobj.db.buffs["ttb"].get("cachetest"))
# has
self.assertTrue(handler.has(_TestModBuff))
self.assertTrue(handler.has("tmb"))
self.assertFalse(handler.has(_EmptyBuff))
# remove
handler.remove("tmb")
self.assertFalse(self.testobj.db.buffs.get("tmb"))
# remove stacks
handler.add(_TestModBuff, stacks=3)
handler.remove("tmb", stacks=3)
self.assertFalse(self.testobj.db.buffs.get("tmb"))
# remove by type
handler.add(_TestModBuff)
handler.remove_by_type(_TestModBuff)
self.assertFalse(self.testobj.db.buffs.get("tmb"))
# remove by buff instance
handler.add(_TestModBuff)
handler.all["tmb"].remove()
self.assertFalse(self.testobj.db.buffs.get("tmb"))
# remove by source
handler.add(_TestModBuff)
handler.remove_by_source(None)
self.assertFalse(self.testobj.db.buffs.get("tmb"))
# remove by cachevalue
handler.add(_TestModBuff)
handler.remove_by_cachevalue("failure", True)
self.assertTrue(self.testobj.db.buffs.get("tmb"))
# remove all
handler.add(_TestModBuff)
handler.clear()
self.assertFalse(self.testobj.buffs.all)
@patch("evennia.contrib.rpg.buffs.buff.utils.delay", new=Mock())
def test_getters(self):
"""tests all built-in getters"""
# setup
handler: BuffHandler = self.testobj.buffs
handler.add(_TestModBuff, source=self.obj2)
handler.add(_TestTrigBuff, to_cache={"ttbcache": True})
# normal getter
self.assertTrue(isinstance(handler.get("tmb"), _TestModBuff))
# stat getters
self.assertTrue(isinstance(handler.get_by_stat("stat1")["tmb"], _TestModBuff))
self.assertFalse(handler.get_by_stat("nullstat"))
# trigger getters
self.assertTrue("ttb" in handler.get_by_trigger("test1").keys())
self.assertFalse("ttb" in handler.get_by_trigger("nulltrig").keys())
# type getters
self.assertTrue("tmb" in handler.get_by_type(_TestModBuff))
self.assertFalse("tmb" in handler.get_by_type(_EmptyBuff))
# source getter
self.assertTrue("tmb" in handler.get_by_source(self.obj2))
self.assertFalse("ttb" in handler.get_by_source(self.obj2))
# cachevalue getter
self.assertFalse("tmb" in handler.get_by_cachevalue("ttbcache"))
self.assertTrue("ttb" in handler.get_by_cachevalue("ttbcache"))
self.assertTrue("ttb" in handler.get_by_cachevalue("ttbcache", True))
@patch("evennia.contrib.rpg.buffs.buff.utils.delay", new=Mock())
def test_details(self):
"""tests that buff details like name and flavor are correct"""
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")
@patch("evennia.contrib.rpg.buffs.buff.utils.delay", new=Mock())
def test_modify(self):
"""tests to ensure that values are modified correctly, and stack across mods"""
# setup
handler: BuffHandler = self.testobj.buffs
_stat1, _stat2 = 0, 10
handler.add(_TestModBuff)
# stat1 and 2 basic mods
self.assertEqual(handler.check(_stat1, "stat1"), 15)
self.assertEqual(handler.check(_stat2, "stat2"), 15)
# checks can take any base value
self.assertEqual(handler.check(_stat1, "stat2"), 0)
self.assertEqual(handler.check(_stat2, "stat1"), 25)
# change to base stat reflected in check
_stat1 += 5
self.assertEqual(handler.check(_stat1, "stat1"), 20)
_stat2 += 10
self.assertEqual(handler.check(_stat2, "stat2"), 30)
# test stacking; single stack, multiple stack, max stacks
handler.add(_TestModBuff)
self.assertEqual(handler.check(_stat1, "stat1"), 25)
handler.add(_TestModBuff, stacks=3)
self.assertEqual(handler.check(_stat1, "stat1"), 40)
handler.add(_TestModBuff, stacks=5)
self.assertEqual(handler.check(_stat1, "stat1"), 40)
# stat2 mod doesn't stack
self.assertEqual(handler.check(_stat2, "stat2"), 30)
# layers with second mod
handler.add(_TestModBuff2)
self.assertEqual(handler.check(_stat1, "stat1"), 100)
self.assertEqual(handler.check(_stat2, "stat2"), 30)
handler.remove_by_type(_TestModBuff)
self.assertEqual(handler.check(_stat1, "stat1"), 30)
self.assertEqual(handler.check(_stat2, "stat2"), 20)
@patch("evennia.contrib.rpg.buffs.buff.utils.delay", new=Mock())
def test_trigger(self):
"""tests to ensure triggers correctly fire"""
# setup
handler: BuffHandler = self.testobj.buffs
handler.add(_TestTrigBuff)
# trigger buffs
handler.trigger("nulltest")
self.assertFalse(self.testobj.db.triggertest1)
self.assertFalse(self.testobj.db.triggertest2)
handler.trigger("test1")
self.assertTrue(self.testobj.db.triggertest1)
self.assertFalse(self.testobj.db.triggertest2)
handler.trigger("test2")
self.assertTrue(self.testobj.db.triggertest1)
self.assertTrue(self.testobj.db.triggertest2)
@patch("evennia.contrib.rpg.buffs.buff.utils.delay", new=Mock())
def test_context_conditional(self):
"""tests to ensure context is passed to buffs, and also tests conditionals"""
# setup
handler: BuffHandler = self.testobj.buffs
handler.add(_TestConBuff)
self.testobj.db.cond1, self.testobj.db.att, self.testobj.db.dmg = False, None, 0
# context to pass, containing basic event data and a little extra to be ignored
_testcontext = {
"attacker": self.obj2,
"defender": self.testobj,
"damage": 5,
"overflow": 10,
}
# test negative conditional
self.assertEqual(
handler.get_by_type(_TestConBuff)["tcb"].conditional(**_testcontext), False
)
handler.trigger("condtest", _testcontext)
self.assertEqual(self.testobj.db.att, None)
self.assertEqual(self.testobj.db.dmg, 0)
# test positive conditional + context passing
self.testobj.db.cond1 = True
self.assertEqual(handler.get_by_type(_TestConBuff)["tcb"].conditional(**_testcontext), True)
handler.trigger("condtest", _testcontext)
self.assertEqual(self.testobj.db.att, self.obj2)
self.assertEqual(self.testobj.db.dmg, 5)
@patch("evennia.contrib.rpg.buffs.buff.utils.delay", new=Mock())
def test_complex(self):
"""tests a complex mod (conditionals, multiple triggers/mods)"""
# setup
handler: BuffHandler = self.testobj.buffs
self.testobj.db.comone, self.testobj.db.comtwo, self.testobj.db.comtext = 10, 0, {}
handler.add(_TestComplexBuff)
# stat checks work correctly and separately
self.assertEqual(self.testobj.db.comtext, {})
self.assertEqual(handler.check(self.testobj.db.comone, "com1"), 105)
self.assertEqual(handler.check(self.testobj.db.comtwo, "com2"), 100)
# stat checks don't happen if the conditional is true
handler.trigger("comtest", self.testobj.db.comtext)
self.assertEqual(self.testobj.db.comtext, {"cond": True})
self.assertEqual(
handler.get_by_type(_TestComplexBuff)["tcomb"].conditional(**self.testobj.db.comtext),
False,
)
self.assertEqual(
handler.check(self.testobj.db.comone, "com1", context=self.testobj.db.comtext), 10
)
self.assertEqual(
handler.check(self.testobj.db.comtwo, "com2", context=self.testobj.db.comtext), 0
)
handler.trigger("complextest", self.testobj.db.comtext)
self.assertEqual(
handler.check(self.testobj.db.comone, "com1", context=self.testobj.db.comtext), 10
)
self.assertEqual(
handler.check(self.testobj.db.comtwo, "com2", context=self.testobj.db.comtext), 0
)
# separate trigger follows different codepath
self.testobj.db.comtext = {"cond": False}
handler.trigger("complextest", self.testobj.db.comtext)
self.assertEqual(self.testobj.db.comtext, {})
self.assertEqual(
handler.check(self.testobj.db.comone, "com1", context=self.testobj.db.comtext), 105
)
self.assertEqual(
handler.check(self.testobj.db.comtwo, "com2", context=self.testobj.db.comtext), 100
)
@patch("evennia.contrib.rpg.buffs.buff.utils.delay")
def test_timing(self, mock_delay: Mock):
"""tests timing-related features, such as ticking and duration"""
# setup
handler: BuffHandler = self.testobj.buffs
mock_delay.side_effect = [None, handler.cleanup]
handler.add(_TestTimeBuff)
calls = [
call(
1,
buff.tick_buff,
handler=handler,
buffkey="ttib",
context={},
initial=False,
persistent=True,
),
call(5, handler.cleanup, persistent=True),
]
mock_delay.assert_has_calls(calls)
self.testobj.db.timetest, self.testobj.db.ticktest = 1, False
# test duration and ticking
_instance = handler.get("ttib")
self.assertTrue(_instance.ticking)
self.assertEqual(_instance.duration, 5)
_instance.at_tick()
self.assertTrue(self.testobj.db.ticktest)
# test duration modification and cleanup
handler.set_duration("ttib", 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_buffableproperty(self):
"""tests buffable properties"""
# setup
self.testobj.buffs.add(_TestModBuff)
self.assertEqual(self.testobj.stat1, 25)
self.testobj.buffs.remove("tmb")
self.assertEqual(self.testobj.stat1, 10)
@patch("evennia.contrib.rpg.buffs.buff.utils.delay", new=Mock())
def test_stresstest(self):
"""tests large amounts of buffs, and related removal methods"""
# setup
for x in range(1, 20):
self.testobj.buffs.add(_TestModBuff, key="test" + str(x))
self.testobj.buffs.add(_TestTrigBuff, key="trig" + str(x))
self.assertEqual(self.testobj.stat1, 295)
self.testobj.buffs.trigger("test1")
self.testobj.buffs.remove_by_type(_TestModBuff)
self.assertEqual(self.testobj.stat1, 10)
self.testobj.buffs.clear()
self.assertFalse(self.testobj.buffs.all)
@patch("evennia.contrib.rpg.buffs.buff.utils.delay", new=Mock())
def test_modgen(self):
"""test generating mods on the fly"""
# setup
handler: BuffHandler = self.testobj.buffs
self.testobj.db.gentest = 5
self.assertEqual(self.testobj.db.gentest, 5)
tc = {"modgen": ["gentest", "add", 5, 0]}
handler.add(StatBuff, key="gentest", to_cache=tc)
self.assertEqual(handler.check(self.testobj.db.gentest, "gentest"), 10)
tc = {"modgen": ["gentest", "add", 10, 0]}
handler.add(StatBuff, key="gentest", to_cache=tc)
self.assertEqual(handler.check(self.testobj.db.gentest, "gentest"), 15)
self.assertEqual(
handler.get("gentest").flavor, "This buff affects the following stats: gentest"
)

View file

@ -7,6 +7,7 @@ allows for customizing the server operation as desired.
This module must contain at least these global functions:
at_server_init()
at_server_start()
at_server_stop()
at_server_reload_start()
@ -16,6 +17,11 @@ at_server_cold_stop()
"""
def at_server_init():
"""
This is called first as the server is starting up, regardless of how.
"""
pass
def at_server_start():
"""

View file

@ -46,8 +46,9 @@ _SA = object.__setattr__
# a file with a flag telling the server to restart after shutdown or not.
SERVER_RESTART = os.path.join(settings.GAME_DIR, "server", "server.restart")
# module containing hook methods called during start_stop
SERVER_STARTSTOP_MODULE = mod_import(settings.AT_SERVER_STARTSTOP_MODULE)
# modules containing hook methods called during start_stop
SERVER_STARTSTOP_MODULES = [mod_import(mod) for mod in make_iter(settings.AT_SERVER_STARTSTOP_MODULE)
if isinstance(mod, str)]
# modules containing plugin services
SERVER_SERVICES_PLUGIN_MODULES = make_iter(settings.SERVER_SERVICES_PLUGIN_MODULES)
@ -413,6 +414,8 @@ class Evennia:
for typeclass_db in TypedObject.__subclasses__()
]
self.at_server_init()
# call correct server hook based on start file value
if mode == "reload":
logger.log_msg("Server successfully reloaded.")
@ -525,14 +528,23 @@ class Evennia:
# server start/stop hooks
def at_server_init(self):
"""
This is called first when the server is starting, before any other hooks, regardless of how it's starting.
"""
for mod in SERVER_STARTSTOP_MODULES:
if hasattr(mod, "at_server_init"):
mod.at_server_init()
def at_server_start(self):
"""
This is called every time the server starts up, regardless of
how it was shut down.
"""
if SERVER_STARTSTOP_MODULE:
SERVER_STARTSTOP_MODULE.at_server_start()
for mod in SERVER_STARTSTOP_MODULES:
if hasattr(mod, "at_server_start"):
mod.at_server_start()
def at_server_stop(self):
"""
@ -540,16 +552,18 @@ class Evennia:
of it is fore a reload, reset or shutdown.
"""
if SERVER_STARTSTOP_MODULE:
SERVER_STARTSTOP_MODULE.at_server_stop()
for mod in SERVER_STARTSTOP_MODULES:
if hasattr(mod, "at_server_stop"):
mod.at_server_stop()
def at_server_reload_start(self):
"""
This is called only when server starts back up after a reload.
"""
if SERVER_STARTSTOP_MODULE:
SERVER_STARTSTOP_MODULE.at_server_reload_start()
for mod in SERVER_STARTSTOP_MODULES:
if hasattr(mod, "at_server_reload_start"):
mod.at_server_reload_start()
def at_post_portal_sync(self, mode):
"""
@ -589,8 +603,9 @@ class Evennia:
This is called only time the server stops before a reload.
"""
if SERVER_STARTSTOP_MODULE:
SERVER_STARTSTOP_MODULE.at_server_reload_stop()
for mod in SERVER_STARTSTOP_MODULES:
if hasattr(mod, "at_server_reload_stop"):
mod.at_server_reload_stop()
def at_server_cold_start(self):
"""
@ -618,16 +633,18 @@ class Evennia:
if character:
character.delete()
guest.delete()
if SERVER_STARTSTOP_MODULE:
SERVER_STARTSTOP_MODULE.at_server_cold_start()
for mod in SERVER_STARTSTOP_MODULES:
if hasattr(mod, "at_server_cold_start"):
mod.at_server_cold_start()
def at_server_cold_stop(self):
"""
This is called only when the server goes down due to a shutdown or reset.
"""
if SERVER_STARTSTOP_MODULE:
SERVER_STARTSTOP_MODULE.at_server_cold_stop()
for mod in SERVER_STARTSTOP_MODULES:
if hasattr(mod, "at_server_cold_stop"):
mod.at_server_cold_stop()
# ------------------------------------------------------------

View file

@ -12,12 +12,12 @@ value - which may change as Evennia is developed. This way you can
always be sure of what you have changed and what is default behaviour.
"""
from django.contrib.messages import constants as messages
from django.urls import reverse_lazy
import os
import sys
from django.contrib.messages import constants as messages
from django.urls import reverse_lazy
######################################################################
# Evennia base server config
######################################################################
@ -386,9 +386,11 @@ INITIAL_SETUP_MODULE = "evennia.server.initial_setup"
# the server's initial setup sequence (the very first startup of the system).
# The check will fail quietly if module doesn't exist or fails to load.
AT_INITIAL_SETUP_HOOK_MODULE = "server.conf.at_initial_setup"
# Module containing your custom at_server_start(), at_server_reload() and
# at_server_stop() methods. These methods will be called every time
# the server starts, reloads and resets/stops respectively.
# Module(s) containing custom at_server_init(), at_server_start(),
# at_server_reload() and at_server_stop() methods. These methods will be called
# every time the server starts, reloads and resets/stops
# respectively. Can be given as a single path or a list of paths. If a list,
# each module's hooks will be called in list order.
AT_SERVER_STARTSTOP_MODULE = "server.conf.at_server_startstop"
# List of one or more module paths to modules containing a function start_
# plugin_services(application). This module will be called with the main