mirror of
https://github.com/evennia/evennia.git
synced 2026-03-16 21:06:30 +01:00
Finish Twitch-combat tutorial
This commit is contained in:
parent
d2551aaaa7
commit
5b2e9bd5a1
17 changed files with 1273 additions and 49 deletions
|
|
@ -144,6 +144,10 @@ class LivingMixin:
|
|||
self.coins -= amount
|
||||
return amount
|
||||
|
||||
def at_attacked(self, attacker, **kwargs):
|
||||
"""Called when being attacked and combat starts."""
|
||||
pass
|
||||
|
||||
def at_damage(self, damage, attacker=None):
|
||||
"""Called when attacked and taking damage."""
|
||||
self.hp -= damage
|
||||
|
|
@ -172,6 +176,8 @@ class LivingMixin:
|
|||
```
|
||||
Most of these are empty since they will behave differently for characters and npcs. But having them in the mixin means we can expect these methods to be available for all living things.
|
||||
|
||||
Once we create more of our game, we will need to remember to actually call these hook methods so they serve a purpose. For example, once we implement combat, we must remember to call `at_attacked` as well as the other methods involving taking damage, getting defeated or dying.
|
||||
|
||||
|
||||
## Character class
|
||||
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ class CombatFailure(RuntimeError):
|
|||
pass
|
||||
|
||||
|
||||
class EvAdventureCombatHandlerBase(DefaultSCript):
|
||||
class EvAdventureCombatBaseHandler(DefaultSCript):
|
||||
"""
|
||||
This should be created when combat starts. It 'ticks' the combat
|
||||
and tracks all sides of it.
|
||||
|
|
@ -161,7 +161,7 @@ from evennia import create_script
|
|||
|
||||
# ...
|
||||
|
||||
class EvAdventureCombatHandlerBase(DefaultScript):
|
||||
class EvAdventureCombatBaseHandler(DefaultScript):
|
||||
|
||||
# ...
|
||||
|
||||
|
|
@ -209,7 +209,7 @@ This is a `classmethod`, meaning it should be used on the handler class directly
|
|||
As a class method we'll need to call this directly on the class, like this:
|
||||
|
||||
```python
|
||||
combathandler = EvAdventureCombatHandlerBase.get_or_create_combathandler(combatant)
|
||||
combathandler = EvAdventureCombatBaseHandler.get_or_create_combathandler(combatant)
|
||||
```
|
||||
|
||||
The result will be a new handler _or_ one that was already defined.
|
||||
|
|
@ -222,7 +222,7 @@ The result will be a new handler _or_ one that was already defined.
|
|||
|
||||
# ...
|
||||
|
||||
class EvAdventureCombatHandlerBase(DefaultScript):
|
||||
class EvAdventureCombatBaseHandler(DefaultScript):
|
||||
# ...
|
||||
|
||||
def msg(self, message, combatant=None, broadcast=True, location=None):
|
||||
|
|
@ -321,7 +321,7 @@ from evennia import EvTable
|
|||
|
||||
# ...
|
||||
|
||||
class EvAdventureCombatHandlerBase(DefaultScript):
|
||||
class EvAdventureCombatBaseHandler(DefaultScript):
|
||||
|
||||
# ...
|
||||
|
||||
|
|
@ -709,19 +709,20 @@ For example, consider this following test of the `CombatHandler.get_combat_summa
|
|||
|
||||
```{code-block} python
|
||||
:linenos:
|
||||
:emphasize-lines: 24,31
|
||||
:emphasize-lines: 25,32
|
||||
|
||||
# in evadventure/tests/test_combat.py
|
||||
|
||||
from unittest.mock import Mock
|
||||
|
||||
from evennia.utils.test_resources import EvenniaTestCase
|
||||
from evennia import create_object
|
||||
from .. import combat_base
|
||||
from ..rooms import EvAdventureRoom
|
||||
from ..characters import EvAdventureCharacter
|
||||
|
||||
|
||||
class TestEvAdventureCombatHandlerBase(CombatHandlerBase):
|
||||
class TestEvAdventureCombatBaseHandler(EvenniaTestCase):
|
||||
|
||||
def setUp(self):
|
||||
|
||||
|
|
@ -751,7 +752,7 @@ class TestEvAdventureCombatHandlerBase(CombatHandlerBase):
|
|||
|
||||
The interesting places are where we apply the mocks:
|
||||
|
||||
- **Line 24** and **Line 31**: While `get_sides` is not implemented yet, we know what it is _supposed_ to return - a tuple of lists. So for the sake of the test, we _replace_ the `get_sides` method with a mock that when called will return something useful.
|
||||
- **Line 25** and **Line 32**: While `get_sides` is not implemented yet, we know what it is _supposed_ to return - a tuple of lists. So for the sake of the test, we _replace_ the `get_sides` method with a mock that when called will return something useful.
|
||||
|
||||
With this kind of approach it's possible to fully test a system also when it's not 'complete' yet.
|
||||
|
||||
|
|
|
|||
|
|
@ -60,3 +60,963 @@ Basically, a user enters an action and after a certain time that action will exe
|
|||
|
||||
You can change up your strategy by performing other actions (like drinking a potion or cast a spell). You can also simply move to another room to 'flee' the combat (but the enemy may of course follow you)
|
||||
|
||||
## General principle
|
||||
|
||||
```{sidebar}
|
||||
An example of an implemented Twitch combat system can be found in [evennia/contrib/tutorials/evadventure/combat_twitch.py](evennia.contrib.tutorials.evadventure.combat_twitch).
|
||||
```
|
||||
Here is the general design of the Twitch-based combat handler:
|
||||
|
||||
- The twitch-version of the CombatHandler will be stored on each combatant whenever combat starts. When combat is over, or they leave the room with combat, the handler will be deleted.
|
||||
- The handler will start queue each action independently, starting a timer until they fire.
|
||||
- All input are handled via Commands.
|
||||
|
||||
## Twitch combat handler
|
||||
|
||||
> Create a new module `evadventure/combat_twitch.py`.
|
||||
|
||||
We will make use of the _Combat Actions_, _Combat dicts_ and the parent `EvAdventureCombatBaseHandler` [we created previously](./Beginner-Tutorial-Combat-Base.md).
|
||||
|
||||
```python
|
||||
# in evadventure/combat_twitch.py
|
||||
|
||||
from .combat_base import (
|
||||
CombatActionAttack,
|
||||
CombatActionHold,
|
||||
CombatActionStunt,
|
||||
CombatActionUseItem,
|
||||
CombatActionWield,
|
||||
EvAdventureCombatBaseHandler,
|
||||
)
|
||||
|
||||
from .combat_base import EvAdventureCombatBaseHandler
|
||||
|
||||
class EvAdventureCombatTwitchHandler(EvAdventureCombatBaseHandler):
|
||||
"""
|
||||
This is created on the combatant when combat starts. It tracks only
|
||||
the combatant's side of the combat and handles when the next action
|
||||
will happen.
|
||||
|
||||
"""
|
||||
|
||||
def msg(self, message, broadcast=True):
|
||||
"""See EvAdventureCombatBaseHandler.msg"""
|
||||
super().msg(message, combatant=self.obj,
|
||||
broadcast=broadcast, location=self.obj.location)
|
||||
```
|
||||
|
||||
We make a child class of `EvAdventureCombatBaseHandler` for our Twitch combat. The parent class is a [Script](../../../Components/Scripts.md), and when a Script sits 'on' an Object, that Object is available on the script as `self.obj`. Since this handler is meant to sit 'on' the combatant, then `self.obj` is thus the combatant and `self.obj.location` is the current room the combatant is in. By using `super()` we can reuse the parent class' `msg()` method with these Twitch-specific details.
|
||||
|
||||
### Getting the sides of combat
|
||||
|
||||
```python
|
||||
# in evadventure/combat_twitch.py
|
||||
|
||||
from evennia.utils import inherits_from
|
||||
|
||||
# ...
|
||||
|
||||
class EvAdventureCombatTwitchHandler(EvAdventureCombatBaseHandler):
|
||||
|
||||
# ...
|
||||
|
||||
def get_sides(self, combatant):
|
||||
"""
|
||||
Get a listing of the two 'sides' of this combat, from the
|
||||
perspective of the provided combatant. The sides don't need
|
||||
to be balanced.
|
||||
|
||||
Args:
|
||||
combatant (Character or NPC): The basis for the sides.
|
||||
|
||||
Returns:
|
||||
tuple: A tuple of lists `(allies, enemies)`, from the
|
||||
perspective of `combatant`. Note that combatant itself
|
||||
is not included in either of these.
|
||||
|
||||
"""
|
||||
# get all entities involved in combat by looking up their combathandlers
|
||||
combatants = [
|
||||
comb
|
||||
for comb in self.obj.location.contents
|
||||
if hasattr(comb, "scripts") and comb.scripts.has(self.key)
|
||||
]
|
||||
location = self.obj.location
|
||||
|
||||
if hasattr(location, "allow_pvp") and location.allow_pvp:
|
||||
# in pvp, everyone else is an enemy
|
||||
allies = [combatant]
|
||||
enemies = [comb for comb in combatants if comb != combatant]
|
||||
else:
|
||||
# otherwise, enemies/allies depend on who combatant is
|
||||
pcs = [comb for comb in combatants if inherits_from(comb, EvAdventureCharacter)]
|
||||
npcs = [comb for comb in combatants if comb not in pcs]
|
||||
if combatant in pcs:
|
||||
# combatant is a PC, so NPCs are all enemies
|
||||
allies = [comb for comb in pcs if comb != combatant]
|
||||
enemies = npcs
|
||||
else:
|
||||
# combatant is an NPC, so PCs are all enemies
|
||||
allies = [comb for comb in npcs if comb != combatant]
|
||||
enemies = pcs
|
||||
return allies, enemies
|
||||
|
||||
```
|
||||
|
||||
Next we add our own implementation of the `get_sides()` method. This presents the sides of combat from the perspective of the provided `combatant`. In Twitch combat, there are a few things that identifies a combatant:
|
||||
|
||||
- That they are in the same location
|
||||
- That they each have a `EvAdventureCombatTwitchHandler` script running on themselves
|
||||
|
||||
```{sidebar} inherits_from
|
||||
Since `inherits_from` is True if your class inherits from the parent at _any_ distance, this particular check would not work if you were to change the NPC class to inherit from our Character class as well. In that case we'd have to come up with some other way to compare the two types of entities.
|
||||
```
|
||||
In a PvP-open room, it's all for themselves - everyone else is considered an 'enemy'. Otherwise we separate PCs from NPCs by seeing if they inherit from `EvAdventureCharacter` (our PC class) or not - if you are a PC, then the NPCs are your enemies and vice versa. The [inherits_from](evennia.utils.utils.inherits_from) is very useful for doing these checks - it will pass also if you inherit from `EvAdventureCharacter` at _any_ distance.
|
||||
|
||||
Note that `allies` does not include the `combatant` itself, so if you are fighting a lone enemy, the return from this method will be `([], [enemy_obj])`.
|
||||
|
||||
### Tracking Advantage / Disadvantage
|
||||
|
||||
```python
|
||||
# in evadventure/combat_twitch.py
|
||||
|
||||
from evennia import AttributeProperty
|
||||
|
||||
# ...
|
||||
|
||||
class EvAdventureCombatTwitchHandler(EvAdventureCombatBaseHandler):
|
||||
|
||||
self.advantage_against = AttributeProperty(dict)
|
||||
self.disadvantage_against = AttributeProperty(dict)
|
||||
|
||||
# ...
|
||||
|
||||
def give_advantage(self, recipient, target):
|
||||
"""Let a recipient gain advantage against the target."""
|
||||
self.advantage_against[target] = True
|
||||
|
||||
def give_disadvantage(self, recipient, target):
|
||||
"""Let an affected party gain disadvantage against a target."""
|
||||
self.disadvantage_against[target] = True
|
||||
|
||||
def has_advantage(self, combatant, target):
|
||||
"""Check if the combatant has advantage against a target."""
|
||||
return self.advantage_against.get(target, False)
|
||||
|
||||
def has_disadvantage(self, combatant, target):
|
||||
"""Check if the combatant has disadvantage against a target."""
|
||||
return self.disadvantage_against.get(target, False)1
|
||||
|
||||
```
|
||||
|
||||
As seen in the previous lesson, the Actions call these methods to store the fact that
|
||||
a given combatant has advantage.
|
||||
|
||||
In this Twitch-combat case, the one getting the advantage is always one on which the combathandler is defined, so we don't actually need to use the `recipient/combatant` argument (it's always going to be `self.obj`) - only `target` is important.
|
||||
|
||||
We create two new Attributes to store the relation as dicts.
|
||||
|
||||
### Queue action
|
||||
|
||||
```{code-block} python
|
||||
:linenos:
|
||||
:emphasize-lines: 17,26,30,43,44, 48, 49
|
||||
# in evadventure/combat_twitch.py
|
||||
|
||||
from evennia.utils import repeat, unrepeat
|
||||
from .combat_base import (
|
||||
CombatActionAttack,
|
||||
CombatActionHold,
|
||||
CombatActionStunt,
|
||||
CombatActionUseItem,
|
||||
CombatActionWield,
|
||||
EvAdventureCombatBaseHandler,
|
||||
)
|
||||
|
||||
# ...
|
||||
|
||||
class EvAdventureCombatTwitchHandler(EvAdventureCombatBaseHandler):
|
||||
|
||||
action_classes = {
|
||||
"hold": CombatActionHold,
|
||||
"attack": CombatActionAttack,
|
||||
"stunt": CombatActionStunt,
|
||||
"use": CombatActionUseItem,
|
||||
"wield": CombatActionWield,
|
||||
}
|
||||
|
||||
action_dict = AttributeProperty(dict, autocreate=False)
|
||||
current_ticker_ref = AttributeProperty(None, autocreate=False)
|
||||
|
||||
# ...
|
||||
|
||||
def queue_action(self, action_dict, combatant=None):
|
||||
"""
|
||||
Schedule the next action to fire.
|
||||
|
||||
Args:
|
||||
action_dict (dict): The new action-dict to initialize.
|
||||
combatant (optional): Unused.
|
||||
|
||||
"""
|
||||
if action_dict["key"] not in self.action_classes:
|
||||
self.obj.msg("This is an unkown action!")
|
||||
return
|
||||
|
||||
# store action dict and schedule it to run in dt time
|
||||
self.action_dict = action_dict
|
||||
dt = action_dict.get("dt", 0)
|
||||
|
||||
if self.current_ticker_ref:
|
||||
# we already have a current ticker going - abort it
|
||||
unrepeat(self.current_ticker_ref)
|
||||
if dt <= 0:
|
||||
# no repeat
|
||||
self.current_ticker_ref = None
|
||||
else:
|
||||
# always schedule the task to be repeating, cancel later
|
||||
# otherwise. We store the tickerhandler's ref to make sure
|
||||
# we can remove it later
|
||||
self.current_ticker_ref = repeat(
|
||||
dt, self.execute_next_action, id_string="combat")
|
||||
|
||||
```
|
||||
|
||||
- The `queue_action` (**Line 30**) method takes an "Action dict" representing an action the combatant wants to perform next. It must be one of the keyed Actions added to the handler in the `action_classes` property (**Line 17**). We make no use of the `combatant` keyword argument since we already know that the combatant is `self.obj`.
|
||||
- **Line 43**: We simply store the given action dict in the Attribute `action_dict` on the handler. Simple and effective!
|
||||
- **Line 44**: When you enter e.g. `attack`, you expect in this type of combat to see the `attack` command repeat automatically even if you don't enter anything more. To this end we are looking for a new key in action dicts, indicating that this action should _repeat_ with a certain rate (dt, given in seconds). We make this compatible with all action dicts by simply assuming it's zero if not specified.
|
||||
- [evennia.utils.utils.repeat](evennia.utils.utils.repeat) and [evennia.utils.utils.unrepeat](evennia.utils.utils.unrepeat) are convenient shortcuts to the [TickerHandler](../../../Components/TickerHandler.md). You tell `repeat` to call a given method/function at a certain rate. What you get back is a reference that you can then later use to 'un-repeat' (stop the repeating) later. We make sure to store this reference (we don't care exactly how it looks, just that we need to store it) in `the current_ticket_ref` Attribute.
|
||||
- **Line 48**: Whenever we queue a new action (it may replace an existing one) we must make sure to kill (un-repeat) any old repeats that are ongoing. Otherwise we would get old actions firing over and over and new ones starting alongside them.
|
||||
- **Line 49**: If `dt` is set, we call `repeat` to set up a new repeat action at the given rate. We store this new reference. After `dt` seconds, the `.execute_next_action` method will fire (we'll create that in the next section).
|
||||
|
||||
### Execute an action
|
||||
|
||||
```{code-block} python
|
||||
:linenos:
|
||||
:emphasize-lines: 5,15,16,18,22,27
|
||||
|
||||
# in evadventure/combat_twitch.py
|
||||
|
||||
class EvAdventureCombatTwitchHandler(EvAdventureCombatBaseHandler):
|
||||
|
||||
fallback_action_dict = AttributeProperty({"key": "hold", "dt": 0})
|
||||
|
||||
# ...
|
||||
|
||||
def execute_next_action(self):
|
||||
"""
|
||||
Triggered after a delay by the command
|
||||
"""
|
||||
combatant = self.obj
|
||||
action_dict = self.action_dict
|
||||
action_class = self.action_classes[action_dict["key"]]
|
||||
action = action_class(self, combatant, action_dict)
|
||||
|
||||
if action.can_use():
|
||||
action.execute()
|
||||
action.post_execute()
|
||||
|
||||
if not action_dict.get("repeat", True):
|
||||
# not a repeating action, use the fallback (normally the original attack)
|
||||
self.action_dict = self.fallback_action_dict
|
||||
self.queue_action(self.fallback_action_dict)
|
||||
|
||||
self.check_stop_combat()
|
||||
```
|
||||
|
||||
This is the method called after `dt` seconds in `queue_action`.
|
||||
|
||||
- **Line 5**: We defined a 'fallback action'. This is used after a one-time action (one that should not repeat) has completed.
|
||||
- **Line 15**: We take the `'key'` from the `action-dict` and use the `action_classes` mapping to get an action class (e.g. `ACtionAttack` we defined [here](./Beginner-Tutorial-Combat-Base.md#attack-action)).
|
||||
- **Line 16**: Here we initialize the action class with the actual current data - the combatant and the `action_dict`. This calls the `__init__` method on the class and makes the action ready to use.
|
||||
- **Line 18**: Here we run through the usage methods of the action - where we perform the action. We let the action itself handle all the logics.
|
||||
- **Line 22**: We check for another optional flag on the action-dict: `repeat`. Unless it's set, we use the fallback-action defined on **Line 5**. Many actions should not repeat - for example, it would not make sense to do `wield` for the same weapon over and over.
|
||||
- **Line 27**: It's important that we know how to stop combat. We will write this method next.
|
||||
|
||||
### Checking and stopping combat
|
||||
|
||||
```{code-block} python
|
||||
:linenos:
|
||||
:emphasize-lines: 12,18,19
|
||||
|
||||
# in evadventure/combat_twitch.py
|
||||
|
||||
class EvAdventureCombatTwitchHandler(EvAdventureCombatBaseHandler):
|
||||
|
||||
# ...
|
||||
|
||||
def check_stop_combat(self):
|
||||
"""
|
||||
Check if the combat is over.
|
||||
"""
|
||||
|
||||
allies, enemies = self.get_sides(self.obj)
|
||||
allies.append(self.obj)
|
||||
|
||||
location = self.obj.location
|
||||
|
||||
# only keep combatants that are alive and still in the same room
|
||||
allies = [comb for comb in allies if comb.hp > 0 and comb.location == location]
|
||||
enemies = [comb for comb in enemies if comb.hp > 0 and comb.location == location]
|
||||
|
||||
if not allies and not enemies:
|
||||
self.msg("The combat is over. Noone stands.", broadcast=False)
|
||||
self.stop_combat()
|
||||
return
|
||||
if not allies:
|
||||
self.msg("The combat is over. You lost.", broadcast=False)
|
||||
self.stop_combat()
|
||||
if not enemies:
|
||||
self.msg("The combat is over. You won!", broadcast=False)
|
||||
self.stop_combat()
|
||||
|
||||
def stop_combat(self):
|
||||
pass # We'll finish this last
|
||||
```
|
||||
|
||||
We must make sure to check if combat is over.
|
||||
|
||||
- **Line 12**: With our `.get_sides()` method we can easily get the two sides of the conflict.
|
||||
- **Lines 18, 19**: We get everyone still alive _and still in the same room_. The latter condition is important in case we move away from the battle - you can't hit your enemy from another room.
|
||||
|
||||
In the `stop_method` we'll need to do a bunch of cleanup. We'll hold off on implementing this until we have the Commands we'll need to wrap up the Twitch combat. Read on.
|
||||
|
||||
## Commands
|
||||
|
||||
We want each action to map to a [Command](../../../Components/Commands.md) - an actual input the player can pass to the game.
|
||||
|
||||
### Base Combat class
|
||||
|
||||
We should try to find the similarities between the commands we'll need and group them into one parent class. When a Command fires, it will fire the following methods on itself, in sequence:
|
||||
|
||||
1. `cmd.at_pre_command()`
|
||||
2. `cmd.parse()`
|
||||
3. `cmd.func()`
|
||||
4. `cmd.at_post_command()`
|
||||
|
||||
We'll override the first two for our parent.
|
||||
|
||||
```{code-block} python
|
||||
:linenos:
|
||||
:emphasize-lines: 22,48
|
||||
|
||||
# in evadventure/combat_twitch.py
|
||||
|
||||
from evennia import Command
|
||||
from evennia import InterruptCommand
|
||||
|
||||
# ...
|
||||
|
||||
# after the combat handler class
|
||||
|
||||
class _BaseTwitchCombatCommand(Command):
|
||||
"""
|
||||
Parent class for all twitch-combat commnads.
|
||||
|
||||
"""
|
||||
|
||||
def at_pre_command(self):
|
||||
"""
|
||||
Called before parsing.
|
||||
|
||||
"""
|
||||
if not self.caller.location or not self.caller.location.allow_combat:
|
||||
self.msg("Can't fight here!")
|
||||
raise InterruptCommand()
|
||||
|
||||
def parse(self):
|
||||
"""
|
||||
Handle parsing of most supported combat syntaxes (except stunts).
|
||||
|
||||
<action> [<target>|<item>]
|
||||
or
|
||||
<action> <item> [on] <target>
|
||||
|
||||
Use 'on' to differentiate if names/items have spaces in the name.
|
||||
|
||||
"""
|
||||
self.args = args = self.args.strip()
|
||||
self.lhs, self.rhs = "", ""
|
||||
|
||||
if not args:
|
||||
return
|
||||
|
||||
if " on " in args:
|
||||
lhs, rhs = args.split(" on ", 1)
|
||||
else:
|
||||
lhs, *rhs = args.split(None, 1)
|
||||
rhs = " ".join(rhs)
|
||||
self.lhs, self.rhs = lhs.strip(), rhs.strip()
|
||||
|
||||
def get_or_create_combathandler(self, target=None, combathandler_name="combathandler"):
|
||||
"""
|
||||
Get or create the combathandler assigned to this combatant.
|
||||
|
||||
"""
|
||||
if target:
|
||||
# add/check combathandler to the target
|
||||
if target.hp_max is None:
|
||||
self.msg("You can't attack that!")
|
||||
raise InterruptCommand()
|
||||
|
||||
EvAdventureCombatTwitchHandler.get_or_create_combathandler(target)
|
||||
return EvAdventureCombatTwitchHandler.get_or_create_combathandler(self.caller)
|
||||
```
|
||||
|
||||
- **Line 23**: If the current location doesn't allow combat, all combat commands should exit immediately. To stop the command before it reaches the `.func()`, we must raise the `InterruptCommand()`.
|
||||
- **Line 49**: It's convenient to add a helper method for getting the command handler because all our commands will be using it. It in turn calls the class method `get_or_create_combathandler` we inherit from the parent of `EvAdventureCombatTwitchHandler`.
|
||||
|
||||
### In-combat look command
|
||||
|
||||
```python
|
||||
# in evadventure/combat_twitch.py
|
||||
|
||||
from evennia import default_cmds
|
||||
from evennia.utils import pad
|
||||
|
||||
# ...
|
||||
|
||||
class CmdLook(default_cmds.CmdLook, _BaseTwitchCombatCommand):
|
||||
def func(self):
|
||||
# get regular look, followed by a combat summary
|
||||
super().func()
|
||||
if not self.args:
|
||||
combathandler = self.get_or_create_combathandler()
|
||||
txt = str(combathandler.get_combat_summary(self.caller))
|
||||
maxwidth = max(display_len(line) for line in txt.strip().split("\n"))
|
||||
self.msg(f"|r{pad(' Combat Status ', width=maxwidth, fillchar='-')}|n\n{txt}")
|
||||
```
|
||||
|
||||
When in combat we want to be able to do `look` and get the normal look but with the extra `combat summary` at the end (on the form `Me (Hurt) vs Troll (Perfect)`). So
|
||||
|
||||
The last line uses Evennia's `utils.pad` function to put the text "Combat Status" surrounded by a line on both sides.
|
||||
|
||||
The result will be the look command output followed directly by
|
||||
|
||||
```shell
|
||||
--------- Combat Status ----------
|
||||
You (Wounded) vs Troll (Scraped)
|
||||
```
|
||||
|
||||
### Hold command
|
||||
|
||||
```python
|
||||
class CmdHold(_BaseTwitchCombatCommand):
|
||||
"""
|
||||
Hold back your blows, doing nothing.
|
||||
|
||||
Usage:
|
||||
hold
|
||||
|
||||
"""
|
||||
|
||||
key = "hold"
|
||||
|
||||
def func(self):
|
||||
combathandler = self.get_or_create_combathandler()
|
||||
combathandler.queue_action({"key": "hold"})
|
||||
combathandler.msg("$You() $conj(hold) back, doing nothing.", self.caller)
|
||||
```
|
||||
|
||||
The 'do nothing' command showcases the basic principle of how all following commands work:
|
||||
|
||||
1. Get the combathandler (will be created or loaded if it already existed).
|
||||
2. Queue the action by passing its action-dict to the `combathandler.queue_action` method.
|
||||
3. Confirm to the caller that they now queued this action.
|
||||
|
||||
### Attack command
|
||||
|
||||
```python
|
||||
# in evadventure/combat_twitch.py
|
||||
|
||||
# ...
|
||||
|
||||
class CmdAttack(_BaseTwitchCombatCommand):
|
||||
"""
|
||||
Attack a target. Will keep attacking the target until
|
||||
combat ends or another combat action is taken.
|
||||
|
||||
Usage:
|
||||
attack/hit <target>
|
||||
|
||||
"""
|
||||
|
||||
key = "attack"
|
||||
aliases = ["hit"]
|
||||
help_category = "combat"
|
||||
|
||||
def func(self):
|
||||
target = self.caller.search(self.lhs)
|
||||
if not target:
|
||||
return
|
||||
|
||||
combathandler = self.get_or_create_combathandler(target)
|
||||
combathandler.queue_action(
|
||||
{"key": "attack",
|
||||
"target": target,
|
||||
"dt": 3,
|
||||
"repeat": True}
|
||||
)
|
||||
combathandler.msg(f"$You() $conj(attack) $You({target.key})!", self.caller)
|
||||
```
|
||||
|
||||
The `attack` command becomes quite simple because we do all the heavy lifting in the combathandler and in the `ActionAttack` class. Note that we set `dt` to a fixed `3` here, but in a more complex system one could imagine your skills, weapon and circumstance affecting how long your attack will take.
|
||||
|
||||
```python
|
||||
# in evadventure/combat_twitch.py
|
||||
|
||||
from .enums import ABILITY_REVERSE_MAP
|
||||
|
||||
# ...
|
||||
|
||||
class CmdStunt(_BaseTwitchCombatCommand):
|
||||
"""
|
||||
Perform a combat stunt, that boosts an ally against a target, or
|
||||
foils an enemy, giving them disadvantage against an ally.
|
||||
|
||||
Usage:
|
||||
boost [ability] <recipient> <target>
|
||||
foil [ability] <recipient> <target>
|
||||
boost [ability] <target> (same as boost me <target>)
|
||||
foil [ability] <target> (same as foil <target> me)
|
||||
|
||||
Example:
|
||||
boost STR me Goblin
|
||||
boost DEX Goblin
|
||||
foil STR Goblin me
|
||||
foil INT Goblin
|
||||
boost INT Wizard Goblin
|
||||
|
||||
"""
|
||||
|
||||
key = "stunt"
|
||||
aliases = (
|
||||
"boost",
|
||||
"foil",
|
||||
)
|
||||
help_category = "combat"
|
||||
|
||||
def parse(self):
|
||||
args = self.args
|
||||
|
||||
if not args or " " not in args:
|
||||
self.msg("Usage: <ability> <recipient> <target>")
|
||||
raise InterruptCommand()
|
||||
|
||||
advantage = self.cmdname != "foil"
|
||||
|
||||
# extract data from the input
|
||||
|
||||
stunt_type, recipient, target = None, None, None
|
||||
|
||||
stunt_type, *args = args.split(None, 1)
|
||||
if stunt_type:
|
||||
stunt_type = stunt_type.strip().lower()
|
||||
|
||||
args = args[0] if args else ""
|
||||
|
||||
recipient, *args = args.split(None, 1)
|
||||
target = args[0] if args else None
|
||||
|
||||
# validate input and try to guess if not given
|
||||
|
||||
# ability is requried
|
||||
if not stunt_type or stunt_type not in ABILITY_REVERSE_MAP:
|
||||
self.msg(
|
||||
f"'{stunt_type}' is not a valid ability. Pick one of"
|
||||
f" {', '.join(ABILITY_REVERSE_MAP.keys())}."
|
||||
)
|
||||
raise InterruptCommand()
|
||||
|
||||
if not recipient:
|
||||
self.msg("Must give at least a recipient or target.")
|
||||
raise InterruptCommand()
|
||||
|
||||
if not target:
|
||||
# something like `boost str target`
|
||||
target = recipient if advantage else "me"
|
||||
recipient = "me" if advantage else recipient
|
||||
we still have None:s at this point, we can't continue
|
||||
if None in (stunt_type, recipient, target):
|
||||
self.msg("Both ability, recipient and target of stunt must be given.")
|
||||
raise InterruptCommand()
|
||||
|
||||
# save what we found so it can be accessed from func()
|
||||
self.advantage = advantage
|
||||
self.stunt_type = ABILITY_REVERSE_MAP[stunt_type]
|
||||
self.recipient = recipient.strip()
|
||||
self.target = target.strip()
|
||||
|
||||
def func(self):
|
||||
target = self.caller.search(self.target)
|
||||
if not target:
|
||||
return
|
||||
recipient = self.caller.search(self.recipient)
|
||||
if not recipient:
|
||||
return
|
||||
|
||||
combathandler = self.get_or_create_combathandler(target)
|
||||
|
||||
combathandler.queue_action(
|
||||
{
|
||||
"key": "stunt",
|
||||
"recipient": recipient,
|
||||
"target": target,
|
||||
"advantage": self.advantage,
|
||||
"stunt_type": self.stunt_type,
|
||||
"defense_type": self.stunt_type,
|
||||
"dt": 3,
|
||||
},
|
||||
)
|
||||
combathandler.msg("$You() prepare a stunt!", self.caller)
|
||||
|
||||
```
|
||||
|
||||
This looks much longer, but that is only because the stunt command should understand many different input structures depending on if you are trying to create a advantage or disadvantage, and if an ally or enemy should receive the effect of the stunt.
|
||||
|
||||
Note the `enums.ABILITY_REVERSE_MAP` (created in the [Utilities lesson](./Beginner-Tutorial-Utilities.md)) being useful to convert your input of 'str' into `Ability.STR` needed by the action dict.
|
||||
|
||||
Once we've sorted out the string parsing, the `func` is simple - we find the target and recipient and use them to build the needed action-dict to queue.
|
||||
|
||||
### Using items
|
||||
|
||||
```python
|
||||
# in evadventure/combat_twitch.py
|
||||
|
||||
# ...
|
||||
|
||||
class CmdUseItem(_BaseTwitchCombatCommand):
|
||||
"""
|
||||
Use an item in combat. The item must be in your inventory to use.
|
||||
|
||||
Usage:
|
||||
use <item>
|
||||
use <item> [on] <target>
|
||||
|
||||
Examples:
|
||||
use potion
|
||||
use throwing knife on goblin
|
||||
use bomb goblin
|
||||
|
||||
"""
|
||||
|
||||
key = "use"
|
||||
help_category = "combat"
|
||||
|
||||
def parse(self):
|
||||
super().parse()
|
||||
|
||||
if not self.args:
|
||||
self.msg("What do you want to use?")
|
||||
raise InterruptCommand()
|
||||
|
||||
self.item = self.lhs
|
||||
self.target = self.rhs or "me"
|
||||
|
||||
def func(self):
|
||||
item = self.caller.search(
|
||||
self.item,
|
||||
candidates=self.caller.equipment.get_usable_objects_from_backpack()
|
||||
)
|
||||
if not item:
|
||||
self.msg("(You must carry the item to use it.)")
|
||||
return
|
||||
if self.target:
|
||||
target = self.caller.search(self.target)
|
||||
if not target:
|
||||
return
|
||||
|
||||
combathandler = self.get_or_create_combathandler(self.target)
|
||||
combathandler.queue_action(
|
||||
{"key": "use",
|
||||
"item": item,
|
||||
"target": target,
|
||||
"dt": 3}
|
||||
)
|
||||
combathandler.msg(
|
||||
f"$You() prepare to use {item.get_display_name(self.caller)}!", self.caller
|
||||
)
|
||||
```
|
||||
|
||||
To use an item, we need to make sure we are carrying it. Luckily our work in the [Equipment lesson](./Beginner-Tutorial-Equipment.md) gives us easy methods we can use to search for suitable objects.
|
||||
|
||||
### Wielding new weapons and equipment
|
||||
|
||||
```python
|
||||
# in evadventure/combat_twitch.py
|
||||
|
||||
# ...
|
||||
|
||||
class CmdWield(_BaseTwitchCombatCommand):
|
||||
"""
|
||||
Wield a weapon or spell-rune. You will the wield the item,
|
||||
swapping with any other item(s) you were wielded before.
|
||||
|
||||
Usage:
|
||||
wield <weapon or spell>
|
||||
|
||||
Examples:
|
||||
wield sword
|
||||
wield shield
|
||||
wield fireball
|
||||
|
||||
Note that wielding a shield will not replace the sword in your hand,
|
||||
while wielding a two-handed weapon (or a spell-rune) will take
|
||||
two hands and swap out what you were carrying.
|
||||
|
||||
"""
|
||||
|
||||
key = "wield"
|
||||
help_category = "combat"
|
||||
|
||||
def parse(self):
|
||||
if not self.args:
|
||||
self.msg("What do you want to wield?")
|
||||
raise InterruptCommand()
|
||||
super().parse()
|
||||
|
||||
def func(self):
|
||||
item = self.caller.search(
|
||||
self.args, candidates=self.caller.equipment.get_wieldable_objects_from_backpack()
|
||||
)
|
||||
if not item:
|
||||
self.msg("(You must carry the item to wield it.)")
|
||||
return
|
||||
combathandler = self.get_or_create_combathandler()
|
||||
combathandler.queue_action({"key": "wield", "item": item, "dt": 3})
|
||||
combathandler.msg(f"$You() reach for {item.get_display_name(self.caller)}!", self.caller)
|
||||
|
||||
```
|
||||
|
||||
Wield follows the same pattern.
|
||||
|
||||
|
||||
## Grouping Commands for use
|
||||
|
||||
To make these commands available to use we must add them to a [Command Set](../../../Components/Command-Sets.md).
|
||||
|
||||
```python
|
||||
# in evadventure/combat_twitch.py
|
||||
|
||||
from evennia import CmdSet
|
||||
|
||||
# ...
|
||||
|
||||
# after the commands
|
||||
|
||||
class TwitchCombatCmdSet(CmdSet):
|
||||
"""
|
||||
Add to character, to be able to attack others in a twitch-style way.
|
||||
"""
|
||||
|
||||
def at_cmdset_creation(self):
|
||||
self.add(CmdAttack())
|
||||
self.add(CmdHold())
|
||||
self.add(CmdStunt())
|
||||
self.add(CmdUseItem())
|
||||
self.add(CmdWield())
|
||||
|
||||
|
||||
class TwitchLookCmdSet(CmdSet):
|
||||
"""
|
||||
This will be added/removed dynamically when in combat.
|
||||
"""
|
||||
|
||||
def at_cmdset_creation(self):
|
||||
self.add(CmdLook())
|
||||
|
||||
|
||||
```
|
||||
|
||||
The first cmdset, `TwitchCombatCmdSet` is intended to be added to the Character. We can do so permanently by adding the cmdset to the default character cmdset (as outlined in the [Beginner Command lesson](../Part1/Beginner-Tutorial-Adding-Commands.md)). We can also add it more explicitly to our Character class over in `characters.py`:
|
||||
|
||||
```python
|
||||
# in evadventure/characters.py
|
||||
|
||||
# ...
|
||||
|
||||
class EvAdventureCharacter(LivingMixin, DefaultCharacter):
|
||||
|
||||
# ...
|
||||
|
||||
def at_object_creation
|
||||
from .combat_twitch import TwitchCombatCmdSet
|
||||
self.cmdset.add(TwitchCombatCmdSet, persistent=True)
|
||||
|
||||
```
|
||||
|
||||
For quick testing we will also explore another option in the next section.
|
||||
|
||||
What about that `TwitchLookCmdSet`? We can't add it to our character permanently, because we only want this particular version of `look` to operate while we are in combat.
|
||||
|
||||
We must make sure to add and clean this up when combat starts and ends.
|
||||
|
||||
### Combat startup and cleanup
|
||||
|
||||
```{code-block} python
|
||||
:linenos:
|
||||
:emphasize-lines: 9,13,14,15,16
|
||||
|
||||
# in evadventure/combat_twitch.py
|
||||
|
||||
# ...
|
||||
|
||||
class EvAdventureCombatTwitchHandler(EvAdventureCombatBaseHandler):
|
||||
|
||||
# ...
|
||||
|
||||
def at_init(self):
|
||||
self.obj.cmdset.add(TwitchLookCmdSet, persistent=False)
|
||||
|
||||
def stop_combat(self):
|
||||
self.queue_action({"key": "hold", "dt": 0}) # make sure ticker is killed
|
||||
del self.obj.ndb.combathandler
|
||||
self.obj.cmdset.remove(TwitchLookCmdSet)
|
||||
self.delete()
|
||||
```
|
||||
|
||||
Now that we have the Look command set, we can finish the Twitch combat handler.
|
||||
|
||||
- **Line 9**: The `at_init` method is a standard Evennia method available on all typeclassed entities (including `Scripts`, which is what our combat handler is). Unlike `at_object_creation` (which only fires once, when the object is first created), `at_init` will be called every time the object is loaded into memory (normally after you do a server `reload`). So we add the `TwitchLookCmdSet` here. We do so non-persistently, since we don't want to get an ever growing number of cmdsets added every time we reload.
|
||||
- **Line 13**: By queuing a hold action with `dt` of `0`, we make sure to kill the `repeat` action that is going on. If not, it would still fire later - and find that the combat handler is gone.
|
||||
- **Line 14**: If looking at how we defined the `get_or_create_combathandler` classmethod (the one we have been using to get/create the combathandler during the combat), you'll see that it caches the handler as `.ndb.combathandler` on the object we send to it. So we delete that cached reference here to make sure it's gone.
|
||||
- **Line 15**: We remove the look-cmdset from ourselves (remember `self.obj` is you, the combatant that now just finished combat).
|
||||
- **Line 16**: We delete the combat handler itself.
|
||||
|
||||
|
||||
## Unit Testing
|
||||
|
||||
```{sidebar}
|
||||
See [evennia/contrib/tutorials/evadventure/tests/test_combat.py](evennia.contrib.tutorials.evadventure.tests.test_combat) for an example of a full suite of combat tests.
|
||||
```
|
||||
|
||||
> Create `evadventure/tests/test_combat.py` (if you don't already have it).
|
||||
|
||||
Both the Twitch command handler and commands can and should be unit tested. Testing of commands are made easier by Evennia's special `EvenniaCommandTestMixin` class. This makes the `.call` method available and makes it easy to check if a command returns what you expect.
|
||||
|
||||
Here's an example:
|
||||
|
||||
```python
|
||||
# in evadventure/tests/test_combat.py
|
||||
|
||||
from unittest.mock import Mock, patch
|
||||
from evennia.utils.test_resources import EvenniaCommandTestMixin
|
||||
|
||||
from .. import combat_twitch
|
||||
|
||||
# ...
|
||||
|
||||
class TestEvAdventureTwitchCombat(EvenniaCommandTestMixin)
|
||||
|
||||
def setUp(self):
|
||||
self.combathandler = (
|
||||
combat_twitch.EvAdventureCombatTwitchHandler.get_or_create_combathandler(
|
||||
self.char1, key="combathandler")
|
||||
)
|
||||
|
||||
@patch("evadventure.combat_twitch.unrepeat", new=Mock())
|
||||
@patch("evadventure.combat_twitch.repeat", new=Mock())
|
||||
def test_hold_command(self):
|
||||
self.call(combat_twitch, CmdHold(), "", "You hold back, doing nothing")
|
||||
self.assertEqual(self.combathandler.action_dict, {"key": "hold"})
|
||||
|
||||
```
|
||||
|
||||
The `EvenniaCommandTestMixin` as a few default objects, including `self.char1`, which we make use of here.
|
||||
|
||||
The two `@patch` lines are Python [decorators](https://realpython.com/primer-on-python-decorators/) that 'patch' the `test_hold_command` method. What they do is basically saying "in the following method, whenever any code tries to access `evadventure.combat_twitch.un/repeat`, just return a Mocked object instead".
|
||||
|
||||
We do this patching as an easy way to avoid creating timers in the unit test - these timers would finish after the test finished (which includes deleting its objects) and thus fail.
|
||||
|
||||
Inside the test, we use the `self.call()` method to explicitly fire the Command (with no argument) and check that the output is what we expect. Lastly we check that the combathandler is set up correctly, having stored the action-dict on itself.
|
||||
|
||||
## A small combat test
|
||||
|
||||
Showing that the individual pieces of code works (unit testing) is not enough to be sure that your combat system is actually working. We need to test all the pieces _together_. This is often called _functional testing_. While functional testing can also be automated, wouldn't it be fun to be able to actually see our code in action?
|
||||
|
||||
This is what we need for a minimal test:
|
||||
|
||||
- A room with combat enabled.
|
||||
- An NPC to attack (it won't do anything back yet since we haven't added any AI)
|
||||
- A weapon we can `wield`
|
||||
- An item (like a potion) we can `use`.
|
||||
|
||||
```{sidebar}
|
||||
You can find an example batch-command script in [evennia/contrib/tutorials/evadventure/batchscripts/combat_demo.ev](evennia.contrib.tutorials.evadventure.batchscript)
|
||||
```
|
||||
While you can create these manually in-game, it can be convenient to create a [batch-command script](../../../Components/Batch-Command-Processor.md) to set up your testing environment.
|
||||
|
||||
> create a new subfolder `evadventure/batchscripts/` (if it doesn't exist)
|
||||
|
||||
|
||||
> create a new file `evadventure/combat_demo.ev` (note, it's `.ev` not `.py`!)
|
||||
|
||||
A batch-command file is a text file with normal in-game commands, one per line, separated by lines starting with `#` (these are required between all command lines). Here's how it looks:
|
||||
|
||||
```
|
||||
# Evadventure combat demo
|
||||
|
||||
# start from limbo
|
||||
|
||||
tel #2
|
||||
|
||||
# turn ourselves into a evadventure-character
|
||||
|
||||
type self = evadventure.characters.EvAdventureCharacter
|
||||
|
||||
# assign us the twitch combat cmdset (requires superuser/developer perms)
|
||||
|
||||
py self.cmdset.add("evadventure.combat_twitch.TwitchCombatCmdSet", persistent=True)
|
||||
|
||||
# Create a weapon in our inventory (using all defaults)
|
||||
|
||||
create sword:evadventure.objects.EvAdventureWeapon
|
||||
|
||||
# create a consumable to use
|
||||
|
||||
create potion:evadventure.objects.EvAdventureConsumable
|
||||
|
||||
# dig a combat arena
|
||||
|
||||
dig arena:evadventure.rooms.EvAdventureRoom = arena,back
|
||||
|
||||
# go to arena
|
||||
|
||||
arena
|
||||
|
||||
# allow combat in this room
|
||||
|
||||
set here/allow_combat = True
|
||||
|
||||
# create a dummy enemy to hit on
|
||||
|
||||
create/drop dummy puppet;dummy:evadventure.npcs.EvAdventureNPC
|
||||
|
||||
# describe the dummy
|
||||
|
||||
desc dummy = This is is an ugly training dummy made out of hay and wood.
|
||||
|
||||
# make the dummy crazy tough
|
||||
|
||||
set dummy/hp_max = 1000
|
||||
|
||||
#
|
||||
|
||||
set dummy/hp = 1000
|
||||
```
|
||||
|
||||
Log into the game with a developer/superuser account and run
|
||||
|
||||
> batchcmd evadventure.batchscripts.combat_demo
|
||||
|
||||
This should place you in the arena with the dummy (if not, check for errors in the output! Use `objects` and `delete` commands to list and delete objects if you need to start over. )
|
||||
|
||||
You can now try `attack dummy` and should be able to pound away at the dummy (lower its health to test destroying it). Use `back` to 'flee' the combat.
|
||||
|
||||
## Conclusions
|
||||
|
||||
This was a big lesson! Even though our combat system is not very complex, there are still many moving parts to keep in mind.
|
||||
|
||||
Also, while pretty simple, there is also a lot of growth possible with this system. You could easily expand from this or use it as inspiration for your own game.
|
||||
|
||||
Next we'll try to achieve the same thing within a turn-based framework!
|
||||
|
|
@ -1,5 +1,137 @@
|
|||
# Non-Player-Characters (NPCs)
|
||||
# Non-Player-Characters
|
||||
|
||||
```{warning}
|
||||
This part of the Beginner tutorial is still being developed.
|
||||
```
|
||||
NPCs are all active agents that are not player characters. NPCs could be anything from merchants and quest givers, to monsters and bosses. They could also be 'flavor' - townsfolk doing their chores, farmers tending their fields - there to make the world feel "more alive".
|
||||
|
||||
```{sidebar} vNPCs
|
||||
You should usually avoid creating hundreds of NPC objects to populate your 'busy town' - in a text game so many NPCs will just spam the screen and annoy your players. Since this is a text game, you can usually get away with using _vNPcs_ - virtual NPCs. vNPCs are only described in text - a room could be described as a bustling street, farmers can be described shouting to each other. Using room descriptions for this works well, but the tutorial lesson about [EvAdventure Rooms](./Beginner-Tutorial-Rooms.md) has a section called [adding life to a room](./Beginner-Tutorial-Rooms.md#adding-life-to-a-room) that can be used for making vNPCs appear to do things in the background.
|
||||
```
|
||||
|
||||
In this lesson we will create the base class of _Knave_ NPCs. According to the _Knave_ rules, NPCs have some simplified stats compared to the [PC characters](./Beginner-Tutorial-Characters.md) we designed earlier:
|
||||
|
||||
## The NPC base class
|
||||
|
||||
> Create a new module `evadventure/npcs.py`.
|
||||
|
||||
```{code-block} python
|
||||
:linenos:
|
||||
:emphasize-lines: 9, 12, 13, 15, 17, 19, 25, 23, 59, 61
|
||||
|
||||
# in evadventure/npcs.py
|
||||
|
||||
from evennia import DefaultCharacter, AttributeProperty
|
||||
|
||||
from .characters import LivingMixin
|
||||
from .enums import Ability
|
||||
|
||||
|
||||
class EvAdventureNPC(LivingMixin, DefaultCharacter):
|
||||
"""Base class for NPCs"""
|
||||
|
||||
is_pc = False
|
||||
hit_dice = AttributeProperty(default=1, autocreate=False)
|
||||
armor = AttributeProperty(default=1, autocreate=False) # +10 to get armor defense
|
||||
hp_multiplier = AttributeProperty(default=4, autocreate=False) # 4 default in Knave
|
||||
hp = AttributeProperty(default=None, autocreate=False) # internal tracking, use .hp property
|
||||
morale = AttributeProperty(default=9, autocreate=False)
|
||||
allegiance = AttributeProperty(default=Ability.ALLEGIANCE_HOSTILE, autocreate=False)
|
||||
|
||||
weapon = AttributeProperty(default=BARE_HANDS, autocreate=False) # instead of inventory
|
||||
coins = AttributeProperty(default=1, autocreate=False) # coin loot
|
||||
|
||||
is_idle = AttributeProperty(default=False, autocreate=False)
|
||||
|
||||
@property
|
||||
def strength(self):
|
||||
return self.hit_dice
|
||||
|
||||
@property
|
||||
def dexterity(self):
|
||||
return self.hit_dice
|
||||
|
||||
@property
|
||||
def constitution(self):
|
||||
return self.hit_dice
|
||||
|
||||
@property
|
||||
def intelligence(self):
|
||||
return self.hit_dice
|
||||
|
||||
@property
|
||||
def wisdom(self):
|
||||
return self.hit_dice
|
||||
|
||||
@property
|
||||
def charisma(self):
|
||||
return self.hit_dice
|
||||
|
||||
@property
|
||||
def hp_max(self):
|
||||
return self.hit_dice * self.hp_multiplier
|
||||
|
||||
def at_object_creation(self):
|
||||
"""
|
||||
Start with max health.
|
||||
|
||||
"""
|
||||
self.hp = self.hp_max
|
||||
self.tags.add("npcs", category="group")
|
||||
|
||||
def ai_next_action(self, **kwargs):
|
||||
"""
|
||||
The system should regularly poll this method to have
|
||||
the NPC do their next AI action.
|
||||
|
||||
"""
|
||||
pass
|
||||
```
|
||||
|
||||
- **Line 9**: By use of _multiple inheritance_ we use the `LinvingMixin` we created in the [Character lesson](./Beginner-Tutorial-Characters.md). This includes a lot of useful methods, such as showing our 'hurt level', methods to use to heal, hooks to call when getting attacked, hurt and so on. We can re-use all of those in upcoming NPC subclasses.
|
||||
- **Line 12**: The `is_pc` is a quick and convenient way to check if this is, well, a PC or not. We will use it in the upcoming [Combat base lesson](./Beginner-Tutorial-Combat-Base.md).
|
||||
- **Line 13**: The NPC is simplified in that all stats are just based on the `Hit dice` number (see **Lines 25-51**). We store `armor` and a `weapon` as direct [Attributes](../../../Components/Attributes.md) on the class rather than bother implementing a full equipment system.
|
||||
- **Lines 17, 18**: The `morale` and `allegiance` are _Knave_ properties determining how likely the NPC is to flee in a combat situation and if they are hostile or friendly.
|
||||
- **Line 19**: The `is_idle` Attribute is a useful property. It should be available on all NPCs and will be used to disable AI entirely.
|
||||
- **Line 59**: We make sure to tag NPCs. We may want to group different NPCs together later, for example to have all NPCs with the same tag respond if one of them is attacked.
|
||||
- **Line 61**: The `ai_next_action` is a method we prepare for the system to be able to ask the NPC 'what do you want to do next?'. In it we will add all logic related to the artificial intelligence of the NPC - such as walking around, attacking and performing other actions.
|
||||
|
||||
|
||||
## Testing
|
||||
|
||||
> Create a new module `evadventure/tests/test_npcs.py`
|
||||
|
||||
Not so much to test yet, but we will be using the same module to test other aspects of NPCs in the future, so let's create it now.
|
||||
|
||||
```python
|
||||
# in evadventure/tests/test_npcs.py
|
||||
|
||||
from evennia import create_object
|
||||
from evennia.utils.test_resources import EvenniaTest
|
||||
|
||||
from .. import npcs
|
||||
|
||||
class TestNPCBase(EvenniaTest):
|
||||
"""Test the NPC base class"""
|
||||
|
||||
def test_npc_base(self):
|
||||
npc = create_object(
|
||||
npcs.EvAdventureNPC,
|
||||
key="TestNPC",
|
||||
attributes=[("hit_dice", 4)], # set hit_dice to 4
|
||||
)
|
||||
|
||||
self.assertEqual(npc.hp_multiplier, 4)
|
||||
self.assertEqual(npc.hp, 16)
|
||||
self.assertEqual(npc.strength, 4)
|
||||
self.assertEqual(npc.charisma, 4)
|
||||
|
||||
|
||||
|
||||
```
|
||||
|
||||
Nothing special here. Note how the `create_object` helper function takes `attributes` as a keyword. This is a list of tuples we use to set different values than the default ones to Attributes. We then check a few of the properties to make sure they return what we expect.
|
||||
|
||||
|
||||
## Conclusions
|
||||
|
||||
In _Knave_, an NPC is a simplified version of a Player Character. In other games and rule systems, they may be all but identical.
|
||||
|
||||
With the NPC class in place, we have enough to create a 'test dummy'. Since it has no AI yet, it won't fight back, but it will be enough to have something to hit when we test our combat in the upcoming lessons.
|
||||
|
|
@ -46,12 +46,13 @@ Beginner-Tutorial-Objects
|
|||
Beginner-Tutorial-Equipment
|
||||
Beginner-Tutorial-Chargen
|
||||
Beginner-Tutorial-Rooms
|
||||
Beginner-Tutorial-NPCs
|
||||
Beginner-Tutorial-Combat-Base
|
||||
Beginner-Tutorial-Combat-Twitch
|
||||
Beginner-Tutorial-Combat-Turnbased
|
||||
Beginner-Tutorial-NPCs
|
||||
Beginner-Tutorial-Dungeon
|
||||
Beginner-Tutorial-Monsters
|
||||
Beginner-Tutorial-Quests
|
||||
Beginner-Tutorial-Shops
|
||||
Beginner-Tutorial-Dungeon
|
||||
Beginner-Tutorial-Commands
|
||||
```
|
||||
|
|
|
|||
|
|
@ -40,9 +40,10 @@ Later on we must make sure our combat systems honors these values.
|
|||
Here's a room that allows non-lethal PvP (sparring):
|
||||
|
||||
```python
|
||||
|
||||
# in evadventure/rooms.py
|
||||
|
||||
# ...
|
||||
|
||||
class EvAdventurePvPRoom(EvAdventureRoom):
|
||||
"""
|
||||
Room where PvP can happen, but noone gets killed.
|
||||
|
|
@ -86,6 +87,8 @@ Let's expand the base `EvAdventureRoom` with the map.
|
|||
|
||||
# in evadventyre/rooms.py
|
||||
|
||||
# ...
|
||||
|
||||
from copy import deepcopy
|
||||
from evennia import DefaultCharacter
|
||||
from evennia.utils.utils import inherits_from
|
||||
|
|
@ -162,6 +165,64 @@ The string returned from `get_display_header` will end up at the top of the [roo
|
|||
- **Line 58**: We want to be able to get on/off the grid if so needed. So if a room has a non-cardinal exit in it (like 'back' or up/down), we'll indicate this by showing the `>` symbol instead of the `@` in your current room.
|
||||
- **Line 67**: Once we have placed all the exit- and room-symbols in the grid, we merge it all together into a single string. At the end we use Python's standard [join](https://www.w3schools.com/python/ref_string_join.asp) to convert the grid into a single string. In doing so we must flip the grid upside down (reverse the outermost list). Why is this? If you think about how a MUD game displays its data - by printing at the bottom and then scrolling upwards - you'll realize that Evennia has to send out the top of your map _first_ and the bottom of it _last_ for it to show correctly to the user.
|
||||
|
||||
## Adding life to a room
|
||||
|
||||
Normally the room is static until you do something in it. But let's say you are in a room described to be a bustling market. Would it not be nice to occasionally get some random messages like
|
||||
|
||||
"You hear a merchant calling out his wares."
|
||||
"The sound of music drifts over the square from an open tavern door."
|
||||
"The sound of commerse rises and fall in a steady rythm."
|
||||
|
||||
Here's an example of how to accomplish this:
|
||||
|
||||
```{code-block} python
|
||||
:linenos:
|
||||
:emphasize-lines: 22,25
|
||||
|
||||
# in evadventure/rooms.py
|
||||
|
||||
# ...
|
||||
|
||||
from random import choice, random
|
||||
from evennia import TICKER_HANDLER
|
||||
|
||||
# ...
|
||||
|
||||
class EchoingRoom(EvAdventureRoom):
|
||||
"""A room that randomly echoes messages to everyone inside it"""
|
||||
|
||||
echoes = AttributeProperty(list, autocreate=False)
|
||||
echo_rate = AttributeProperty(60 * 2, autocreate=False)
|
||||
echo_chance = AttributeProperty(0.1, autocreate=False)
|
||||
|
||||
def send_echo(self):
|
||||
if self.echoes and random() < self.echo_chance:
|
||||
self.msg_contents(choice(self.echoes))
|
||||
|
||||
def start_echo(self):
|
||||
TICKER_HANDLER.add(self.echo_rate, self.send_echo)
|
||||
|
||||
def stop_echo(self):
|
||||
TICKER_HANDLER.remove(self.echo_rate, self.send_echo)
|
||||
```
|
||||
|
||||
The [TickerHandler](../../../Components/TickerHandler.md). This is acts as a 'please tick me - subscription service'. In **Line 22** we tell add our `.send_echo` method to the handler and tell the TickerHandler to call that method every `.echo_rate` seconds.
|
||||
|
||||
When the `.send_echo` method is called, it will use `random.random()` to check if we should _actually_ do anything. In our example we only show a message 10% of the time. In that case we use Python's `random.choice()` to grab a random text string from the `.echoes` list to send to everyone inside this room.
|
||||
|
||||
Here's how you'd use this room in-game:
|
||||
|
||||
> dig market:evadventure.EchoingRoom = market,back
|
||||
> market
|
||||
> set here/echoes = ["You hear a merchant shouting", "You hear the clatter of coins"]
|
||||
> py here.start_echo()
|
||||
|
||||
If you wait a while you'll eventually see one of the two echoes show up. Use `py here.stop_echo()` if you want.
|
||||
|
||||
It's a good idea to be able to turn on/off the echoes at will, if nothing else because you'd be surprised how annoying they can be if they show too often.
|
||||
|
||||
In this example we had to resort to `py` to activate/deactivate the echoes, but you could very easily make little utility [Commands](../Part1/Beginner-Tutorial-Adding-Commands.md) `startecho` and `stopecho` to do it for you. This we leave as a bonus exercise.
|
||||
|
||||
## Testing
|
||||
|
||||
> Create a new module `evadventure/tests/test_rooms.py`.
|
||||
|
|
|
|||
|
|
@ -122,12 +122,25 @@ class Ability(Enum):
|
|||
ALLEGIANCE_HOSTILE = "hostile"
|
||||
ALLEGIANCE_NEUTRAL = "neutral"
|
||||
ALLEGIANCE_FRIENDLY = "friendly"
|
||||
|
||||
|
||||
|
||||
ABILITY_REVERSE_MAP = {
|
||||
"str": Ability.STR,
|
||||
"dex": Ability.DEX,
|
||||
"con": Ability.CON,
|
||||
"int": Ability.INT,
|
||||
"wis": Ability.WIS,
|
||||
"cha": Ability.CHA
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
Here the `Ability` class holds basic properties of a character sheet.
|
||||
|
||||
The `ABILITY_REVERSE_MAP` is a convenient map to go the other way - if you in some command were to enter the string 'cha', we could use this mapping to directly convert your input to the correct `Ability`:
|
||||
|
||||
ability = ABILITY_REVERSE_MAP.get(your_input)
|
||||
|
||||
|
||||
## Utility module
|
||||
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ evennia.contrib.tutorials.evadventure.tests
|
|||
evennia.contrib.tutorials.evadventure.tests.test_commands
|
||||
evennia.contrib.tutorials.evadventure.tests.test_dungeon
|
||||
evennia.contrib.tutorials.evadventure.tests.test_equipment
|
||||
evennia.contrib.tutorials.evadventure.tests.test_npcs
|
||||
evennia.contrib.tutorials.evadventure.tests.test_quests
|
||||
evennia.contrib.tutorials.evadventure.tests.test_rooms
|
||||
evennia.contrib.tutorials.evadventure.tests.test_rules
|
||||
|
|
|
|||
|
|
@ -0,0 +1,10 @@
|
|||
```{eval-rst}
|
||||
evennia.contrib.tutorials.evadventure.tests.test\_npcs
|
||||
=============================================================
|
||||
|
||||
.. automodule:: evennia.contrib.tutorials.evadventure.tests.test_npcs
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
```
|
||||
|
|
@ -15,7 +15,7 @@ type self = evennia.contrib.tutorials.evadventure.characters.EvAdventureCharacte
|
|||
|
||||
# assign us the twitch combat cmdset (requires superuser/developer perms)
|
||||
|
||||
py self.cmdset.add("evennia.contrib.tutorials.evadventure.combat_twitch.TwitchAttackCmdSet", persistent=True)
|
||||
py self.cmdset.add("evennia.contrib.tutorials.evadventure.combat_twitch.TwitchCombatCmdSet", persistent=True)
|
||||
|
||||
# Create and give us a weapons (this will use defaults on the class)
|
||||
|
||||
|
|
|
|||
|
|
@ -64,6 +64,13 @@ class LivingMixin:
|
|||
else:
|
||||
self.msg(f"You are healed for {healed} health.")
|
||||
|
||||
def at_attacked(self, attacker, **kwargs):
|
||||
"""
|
||||
Called when being attacked / combat starts.
|
||||
|
||||
"""
|
||||
pass
|
||||
|
||||
def at_damage(self, damage, attacker=None):
|
||||
"""
|
||||
Called when attacked and taking damage.
|
||||
|
|
|
|||
|
|
@ -204,7 +204,6 @@ class CombatActionStunt(CombatAction):
|
|||
"|yHaving succeeded, you hold back to plan your next move.|n [hold]",
|
||||
broadcast=False,
|
||||
)
|
||||
combathandler.queue_action(attacker, combathandler.fallback_action_dict)
|
||||
else:
|
||||
self.msg(f"$You({defender.key}) $conj(resist)! $You() $conj(fail) the stunt.")
|
||||
|
||||
|
|
@ -240,7 +239,6 @@ class CombatActionUseItem(CombatAction):
|
|||
)
|
||||
item.at_post_use(user, target)
|
||||
# to back to idle after this
|
||||
self.combathandler.queue_action(self.combatant, self.combathandler.fallback_action_dict)
|
||||
|
||||
|
||||
class CombatActionWield(CombatAction):
|
||||
|
|
@ -260,13 +258,12 @@ class CombatActionWield(CombatAction):
|
|||
|
||||
def execute(self):
|
||||
self.combatant.equipment.move(self.item)
|
||||
self.combathandler.queue_action(self.combatant, self.combathandler.fallback_action_dict)
|
||||
|
||||
|
||||
# main combathandler
|
||||
|
||||
|
||||
class EvAdventureCombatHandlerBase(DefaultScript):
|
||||
class EvAdventureCombatBaseHandler(DefaultScript):
|
||||
"""
|
||||
This script is created when a combat starts. It 'ticks' the combat and tracks
|
||||
all sides of it.
|
||||
|
|
@ -481,13 +478,14 @@ class EvAdventureCombatHandlerBase(DefaultScript):
|
|||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def queue_action(self, combatant, action_dict):
|
||||
def queue_action(self, action_dict, combatant=None):
|
||||
"""
|
||||
Queue an action by adding the new actiondict.
|
||||
|
||||
Args:
|
||||
combatant (EvAdventureCharacter, EvAdventureNPC): A combatant queueing the action.
|
||||
action_dict (dict): A dict describing the action class by name along with properties.
|
||||
combatant (EvAdventureCharacter, EvAdventureNPC, optional): A combatant queueing the
|
||||
action.
|
||||
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ from .combat_base import (
|
|||
CombatActionStunt,
|
||||
CombatActionUseItem,
|
||||
CombatActionWield,
|
||||
EvAdventureCombatHandlerBase,
|
||||
EvAdventureCombatBaseHandler,
|
||||
)
|
||||
from .enums import Ability
|
||||
|
||||
|
|
@ -70,7 +70,7 @@ class CombatActionFlee(CombatAction):
|
|||
)
|
||||
|
||||
|
||||
class EvAdventureTurnbasedCombatHandler(EvAdventureCombatHandlerBase):
|
||||
class EvAdventureTurnbasedCombatHandler(EvAdventureCombatBaseHandler):
|
||||
"""
|
||||
A version of the combathandler, handling turn-based combat.
|
||||
|
||||
|
|
|
|||
|
|
@ -15,12 +15,12 @@ from .combat_base import (
|
|||
CombatActionStunt,
|
||||
CombatActionUseItem,
|
||||
CombatActionWield,
|
||||
EvAdventureCombatHandlerBase,
|
||||
EvAdventureCombatBaseHandler,
|
||||
)
|
||||
from .enums import ABILITY_REVERSE_MAP
|
||||
|
||||
|
||||
class EvAdventureCombatTwitchHandler(EvAdventureCombatHandlerBase):
|
||||
class EvAdventureCombatTwitchHandler(EvAdventureCombatBaseHandler):
|
||||
"""
|
||||
This is created on the combatant when combat starts. It tracks only the combatants
|
||||
side of the combat and handles when the next action will happen.
|
||||
|
|
@ -39,8 +39,8 @@ class EvAdventureCombatTwitchHandler(EvAdventureCombatHandlerBase):
|
|||
|
||||
# dynamic properties
|
||||
|
||||
advantages_against = AttributeProperty(dict)
|
||||
disadvantages_against = AttributeProperty(dict)
|
||||
advantage_against = AttributeProperty(dict)
|
||||
disadvantage_against = AttributeProperty(dict)
|
||||
|
||||
action_dict = AttributeProperty(dict)
|
||||
fallback_action_dict = AttributeProperty({"key": "hold", "dt": 0})
|
||||
|
|
@ -48,7 +48,7 @@ class EvAdventureCombatTwitchHandler(EvAdventureCombatHandlerBase):
|
|||
# stores the current ticker reference, so we can manipulate it later
|
||||
current_ticker_ref = AttributeProperty(None)
|
||||
|
||||
def msg(self, message, broadcast=True):
|
||||
def msg(self, message, broadcast=True, **kwargs):
|
||||
"""
|
||||
Central place for sending messages to combatants. This allows
|
||||
for adding any combat-specific text-decoration in one place.
|
||||
|
|
@ -124,7 +124,7 @@ class EvAdventureCombatTwitchHandler(EvAdventureCombatHandlerBase):
|
|||
some future boost)
|
||||
|
||||
"""
|
||||
self.advantages_against[target] = True
|
||||
self.advantage_against[target] = True
|
||||
|
||||
def give_disadvantage(self, recipient, target):
|
||||
"""
|
||||
|
|
@ -136,7 +136,7 @@ class EvAdventureCombatTwitchHandler(EvAdventureCombatHandlerBase):
|
|||
an enemy.
|
||||
|
||||
"""
|
||||
self.disadvantages_against[target] = True
|
||||
self.disadvantage_against[target] = True
|
||||
|
||||
def has_advantage(self, combatant, target):
|
||||
"""
|
||||
|
|
@ -147,7 +147,7 @@ class EvAdventureCombatTwitchHandler(EvAdventureCombatHandlerBase):
|
|||
target (Character or NPC): The target to check advantage against.
|
||||
|
||||
"""
|
||||
return self.advantages_against.get(target, False)
|
||||
return self.advantage_against.get(target, False)
|
||||
|
||||
def has_disadvantage(self, combatant, target):
|
||||
"""
|
||||
|
|
@ -158,14 +158,15 @@ class EvAdventureCombatTwitchHandler(EvAdventureCombatHandlerBase):
|
|||
target (Character or NPC): The target to check disadvantage against.
|
||||
|
||||
"""
|
||||
return self.disadvantages_against.get(target, False)
|
||||
return self.disadvantage_against.get(target, False)
|
||||
|
||||
def queue_action(self, action_dict):
|
||||
def queue_action(self, action_dict, combatant=None):
|
||||
"""
|
||||
Schedule the next action to fire.
|
||||
|
||||
Args:
|
||||
action_dict (dict): The new action-dict to initialize.
|
||||
combatant: Unused.
|
||||
|
||||
"""
|
||||
if action_dict["key"] not in self.action_classes:
|
||||
|
|
@ -323,7 +324,7 @@ class CmdAttack(_BaseTwitchCombatCommand):
|
|||
combathandler = self.get_or_create_combathandler(target)
|
||||
# we use a fixed dt of 3 here, to mimic Diku style; one could also picture
|
||||
# attacking at a different rate, depending on skills/weapon etc.
|
||||
combathandler.queue_action({"key": "attack", "target": target, "dt": 3})
|
||||
combathandler.queue_action({"key": "attack", "target": target, "dt": 3, "repeat": True})
|
||||
combathandler.msg(f"$You() $conj(attack) $You({target.key})!", self.caller)
|
||||
|
||||
|
||||
|
|
@ -435,8 +436,6 @@ class CmdStunt(_BaseTwitchCombatCommand):
|
|||
self.target = target.strip()
|
||||
|
||||
def func(self):
|
||||
combathandler = self.get_or_create_combathandler(self.target)
|
||||
|
||||
target = self.caller.search(self.target)
|
||||
if not target:
|
||||
return
|
||||
|
|
@ -444,6 +443,8 @@ class CmdStunt(_BaseTwitchCombatCommand):
|
|||
if not recipient:
|
||||
return
|
||||
|
||||
combathandler = self.get_or_create_combathandler(target)
|
||||
|
||||
combathandler.queue_action(
|
||||
{
|
||||
"key": "stunt",
|
||||
|
|
@ -452,6 +453,7 @@ class CmdStunt(_BaseTwitchCombatCommand):
|
|||
"advantage": self.advantage,
|
||||
"stunt_type": self.stunt_type,
|
||||
"defense_type": self.stunt_type,
|
||||
"dt": 3,
|
||||
},
|
||||
)
|
||||
combathandler.msg("$You() prepare a stunt!", self.caller)
|
||||
|
|
@ -498,7 +500,7 @@ class CmdUseItem(_BaseTwitchCombatCommand):
|
|||
return
|
||||
|
||||
combathandler = self.get_or_create_combathandler(self.target)
|
||||
combathandler.queue_action({"key": "use", "item": item, "target": target})
|
||||
combathandler.queue_action({"key": "use", "item": item, "target": target, "dt": 3})
|
||||
combathandler.msg(
|
||||
f"$You() prepare to use {item.get_display_name(self.caller)}!", self.caller
|
||||
)
|
||||
|
|
@ -539,11 +541,11 @@ class CmdWield(_BaseTwitchCombatCommand):
|
|||
self.msg("(You must carry the item to wield it.)")
|
||||
return
|
||||
combathandler = self.get_or_create_combathandler()
|
||||
combathandler.queue_action({"key": "wield", "item": item})
|
||||
combathandler.queue_action({"key": "wield", "item": item, "dt": 3})
|
||||
combathandler.msg(f"$You() reach for {item.get_display_name(self.caller)}!", self.caller)
|
||||
|
||||
|
||||
class TwitchAttackCmdSet(CmdSet):
|
||||
class TwitchCombatCmdSet(CmdSet):
|
||||
"""
|
||||
Add to character, to be able to attack others in a twitch-style way.
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -54,7 +54,8 @@ class EvAdventureNPC(LivingMixin, DefaultCharacter):
|
|||
weapon = AttributeProperty(default=BARE_HANDS, autocreate=False) # instead of inventory
|
||||
coins = AttributeProperty(default=1, autocreate=False) # coin loot
|
||||
|
||||
# if this npc is attacked, everyone with the same tag in the current location will also be pulled into combat.
|
||||
# if this npc is attacked, everyone with the same tag in the current location will also be
|
||||
# pulled into combat.
|
||||
group = TagProperty("npcs")
|
||||
|
||||
@property
|
||||
|
|
@ -91,8 +92,16 @@ class EvAdventureNPC(LivingMixin, DefaultCharacter):
|
|||
|
||||
"""
|
||||
self.hp = self.hp_max
|
||||
self.tags.add("npcs", category="group")
|
||||
|
||||
def ai_combat_next_action(self, **kwargs):
|
||||
def at_attacked(self, attacker, **kwargs):
|
||||
"""
|
||||
Called when being attacked and combat starts.
|
||||
|
||||
"""
|
||||
pass
|
||||
|
||||
def ai_next_action(self, **kwargs):
|
||||
"""
|
||||
The combat engine should ask this method in order to
|
||||
get the next action the npc should perform in combat.
|
||||
|
|
@ -247,7 +256,7 @@ class EvAdventureMob(EvAdventureNPC):
|
|||
# chance (%) that this enemy will loot you when defeating you
|
||||
loot_chance = AttributeProperty(75, autocreate=False)
|
||||
|
||||
def ai_combat_next_action(self, combathandler):
|
||||
def ai_next_action(self, **kwargs):
|
||||
"""
|
||||
Called to get the next action in combat.
|
||||
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@ class _CombatTestBase(EvenniaTestCase):
|
|||
self.target.msg = Mock()
|
||||
|
||||
|
||||
class TestEvAdventureCombatHandlerBase(_CombatTestBase):
|
||||
class TestEvAdventureCombatBaseHandler(_CombatTestBase):
|
||||
"""
|
||||
Test the base functionality of the base combat handler.
|
||||
|
||||
|
|
@ -60,7 +60,7 @@ class TestEvAdventureCombatHandlerBase(_CombatTestBase):
|
|||
def setUp(self):
|
||||
"""This also tests the `get_or_create_combathandler` classfunc"""
|
||||
super().setUp()
|
||||
self.combathandler = combat_base.EvAdventureCombatHandlerBase.get_or_create_combathandler(
|
||||
self.combathandler = combat_base.EvAdventureCombatBaseHandler.get_or_create_combathandler(
|
||||
self.location, key="combathandler"
|
||||
)
|
||||
|
||||
|
|
@ -109,7 +109,7 @@ class TestCombatActionsBase(_CombatTestBase):
|
|||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.combathandler = combat_base.EvAdventureCombatHandlerBase.get_or_create_combathandler(
|
||||
self.combathandler = combat_base.EvAdventureCombatBaseHandler.get_or_create_combathandler(
|
||||
self.location, key="combathandler"
|
||||
)
|
||||
# we need to mock all NotImplemented methods
|
||||
|
|
@ -552,11 +552,11 @@ class TestEvAdventureTwitchCombatHandler(EvenniaCommandTestMixin, _CombatTestBas
|
|||
|
||||
def test_give_advantage(self):
|
||||
self.combatant_combathandler.give_advantage(self.combatant, self.target)
|
||||
self.assertTrue(self.combatant_combathandler.advantages_against[self.target])
|
||||
self.assertTrue(self.combatant_combathandler.advantage_against[self.target])
|
||||
|
||||
def test_give_disadvantage(self):
|
||||
self.combatant_combathandler.give_disadvantage(self.combatant, self.target)
|
||||
self.assertTrue(self.combatant_combathandler.disadvantages_against[self.target])
|
||||
self.assertTrue(self.combatant_combathandler.disadvantage_against[self.target])
|
||||
|
||||
@patch("evennia.contrib.tutorials.evadventure.combat_twitch.unrepeat", new=Mock())
|
||||
@patch("evennia.contrib.tutorials.evadventure.combat_twitch.repeat", new=Mock(return_value=999))
|
||||
|
|
|
|||
23
evennia/contrib/tutorials/evadventure/tests/test_npcs.py
Normal file
23
evennia/contrib/tutorials/evadventure/tests/test_npcs.py
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
"""
|
||||
Test NPC classes.
|
||||
|
||||
"""
|
||||
|
||||
from evennia import create_object
|
||||
from evennia.utils.test_resources import EvenniaTest
|
||||
|
||||
from .. import npcs
|
||||
|
||||
|
||||
class TestNPCBase(EvenniaTest):
|
||||
def test_npc_base(self):
|
||||
npc = create_object(
|
||||
npcs.EvAdventureNPC,
|
||||
key="TestNPC",
|
||||
attributes=[("hit_dice", 4), ("armor", 1), ("morale", 9)],
|
||||
)
|
||||
|
||||
self.assertEqual(npc.hp_multiplier, 4)
|
||||
self.assertEqual(npc.hp, 16)
|
||||
self.assertEqual(npc.strength, 4)
|
||||
self.assertEqual(npc.charisma, 4)
|
||||
Loading…
Add table
Add a link
Reference in a new issue