Start documenting evadventure

This commit is contained in:
Griatch 2022-08-05 20:33:22 +02:00
parent 080db7092c
commit 2a62cc427a
27 changed files with 1164 additions and 77 deletions

View file

@ -1,802 +0,0 @@
[prev lesson](../../../Unimplemented.md) | [next lesson](../../../Unimplemented.md)
# Making a sittable object
In this lesson we will go through how to make a chair you can sit on. Sounds easy, right?
Well it is. But in the process of making the chair we will need to consider the various ways
to do it depending on how we want our game to work.
The goals of this lesson are as follows:
- We want a new 'sittable' object, a Chair in particular".
- We want to be able to use a command to sit in the chair.
- Once we are sitting in the chair it should affect us somehow. To demonstrate this we'll
set a flag "Resting" on the Character sitting in the Chair.
- When you sit down you should not be able to walk to another room without first standing up.
- A character should be able to stand up and move away from the chair.
There are two main ways to design the commands for sitting and standing up.
- You can store the commands on the chair so they are only available when a chair is in the room
- You can store the commands on the Character so they are always available and you must always specify
which chair to sit on.
Both of these are very useful to know about, so in this lesson we'll try both. But first
we need to handle some basics.
## Don't move us when resting
When you are sitting in a chair you can't just walk off without first standing up.
This requires a change to our Character typeclass. Open `mygame/typeclasses/characters.py`:
```python
# ...
class Character(DefaultCharacter):
# ...
def at_pre_move(self, destination):
"""
Called by self.move_to when trying to move somewhere. If this returns
False, the move is immediately cancelled.
"""
if self.db.is_resting:
self.msg("You can't go anywhere while resting.")
return False
return True
```
When moving somewhere, [character.move_to](evennia.objects.objects.DefaultObject.move_to) is called. This in turn
will call `character.at_pre_move`. Here we look for an Attribute `is_resting` (which we will assign below)
to determine if we are stuck on the chair or not.
## Making the Chair itself
Next we need the Chair itself, or rather a whole family of "things you can sit on" that we will call
_sittables_. We can't just use a default Object since we want a sittable to contain some custom code. We need
a new, custom Typeclass. Create a new module `mygame/typeclasses/sittables.py` with the following content:
```python
from evennia import DefaultObject
class Sittable(DefaultObject):
def at_object_creation(self):
self.db.sitter = None
def do_sit(self, sitter):
"""
Called when trying to sit on/in this object.
Args:
sitter (Object): The one trying to sit down.
"""
current = self.db.sitter
if current:
if current == sitter:
sitter.msg("You are already sitting on {self.key}.")
else:
sitter.msg(f"You can't sit on {self.key} "
f"- {current.key} is already sitting there!")
return
self.db.sitting = sitter
sitter.db.is_resting = True
sitter.msg(f"You sit on {self.key}")
def do_stand(self, stander):
"""
Called when trying to stand from this object.
Args:
stander (Object): The one trying to stand up.
"""
current = self.db.sitter
if not stander == current:
stander.msg(f"You are not sitting on {self.key}.")
else:
self.db.sitting = None
stander.db.is_resting = False
stander.msg(f"You stand up from {self.key}")
```
Here we have a small Typeclass that handles someone trying to sit on it. It has two methods that we can simply
call from a Command later. We set the `is_resting` Attribute on the one sitting down.
One could imagine that one could have the future `sit` command check if someone is already sitting in the
chair instead. This would work too, but letting the `Sittable` class handle the logic around who can sit on it makes
logical sense.
We let the typeclass handle the logic, and also let it do all the return messaging. This makes it easy to churn out
a bunch of chairs for people to sit on. But it's not perfect. The `Sittable` class is general. What if you want to
make an armchair. You sit "in" an armchair rather than "on" it. We _could_ make a child class of `Sittable` named
`SittableIn` that makes this change, but that feels excessive. Instead we will make it so that Sittables can
modify this per-instance:
```python
from evennia import DefaultObject
class Sittable(DefaultObject):
def at_object_creation(self):
self.db.sitter = None
# do you sit "on" or "in" this object?
self.db.adjective = "on"
def do_sit(self, sitter):
"""
Called when trying to sit on/in this object.
Args:
sitter (Object): The one trying to sit down.
"""
adjective = self.db.adjective
current = self.db.sitter
if current:
if current == sitter:
sitter.msg(f"You are already sitting {adjective} {self.key}.")
else:
sitter.msg(
f"You can't sit {adjective} {self.key} "
f"- {current.key} is already sitting there!")
return
self.db.sitting = sitter
sitter.db.is_resting = True
sitter.msg(f"You sit {adjective} {self.key}")
def do_stand(self, stander):
"""
Called when trying to stand from this object.
Args:
stander (Object): The one trying to stand up.
"""
current = self.db.sitter
if not stander == current:
stander.msg(f"You are not sitting {self.db.adjective} {self.key}.")
else:
self.db.sitting = None
stander.db.is_resting = False
stander.msg(f"You stand up from {self.key}")
```
We added a new Attribute `adjective` which will probably usually be `in` or `on` but could also be `at` if you
want to be able to sit _at a desk_ for example. A regular builder would use it like this:
> create/drop armchair : sittables.Sittable
> set armchair/adjective = in
This is probably enough. But all those strings are hard-coded. What if we want some more dramatic flair when you
sit down?
You sit down and a whoopie cushion makes a loud fart noise!
For this we need to allow some further customization. Let's let the current strings be defaults that
we can replace.
```python
from evennia import DefaultObject
class Sittable(DefaultObject):
"""
An object one can sit on
Customizable Attributes:
adjective: How to sit (on, in, at etc)
Return messages (set as Attributes):
msg_already_sitting: Already sitting here
format tokens {adjective} and {key}
msg_other_sitting: Someone else is sitting here.
format tokens {adjective}, {key} and {other}
msg_sitting_down: Successfully sit down
format tokens {adjective}, {key}
msg_standing_fail: Fail to stand because not sitting.
format tokens {adjective}, {key}
msg_standing_up: Successfully stand up
format tokens {adjective}, {key}
"""
def at_object_creation(self):
self.db.sitter = None
# do you sit "on" or "in" this object?
self.db.adjective = "on"
def do_sit(self, sitter):
"""
Called when trying to sit on/in this object.
Args:
sitter (Object): The one trying to sit down.
"""
adjective = self.db.adjective
current = self.db.sitter
if current:
if current == sitter:
if self.db.msg_already_sitting:
sitter.msg(
self.db.msg_already_sitting.format(
adjective=self.db.adjective, key=self.key))
else:
sitter.msg(f"You are already sitting {adjective} {self.key}.")
else:
if self.db.msg_other_sitting:
sitter.msg(self.db.msg_already_sitting.format(
other=current.key, adjective=self.db.adjective, key=self.key))
else:
sitter.msg(f"You can't sit {adjective} {self.key} "
f"- {current.key} is already sitting there!")
return
self.db.sitting = sitter
sitter.db.is_resting = True
if self.db.msg_sitting_down:
sitter.msg(self.db.msg_sitting_down.format(adjective=adjective, key=self.key))
else:
sitter.msg(f"You sit {adjective} {self.key}")
def do_stand(self, stander):
"""
Called when trying to stand from this object.
Args:
stander (Object): The one trying to stand up.
"""
current = self.db.sitter
if not stander == current:
if self.db.msg_standing_fail:
stander.msg(self.db.msg_standing_fail.format(
adjective=self.db.adjective, key=self.key))
else:
stander.msg(f"You are not sitting {self.db.adjective} {self.key}")
else:
self.db.sitting = None
stander.db.is_resting = False
if self.db.msg_standing_up:
stander.msg(self.db.msg_standing_up.format(
adjective=self.db.adjective, key=self.key))
else:
stander.msg(f"You stand up from {self.key}")
```
Here we really went all out with flexibility. If you need this much is up to you.
We added a bunch of optional Attributes to hold alternative versions of all the messages.
There are some things to note:
- We don't actually initiate those Attributes in `at_object_creation`. This is a simple
optimization. The assumption is that _most_ chairs will probably not be this customized.
So initiating a bunch of Attributes to, say, empty strings would be a lot of useless database calls.
The drawback is that the available Attributes become less visible when reading the code. So we add a long
describing docstring to the end to explain all you can use.
- We use `.format` to inject formatting-tokens in the text. The good thing about such formatting
markers is that they are _optional_. They are there if you want them, but Python will not complain
if you don't include some or any of them. Let's see an example:
> reload # if you have new code
> create/drop armchair : sittables.Sittable
> set armchair/adjective = in
> set armchair/msg_sitting_down = As you sit down {adjective} {key}, life feels easier.
> set armchair/msg_standing_up = You stand up from {key}. Life resumes.
The `{key}` and `{adjective}` are examples of optional formatting markers. Whenever the message is
returned, the format-tokens within will be replaced with `armchair` and `in` respectively. Should we
rename the chair later, this will show in the messages automatically (since `{key}` will change).
We have no Command to use this chair yet. But we can try it out with `py`:
> py self.search("armchair").do_sit(self)
As you sit down in armchair, life feels easier.
> self.db.resting
True
> py self.search("armchair").do_stand(self)
You stand up from armchair. Life resumes
> self.db.resting
False
If you follow along and get a result like this, all seems to be working well!
## Command variant 1: Commands on the chair
This way to implement `sit` and `stand` puts new cmdsets on the Sittable itself.
As we've learned before, commands on objects are made available to others in the room.
This makes the command easy but instead adds some complexity in the management of the CmdSet.
This is how it will look if `armchair` is in the room:
> sit
As you sit down in armchair, life feels easier.
What happens if there are sittables `sofa` and `barstool` also in the room? Evennia will automatically
handle this for us and allow us to specify which one we want:
> sit
More than one match for 'sit' (please narrow target):
sit-1 (armchair)
sit-2 (sofa)
sit-3 (barstool)
> sit-1
As you sit down in armchair, life feels easier.
To keep things separate we'll make a new module `mygame/commands/sittables.py`:
```{sidebar} Separate Commands and Typeclasses?
You can organize these things as you like. If you wanted you could put the sit-command + cmdset
together with the `Sittable` typeclass in `mygame/typeclasses/sittables.py`. That has the advantage of
keeping everything related to sitting in one place. But there is also some organizational merit to
keeping all Commands in one place as we do here.
```
```python
from evennia import Command, CmdSet
class CmdSit(Command):
"""
Sit down.
"""
key = "sit"
def func(self):
self.obj.do_sit(self.caller)
class CmdStand(Command):
"""
Stand up.
"""
key = "stand"
def func(self):
self.obj.do_stand(self.caller)
class CmdSetSit(CmdSet):
priority = 1
def at_cmdset_creation(self):
self.add(CmdSit)
self.add(CmdStand)
```
As seen, the commands are nearly trivial. `self.obj` is the object to which we added the cmdset with this
Command (so for example a chair). We just call the `do_sit/stand` on that object and the `Sittable` will
do the rest.
Why that `priority = 1` on `CmdSetSit`? This makes same-named Commands from this cmdset merge with a bit higher
priority than Commands from the Character-cmdset. Why this is a good idea will become clear shortly.
We also need to make a change to our `Sittable` typeclass. Open `mygame/typeclasses/sittables.py`:
```python
from evennia import DefaultObject
from commands.sittables import CmdSetSit # <- new
class Sittable(DefaultObject):
"""
(docstring)
"""
def at_object_creation(self):
self.db.sitter = None
# do you sit "on" or "in" this object?
self.db.adjective = "on"
self.cmdset.add_default(CmdSetSit) # <- new
```
Any _new_ Sittables will now have your `sit` Command. Your existing `armchair` will not,
since `at_object_creation` will not re-run for already existing objects. We can update it manually:
> reload
> update armchair
We could also update all existing sittables (all on one line):
> py from typeclasses.sittables import Sittable ;
[sittable.at_object_creation() for sittable in Sittable.objects.all()]
> The above shows an example of a _list comprehension_. Think of it as an efficient way to construct a new list
all in one line. You can read more about list comprehensions
[here in the Python docs](https://docs.python.org/3/tutorial/datastructures.html#list-comprehensions).
We should now be able to use `sit` while in the room with the armchair.
> sit
As you sit down in armchair, life feels easier.
> stand
You stand up from armchair.
One issue with placing the `sit` (or `stand`) Command "on" the chair is that it will not be available when in a
room without a Sittable object:
> sit
Command 'sit' is not available. ...
This is practical but not so good-looking; it makes it harder for the user to know a `sit` action is at all
possible. Here is a trick for fixing this. Let's add _another_ Command to the bottom
of `mygame/commands/sittables.py`:
```python
# ...
class CmdNoSitStand(Command):
"""
Sit down or Stand up
"""
key = "sit"
aliases = ["stand"]
def func(self):
if self.cmdname == "sit":
self.msg("You have nothing to sit on.")
else:
self.msg("You are not sitting down.")
```
Here we have a Command that is actually two - it will answer to both `sit` and `stand` since we
added `stand` to its `aliases`. In the command we look at `self.cmdname`, which is the string
_actually used_ to call this command. We use this to return different messages.
We don't need a separate CmdSet for this, instead we will add this
to the default Character cmdset. Open `mygame/commands/default_cmdsets.py`:
```python
# ...
from commands import sittables
class CharacterCmdSet(CmdSet):
"""
(docstring)
"""
def at_cmdset_creation(self):
# ...
self.add(sittables.CmdNoSitStand)
```
To test we'll build a new location without any comfy armchairs and go there:
> reload
> tunnel n = kitchen
north
> sit
You have nothing to sit on.
> south
sit
As you sit down in armchair, life feels easier.
We now have a fully functioning `sit` action that is contained with the chair itself. When no chair is around, a
default error message is shown.
How does this work? There are two cmdsets at play, both of which have a `sit` Command. As you may remember we
set the chair's cmdset to `priority = 1`. This is where that matters. The default Character cmdset has a
priority of 0. This means that whenever we enter a room with a Sittable thing, the `sit` command
from _its_ cmdset will take _precedence_ over the Character cmdset's version. So we are actually picking
_different_ `sit` commands depending on circumstance! The user will never be the wiser.
So this handles `sit`. What about `stand`? That will work just fine:
> stand
You stand up from armchair.
> north
> stand
You are not sitting down.
We have one remaining problem with `stand` though - what happens when you are sitting down and try to
`stand` in a room with more than one chair:
> stand
More than one match for 'stand' (please narrow target):
stand-1 (armchair)
stand-2 (sofa)
stand-3 (barstool)
Since all the sittables have the `stand` Command on them, you'll get a multi-match error. This _works_ ... but
you could pick _any_ of those sittables to "stand up from". That's really weird and non-intuitive. With `sit` it
was okay to get a choice - Evennia can't know which chair we intended to sit on. But we know which chair we
sit on so we should only get _its_ `stand` command.
We will fix this with a `lock` and a custom `lock function`. We want a lock on the `stand` Command that only
makes it available when the caller is actually sitting on the chair the `stand` command is on.
First let's add the lock so we see what we want. Open `mygame/commands/sittables.py`:
```python
# ...
class CmdStand(Command):
"""
Stand up.
"""
key = "stand"
lock = "cmd:sitsonthis()" # < this is new
def func(self):
self.obj.do_stand(self.caller)
# ...
```
We define a [Lock](../../../Components/Locks.md) on the command. The `cmd:` is in what situation Evennia will check
the lock. The `cmd` means that it will check the lock when determining if a user has access to this command or not.
What will be checked is the `sitsonthis` _lock function_ which doesn't exist yet.
Open `mygame/server/conf/lockfuncs.py` to add it!
```python
"""
(module lockstring)
"""
# ...
def sitsonthis(accessing_obj, accessed_obj, *args, **kwargs):
"""
True if accessing_obj is sitting on/in the accessed_obj.
"""
return accessed_obj.db.sitting == accessing_obj
# ...
```
Evennia knows that all functions in `mygame/server/conf/lockfuncs` should be possible to use in a lock definition.
The arguments are required and Evennia will pass all relevant objects to them:
```{sidebar} Lockfuncs
Evennia provides a large number of default lockfuncs, such as checking permission-levels,
if you are carrying or are inside the accessed object etc. There is no concept of 'sitting'
in default Evennia however, so this we need to specify ourselves.
```
- `accessing_obj` is the one trying to access the lock. So us, in this case.
- `accessed_obj` is the entity we are trying to gain a particular type of access to. So the chair.
- `args` is a tuple holding any arguments passed to the lockfunc. Since we use `sitsondthis()` this will
be empty (and if we add anything, it will be ignored).
- `kwargs` is a tuple of keyword arguments passed to the lockfuncs. This will be empty as well in our example.
If you are superuser, it's important that you `quell` yourself before trying this out. This is because the superuser
bypasses all locks - it can never get locked out, but it means it will also not see the effects of a lock like this.
> reload
> quell
> stand
You stand up from armchair
None of the other sittables' `stand` commands passed the lock and only the one we are actually sitting on did.
Adding a Command to the chair object like this is powerful and a good technique to know. It does come with some
caveats though that one needs to keep in mind.
We'll now try another way to add the `sit/stand` commands.
## Command variant 2: Command on Character
Before we start with this, delete the chairs you've created (`del armchair` etc) and then do the following
changes:
- In `mygame/typeclasses/sittables.py`, comment out the line `self.cmdset.add_default(CmdSetSit)`.
- In `mygame/commands/default_cmdsets.py`, comment out the line `self.add(sittables.CmdNoSitStand)`.
This disables the on-object command solution so we can try an alternative. Make sure to `reload` so the
changes are known to Evennia.
In this variation we will put the `sit` and `stand` commands on the `Character` instead of on the chair. This
makes some things easier, but makes the Commands themselves more complex because they will not know which
chair to sit on. We can't just do `sit` anymore. This is how it will work.
> sit <chair>
You sit on chair.
> stand
You stand up from chair.
Open `mygame/commands.sittables.py` again. We'll add a new sit-command. We name the class `CmdSit2` since
we already have `CmdSit` from the previous example. We put everything at the end of the module to
keep it separate.
```python
from evennia import Command, CmdSet
from evennia import InterruptCommand # <- this is new
class CmdSit(Command):
# ...
# ...
# new from here
class CmdSit2(Command):
"""
Sit down.
Usage:
sit <sittable>
"""
key = "sit"
def parse(self):
self.args = self.args.strip()
if not self.args:
self.caller.msg("Sit on what?")
raise InterruptCommand
def func(self):
# self.search handles all error messages etc.
sittable = self.caller.search(self.args)
if not sittable:
return
try:
sittable.do_sit(self.caller)
except AttributeError:
self.caller.msg("You can't sit on that!")
```
With this Command-variation we need to search for the sittable. A series of methods on the Command
are run in sequence:
1. `Command.at_pre_command` - this is not used by default
2. `Command.parse` - this should parse the input
3. `Command.func` - this should implement the actual Command functionality
4. `Command.at_post_func` - this is not used by default
So if we just `return` in `.parse`, `.func` will still run, which is not what we want. To immediately
abort this sequence we need to `raise InterruptCommand`.
```{sidebar} Raising exceptions
Raising an exception allows for immediately interrupting the current program flow. Python
automatically raises error-exceptions when detecting problems with the code. It will be
raised up through the sequence of called code (the 'stack') until it's either `caught` with
a `try ... except` or reaches the outermost scope where it'll be logged or displayed.
```
`InterruptCommand` is an _exception_ that the Command-system catches with the understanding that we want
to do a clean abort. In the `.parse` method we strip any whitespaces from the argument and
sure there actuall _is_ an argument. We abort immediately if there isn't.
We we get to `.func` at all, we know that we have an argument. We search for this and abort if we there was
a problem finding the target.
> We could have done `raise InterruptCommand` in `.func` as well, but `return` is a little shorter to write
> and there is no harm done if `at_post_func` runs since it's empty.
Next we call the found sittable's `do_sit` method. Note that we wrap this call like this:
```python
try:
# code
except AttributeError:
# stuff to do if AttributeError exception was raised
```
The reason is that `caller.search` has no idea we are looking for a Sittable. The user could have tried
`sit wall` or `sit sword`. These don't have a `do_sit` method _but we call it anyway and handle the error_.
This is a very "Pythonic" thing to do. The concept is often called "leap before you look" or "it's easier to
ask for forgiveness than for permission". If `sittable.do_sit` does not exist, Python will raise an `AttributeError`.
We catch this with `try ... except AttributeError` and convert it to a proper error message.
While it's useful to learn about `try ... except`, there is also a way to leverage Evennia to do this without
`try ... except`:
```python
# ...
def func(self):
# self.search handles all error messages etc.
sittable = self.caller.search(
self.args,
typeclass="typeclasses.sittables.Sittable")
if not sittable:
return
sittable.do_sit(self.caller)
```
```{sidebar} Continuing across multiple lines
Note how the `.search()` method's arguments are spread out over multiple
lines. This works for all lists, tuples and other listings and is
a good way to avoid very long and hard-to-read lines.
```
The `caller.search` method has an keyword argument `typeclass` that can take either a python-path to a
typeclass, the typeclass itself, or a list of either to widen the allowed options. In this case we know
for sure that the `sittable` we get is actually a `Sittable` class and we can call `sittable.do_sit` without
needing to worry about catching errors.
Let's do the `stand` command while we are at it. Again, since the Command is external to the chair we don't
know which object we are sitting in and have to search for it.
```python
class CmdStand2(Command):
"""
Stand up.
Usage:
stand
"""
key = "stand"
def func(self):
caller = self.caller
# find the thing we are sitting on/in, by finding the object
# in the current location that as an Attribute "sitter" set
# to the caller
sittable = caller.search(
caller,
candidates=caller.location.contents,
attribute_name="sitter",
typeclass="typeclasses.sittables.Sittable")
# if this is None, the error was already reported to user
if not sittable:
return
sittable.do_stand(caller)
```
This forced us to to use the full power of the `caller.search` method. If we wanted to search for something
more complex we would likely need to break out a [Django query](../Part1/Django-queries.md) to do it. The key here is that
we know that the object we are looking for is a `Sittable` and that it must have an Attribute named `sitter`
which should be set to us, the one sitting on/in the thing. Once we have that we just call `.do_stand` on it
and let the Typeclass handle the rest.
All that is left now is to make this available to us. This type of Command should be available to us all the time
so we can put it in the default Cmdset` on the Character. Open `mygame/default_cmdsets.py`
```python
# ...
from commands import sittables
class CharacterCmdSet(CmdSet):
"""
(docstring)
"""
def at_cmdset_creation(self):
# ...
self.add(sittables.CmdSit2)
self.add(sittables.CmdStand2)
```
Now let's try it out:
> reload
> create/drop sofa : sittables.Sittable
> sit sofa
You sit down on sofa.
> stand
You stand up from sofa.
## Conclusions
In this lesson we accomplished quite a bit:
- We modified our `Character` class to avoid moving when sitting down.
- We made a new `Sittable` typeclass
- We tried two ways to allow a user to interact with sittables using `sit` and `stand` commands.
Eagle-eyed readers will notice that the `stand` command sitting "on" the chair (variant 1) would work just fine
together with the `sit` command sitting "on" the Character (variant 2). There is nothing stopping you from
mixing them, or even try a third solution that better fits what you have in mind.
[prev lesson](../../../Unimplemented.md) | [next lesson](../../../Unimplemented.md)

View file

@ -17,47 +17,52 @@
Taking our new game online and let players try it out
```
In part three of the Evennia Beginner tutorial we will go through the creation of several key parts of our tutorial
game _EvAdventure_. This is a pretty big part with plenty of examples.
In part three of the Evennia Beginner tutorial we will go through the actual creation of
our tutorial game _EvAdventure_, based on the [Knave](https://www.drivethrurpg.com/product/250888/Knave)
RPG ruleset.
If you followed the previous parts of this tutorial you will have some notions about Python and where to find
and make use of things in Evennia. We also have a good idea of the type of game we want.
Even if this is not the game-style you are interested in, following along will give you a lot of experience
with using Evennia. This be of much use when doing your own thing later.
This is a big part. You'll be seeing a lot of code and there are plenty of lessons to go through.
Take your time!
If you followed the previous parts of this tutorial you will have some notions about Python and where to
find and make use of things in Evennia. We also have a good idea of the type of game we want.
Even if this is not the game-style you are interested in, following along will give you a lot
of experience with using Evennia. This be _really_ helpful for doing your own thing later.
Fully coded examples of all code we make in this part can be found in the
[evennia/contrib/tutorials/evadventure](evennia.contrib.tutorials.evadventure) package.
## Lessons
_TODO_
```{toctree}
:maxdepth: 1
Implementing-a-game-rule-system
Turn-based-Combat-System
A-Sittable-Object
```
1. [Changing settings](../../../Unimplemented.md)
1. [Applying contribs](../../../Unimplemented.md)
1. [Creating a rule module](../../../Unimplemented.md)
1. [Tweaking the base Typeclasses](../../../Unimplemented.md)
1. [Character creation menu](../../../Unimplemented.md)
1. [Wearing armor and wielding weapons](../../../Unimplemented.md)
1. [Two types of combat](../../../Unimplemented.md)
1. [Monsters and AI](../../../Unimplemented.md)
1. [Questing and rewards](../../../Unimplemented.md)
1. [Overview of Tech demo](../../../Unimplemented.md)
Beginner-Tutorial-Utilities
Beginner-Tutorial-Rule-System
Beginner-Tutorial-Characters
Beginner-Tuturial-Objects
Beginner-Tutorial-Rooms
Beginner-Tutorial-NPCs
Beginner-Tutorial-Turnbased-Combat
Beginner-Tutorial-Quests
Beginner-Tutorial-Shops
Beginner-Tutorial-Dungeon
Beginner-Tutorial-Commands
## Table of Contents
_TODO_
```{toctree}
:maxdepth: 1
Implementing-a-game-rule-system
Turn-Based-Combat-System
A-Sittable-Object
Beginner-Tutorial-Utilities
Beginner-Tutorial-Rule-System
Beginner-Tutorial-Characters
Beginner-Tuturial-Objects
Beginner-Tutorial-Rooms
Beginner-Tutorial-NPCs
Beginner-Tutorial-Turnbased-Combat
Beginner-Tutorial-Quests
Beginner-Tutorial-Shops
Beginner-Tutorial-Dungeon
Beginner-Tutorial-Commands
```

View file

@ -0,0 +1,351 @@
# Code structure and Utilities
In this lesson we will set up the file structure for _EvAdventure_. We will make some
utilities that will be useful later. We will also learn how to write _tests_.
## Folder structure
Create a new folder under your `mygame` folder, named `evadventure`. Inside it, create
another folder `tests/` and make sure to put empty `__init__.py` files in both. This turns both
folders into packages Python understands to import from.
```
mygame/
commands/
evadventure/ <---
__init__.py <---
tests/ <---
__init__.py <---
__init__.py
README.md
server/
typeclasses/
web/
world/
```
Importing anything from inside this folder from anywhere else under `mygame` will be done by
```python
# from anywhere in mygame/
from evadventure.yourmodulename import whatever
```
This is the 'absolute path` type of import.
Between two modules both in `evadventure/`, you can use a 'relative' import with `.`:
```python
# from a module inside mygame/evadventure
from .yourmodulename import whatever
```
From e.g. inside `mygame/evadventure/tests/` you can import from one level above using `..`:
```python
# from mygame/evadventure/tests/
from ..yourmodulename import whatever
```
## Enums
```{sidebar}
A full example of the enum module is found in
[evennia/contrib/tutorials/evadventure/enums.py](evennia.contrib.tutorials.evadventure.enums).
```
Create a new file `mygame/evadventure/enums.py`.
An [enum](https://docs.python.org/3/library/enum.html) (enumeration) is a way to establish constants
in Python. Best is to show an example:
```python
# in a file mygame/evadventure/enums.py
from enum import Enum
class Ability(Enum):
STR = "strength"
```
You access an enum like this:
```
# from another module in mygame/evadventure
from .enums import Ability
Ability.STR # the enum itself
Ability.STR.value # this is the string "strength"
```
Having enums is recommended practice. With them set up, it means we can make sure to refer to the
same thing every time. Having all enums in one place also means you have a good overview of the
constants you are dealing with.
The alternative would be to for example pass around a string `"constitution"`. If you mis-spell
this (`"consitution"`), you would not necessarily know it right away - the error would happen later
when the string is not recognized. If you make a typo getting `Ability.COM` instead of `Ability.CON`,
Python will immediately raise an error since this enum is not recognized.
With enums you can also do nice direct comparisons like `if ability is Ability.WIS: <do stuff>`.
Note that the `Ability.STR` enum does not have the actual _value_ of e.g. your Strength.
It's just a fixed label for the Strength ability.
Here is the `enum.py` module needed for _Knave_. It covers the basic aspects of
rule systems we need to track (check out the _Knave_ rules. If you use another rule system you'll
likely gradually expand on your enums as you figure out what you'll need).
```python
# mygame/evadventure/enums.py
class Ability(Enum):
"""
The six base ability-bonuses and other
abilities
"""
STR = "strength"
DEX = "dexterity"
CON = "constitution"
INT = "intelligence"
WIS = "wisdom"
CHA = "charisma"
ARMOR = "armor"
HP = "hp"
LEVEL = "level"
XP = "xp"
CRITICAL_FAILURE = "critical_failure"
CRITICAL_SUCCESS = "critical_success"
ALLEGIANCE_HOSTILE = "hostile"
ALLEGIANCE_NEUTRAL = "neutral"
ALLEGIANCE_FRIENDLY = "friendly"
class WieldLocation(Enum):
"""
Wield (or wear) locations.
"""
# wield/wear location
BACKPACK = "backpack"
WEAPON_HAND = "weapon_hand"
SHIELD_HAND = "shield_hand"
TWO_HANDS = "two_handed_weapons"
BODY = "body" # armor
HEAD = "head" # helmets
class ObjType(Enum):
"""
Object types.
"""
WEAPON = "weapon"
ARMOR = "armor"
SHIELD = "shield"
HELMET = "helmet"
CONSUMABLE = "consumable"
GEAR = "gear"
MAGIC = "magic"
QUEST = "quest"
TREASURE = "treasure"
```
Here the `Ability` class holds basic properties of a character sheet, while `WieldLocation` tracks
equipment and where a character would wield and wear things - since _Knave_ has these, it makes sense
to track it. Finally we have a set of different `ObjType`s, for differentiate game items. These are
extracted by reading the _Knave_ object lists and figuring out how they should be categorized.
## Utility module
> Create a new module `mygame/evadventure/utils.py`
```{sidebar}
An example of the utility module is found in
[evennia/contrib/tutorials/evadventure/utils.py](evennia.contrib.tutorials.evadventure.utils)
```
This is for general functions we may need from all over. In this case we only picture one utility,
a function that produces a pretty display of any object we pass to it.
This is an example of the string we want to see:
```
Chipped Sword
Value: ~10 coins [wielded in Weapon hand]
A simple sword used by mercenaries all over
the world.
Slots: 1, Used from: weapon hand
Quality: 3, Uses: None
Attacks using strength against armor.
Damage roll: 1d6
```
Here's the start of how the function could look:
```python
# in mygame/evadventure/utils.py
_OBJ_STATS = """
|c{key}|n
Value: ~|y{value}|n coins{carried}
{desc}
Slots: |w{size}|n, Used from: |w{use_slot_name}|n
Quality: |w{quality}|n, Uses: |wuses|n
Attacks using |w{attack_type_name}|n against |w{defense_type_name}|n
Damage roll: |w{damage_roll}|n
""".strip()
def get_obj_stats(obj, owner=None):
"""
Get a string of stats about the object.
Args:
obj (Object): The object to get stats for.
owner (Object): The one currently owning/carrying `obj`, if any. Can be
used to show e.g. where they are wielding it.
Returns:
str: A nice info string to display about the object.
"""
return _OBJ_STATS.format(
key=obj.key,
value=10,
carried="[Not carried]",
desc=obj.db.desc,
size=1,
quality=3,
uses="infinite"
use_slot_name="backpack",
attack_type_name="strength"
defense_type_name="armor"
damage_roll="1d6"
)
```
Here we set up the string template with place holders for where every piece of info should go.
Study this string so you understand what it does. The `|c`, `|y`, `|w` and `|n` markers are
[Evennia color markup](../../../Concepts/Colors.md) for making the text cyan, yellow, white and neutral-color respectively.
We can guess some things, such that `obj.key` is the name of the object, and that `obj.db.desc` will
hold its description (this is how it is in default Evennia).
But so far we have not established how to get any of the other properties like `size` or `attack_type`.
So we just set them to dummy values. We'll need to get back to this when we have more code in place!
## Testing
> create a new module `mygame/evadventure/tests/test_utils.py`
How do you know if you made a typo in the code above? You could _manually_ test it by reloading your
Evennia server and do the following from in-game:
py from evadventure.utils import get_obj_stats;print(get_obj_stats(self))
You should get back a nice string about yourself! If that works, great! But you'll need to remember
doing that test when you change this code later.
```{sidebar}
In [evennia/contrib/evadventure/tests/test_utils.py](evennia.contrib.evadventure.tests.test_utils)
is the final test module. To dive deeper into unit testing in Evennia, see the
[Unit testing](../../../Coding/Unit-Testing.md) documentation.
```
A _unit test_ allows you to set up automated testing of code. Once you've written your test you
can run it over and over and make sure later changes to your code didn't break things.
In this particular case, we _expect_ to later have to update the test when `get_obj_stats` becomes more
complete and returns more reasonable data.
Evennia comes with extensive functionality to help you test your code. Here's a module for
testing `get_obj_stats`.
```python
# mygame/evadventure/tests/test_utils.py
from evennia.utils import create
from evennia.utils.test_resources import BaseEvenniaTest
from ..import utils
class TestUtils(BaseEvenniaTest):
def test_get_obj_stats(self):
# make a simple object to test with
obj = create.create_object(
key="testobj",
attributes=(("desc", "A test object"),)
)
# run it through the function
result = utils.get_obj_stats(obj)
# check that the result is what we expected
self.assertEqual(
result,
"""
|ctestobj|n
Value: ~|y10|n coins
A test object
Slots: |w1|n, Used from: |wbackpack|n
Quality: |w3|n, Uses: |winfinite|n
Attacks using |wstrength|n against |warmor|n
Damage roll: |w1d6|n
""".strip()
)
```
What happens here is that we create a new test-class `TestUtils` that inherits from `BaseEvenniaTest`.
This inheritance is what makes this a testing class.
We can have any number of methods on this class. To have a method recognized as one containing
code to test, its name _must_ start with `test_`. We have one - `test_get_obj_stats`.
In this method we create a dummy `obj` and gives it a `key` "testobj". Note how we add the
`desc` [Attribute](../../../Components/Attributes.md) directly in the `create_object` call by specifying the attribute as a
tuple `(name, value)`!
We then get the result of passing this dummy-object through `get_obj_stats` we imported earlier.
The `assertEqual` method is available on all testing classes and checks that the `result` is equal
to the string we specify. If they are the same, the test _passes_, otherwise it _fails_ and we
need to investigate what went wrong.
### Running your test
To run your test you need to stand inside your `mygame` folder and execute the following command:
evennia test --settings settings.py .evadventure.tests
This will run all your `evadventure` tests (if you had more of them). To only run your utility tests
you could do
evennia test --settings settings.py .evadventure.tests.test_utils
If all goes well, you should get an `OK` back. Otherwise you need to check the failure, maybe
your return string doesn't quite match what you expected.
## Summary
It's very important to understand how you import code between modules in Python, so if this is still
confusing to you, it's worth to read up on this more.
That said, many newcomers are confused with how to begin, so by creating the folder structure, some
small modules and even making your first unit test, you are off to a great start!

View file

@ -1,265 +0,0 @@
# Implementing a game rule system
The simplest way to create an online roleplaying game (at least from a code perspective) is to
simply grab a paperback RPG rule book, get a staff of game masters together and start to run scenes
with whomever logs in. Game masters can roll their dice in front of their computers and tell the
players the results. This is only one step away from a traditional tabletop game and puts heavy
demands on the staff - it is unlikely staff will be able to keep up around the clock even if they
are very dedicated.
Many games, even the most roleplay-dedicated, thus tend to allow for players to mediate themselves
to some extent. A common way to do this is to introduce *coded systems* - that is, to let the
computer do some of the heavy lifting. A basic thing is to add an online dice-roller so everyone can
make rolls and make sure noone is cheating. Somewhere at this level you find the most bare-bones
roleplaying MUSHes.
The advantage of a coded system is that as long as the rules are fair the computer is too - it makes
no judgement calls and holds no personal grudges (and cannot be accused of holding any). Also, the
computer doesn't need to sleep and can always be online regardless of when a player logs on. The
drawback is that a coded system is not flexible and won't adapt to the unprogrammed actions human
players may come up with in role play. For this reason many roleplay-heavy MUDs do a hybrid
variation - they use coded systems for things like combat and skill progression but leave role play
to be mostly freeform, overseen by staff game masters.
Finally, on the other end of the scale are less- or no-roleplay games, where game mechanics (and
thus player fairness) is the most important aspect. In such games the only events with in-game value
are those resulting from code. Such games are very common and include everything from hack-and-slash
MUDs to various tactical simulations.
So your first decision needs to be just what type of system you are aiming for. This page will try
to give some ideas for how to organize the "coded" part of your system, however big that may be.
## Overall system infrastructure
We strongly recommend that you code your rule system as stand-alone as possible. That is, don't
spread your skill check code, race bonus calculation, die modifiers or what have you all over your
game.
- Put everything you would need to look up in a rule book into a module in `mygame/world`. Hide away
as much as you can. Think of it as a black box (or maybe the code representation of an all-knowing
game master). The rest of your game will ask this black box questions and get answers back. Exactly
how it arrives at those results should not need to be known outside the box. Doing it this way
makes it easier to change and update things in one place later.
- Store only the minimum stuff you need with each game object. That is, if your Characters need
values for Health, a list of skills etc, store those things on the Character - don't store how to
roll or change them.
- Next is to determine just how you want to store things on your Objects and Characters. You can
choose to either store things as individual [Attributes](../../../Components/Attributes.md), like `character.db.STR=34` and
`character.db.Hunting_skill=20`. But you could also use some custom storage method, like a
dictionary `character.db.skills = {"Hunting":34, "Fishing":20, ...}`. A much more fancy solution is
to look at the Ainneve [Trait
handler](https://github.com/evennia/ainneve/blob/master/world/traits.py). Finally you could even go
with a [custom django model](../../../Concepts/New-Models.md). Which is the better depends on your game and the
complexity of your system.
- Make a clear [API](https://en.wikipedia.org/wiki/Application_programming_interface) into your
rules. That is, make methods/functions that you feed with, say, your Character and which skill you
want to check. That is, you want something similar to this:
```python
from world import rules
result = rules.roll_skill(character, "hunting")
result = rules.roll_challenge(character1, character2, "swords")
```
You might need to make these functions more or less complex depending on your game. For example the
properties of the room might matter to the outcome of a roll (if the room is dark, burning etc).
Establishing just what you need to send into your game mechanic module is a great way to also get a
feel for what you need to add to your engine.
## Coded systems
Inspired by tabletop role playing games, most game systems mimic some sort of die mechanic. To this
end Evennia offers a full [dice
roller](https://github.com/evennia/evennia/blob/master/evennia/contrib/dice.py) in its `contrib`
folder. For custom implementations, Python offers many ways to randomize a result using its in-built
`random` module. No matter how it's implemented, we will in this text refer to the action of
determining an outcome as a "roll".
In a freeform system, the result of the roll is just compared with values and people (or the game
master) just agree on what it means. In a coded system the result now needs to be processed somehow.
There are many things that may happen as a result of rule enforcement:
- Health may be added or deducted. This can effect the character in various ways.
- Experience may need to be added, and if a level-based system is used, the player might need to be
informed they have increased a level.
- Room-wide effects need to be reported to the room, possibly affecting everyone in the room.
There are also a slew of other things that fall under "Coded systems", including things like
weather, NPC artificial intelligence and game economy. Basically everything about the world that a
Game master would control in a tabletop role playing game can be mimicked to some level by coded
systems.
## Example of Rule module
Here is a simple example of a rule module. This is what we assume about our simple example game:
- Characters have only four numerical values:
- Their `level`, which starts at 1.
- A skill `combat`, which determines how good they are at hitting things. Starts between 5 and
10.
- Their Strength, `STR`, which determine how much damage they do. Starts between 1 and 10.
- Their Health points, `HP`, which starts at 100.
- When a Character reaches `HP = 0`, they are presumed "defeated". Their HP is reset and they get a
failure message (as a stand-in for death code).
- Abilities are stored as simple Attributes on the Character.
- "Rolls" are done by rolling a 100-sided die. If the result is below the `combat` value, it's a
success and damage is rolled. Damage is rolled as a six-sided die + the value of `STR` (for this
example we ignore weapons and assume `STR` is all that matters).
- Every successful `attack` roll gives 1-3 experience points (`XP`). Every time the number of `XP`
reaches `(level + 1) ** 2`, the Character levels up. When leveling up, the Character's `combat`
value goes up by 2 points and `STR` by one (this is a stand-in for a real progression system).
### Character
The Character typeclass is simple. It goes in `mygame/typeclasses/characters.py`. There is already
an empty `Character` class there that Evennia will look to and use.
```python
from random import randint
from evennia import DefaultCharacter
class Character(DefaultCharacter):
"""
Custom rule-restricted character. We randomize
the initial skill and ability values bettween 1-10.
"""
def at_object_creation(self):
"Called only when first created"
self.db.level = 1
self.db.HP = 100
self.db.XP = 0
self.db.STR = randint(1, 10)
self.db.combat = randint(5, 10)
```
`@reload` the server to load up the new code. Doing `examine self` will however *not* show the new
Attributes on yourself. This is because the `at_object_creation` hook is only called on *new*
Characters. Your Character was already created and will thus not have them. To force a reload, use
the following command:
```
@typeclass/force/reset self
```
The `examine self` command will now show the new Attributes.
### Rule module
This is a module `mygame/world/rules.py`.
```python
from random import randint
def roll_hit():
"Roll 1d100"
return randint(1, 100)
def roll_dmg():
"Roll 1d6"
return randint(1, 6)
def check_defeat(character):
"Checks if a character is 'defeated'."
if character.db.HP <= 0:
character.msg("You fall down, defeated!")
character.db.HP = 100 # reset
def add_XP(character, amount):
"Add XP to character, tracking level increases."
character.db.XP += amount
if character.db.XP >= (character.db.level + 1) ** 2:
character.db.level += 1
character.db.STR += 1
character.db.combat += 2
character.msg(f"You are now level {character.db.level}!")
def skill_combat(*args):
"""
This determines outcome of combat. The one who
rolls under their combat skill AND higher than
their opponent's roll hits.
"""
char1, char2 = args
roll1, roll2 = roll_hit(), roll_hit()
failtext_template = "You are hit by {attacker} for {dmg} damage!"
wintext_template = "You hit {target} for {dmg} damage!"
xp_gain = randint(1, 3)
if char1.db.combat >= roll1 > roll2:
# char 1 hits
dmg = roll_dmg() + char1.db.STR
char1.msg(wintext_template.format(target=char2, dmg=dmg))
add_XP(char1, xp_gain)
char2.msg(failtext_template.format(attacker=char1, dmg=dmg))
char2.db.HP -= dmg
check_defeat(char2)
elif char2.db.combat >= roll2 > roll1:
# char 2 hits
dmg = roll_dmg() + char2.db.STR
char1.msg(failtext_template.format(attacker=char2, dmg=dmg))
char1.db.HP -= dmg
check_defeat(char1)
char2.msg(wintext_template.format(target=char1, dmg=dmg))
add_XP(char2, xp_gain)
else:
# a draw
drawtext = "Neither of you can find an opening."
char1.msg(drawtext)
char2.msg(drawtext)
SKILLS = {"combat": skill_combat}
def roll_challenge(character1, character2, skillname):
"""
Determine the outcome of a skill challenge between
two characters based on the skillname given.
"""
if skillname in SKILLS:
SKILLS[skillname](character1, character2)
else:
raise RunTimeError(f"Skillname {skillname} not found.")
```
These few functions implement the entirety of our simple rule system. We have a function to check
the "defeat" condition and reset the `HP` back to 100 again. We define a generic "skill" function.
Multiple skills could all be added with the same signature; our `SKILLS` dictionary makes it easy to
look up the skills regardless of what their actual functions are called. Finally, the access
function `roll_challenge` just picks the skill and gets the result.
In this example, the skill function actually does a lot - it not only rolls results, it also informs
everyone of their results via `character.msg()` calls.
Here is an example of usage in a game command:
```python
from evennia import Command
from world import rules
class CmdAttack(Command):
"""
attack an opponent
Usage:
attack <target>
This will attack a target in the same room, dealing
damage with your bare hands.
"""
def func(self):
"Implementing combat"
caller = self.caller
if not self.args:
caller.msg("You need to pick a target to attack.")
return
target = caller.search(self.args)
if target:
rules.roll_challenge(caller, target, "combat")
```
Note how simple the command becomes and how generic you can make it. It becomes simple to offer any
number of Combat commands by just extending this functionality - you can easily roll challenges and
pick different skills to check. And if you ever decided to, say, change how to determine hit chance,
you don't have to change every command, but need only change the single `roll_hit` function inside
your `rules` module.

View file

@ -1,521 +0,0 @@
# Turn based Combat System
This tutorial gives an example of a full, if simplified, combat system for Evennia. It was inspired
by the discussions held on the [mailing
list](https://groups.google.com/forum/#!msg/evennia/wnJNM2sXSfs/-dbLRrgWnYMJ).
## Overview of combat system concepts
Most MUDs will use some sort of combat system. There are several main variations:
- _Freeform_ - the simplest form of combat to implement, common to MUSH-style roleplaying games.
This means the system only supplies dice rollers or maybe commands to compare skills and spit out
the result. Dice rolls are done to resolve combat according to the rules of the game and to direct
the scene. A game master may be required to resolve rule disputes.
- _Twitch_ - This is the traditional MUD hack&slash style combat. In a twitch system there is often
no difference between your normal "move-around-and-explore mode" and the "combat mode". You enter an
attack command and the system will calculate if the attack hits and how much damage was caused.
Normally attack commands have some sort of timeout or notion of recovery/balance to reduce the
advantage of spamming or client scripting. Whereas the simplest systems just means entering `kill
<target>` over and over, more sophisticated twitch systems include anything from defensive stances
to tactical positioning.
- _Turn-based_ - a turn based system means that the system pauses to make sure all combatants can
choose their actions before continuing. In some systems, such entered actions happen immediately
(like twitch-based) whereas in others the resolution happens simultaneously at the end of the turn.
The disadvantage of a turn-based system is that the game must switch to a "combat mode" and one also
needs to take special care of how to handle new combatants and the passage of time. The advantage is
that success is not dependent on typing speed or of setting up quick client macros. This potentially
allows for emoting as part of combat which is an advantage for roleplay-heavy games.
To implement a freeform combat system all you need is a dice roller and a roleplaying rulebook. See
[contrib/dice.py](https://github.com/evennia/evennia/blob/master/evennia/contrib/dice.py) for an
example dice roller. To implement at twitch-based system you basically need a few combat
[commands](../../../Components/Commands.md), possibly ones with a [cooldown](../../Command-Cooldown.md). You also need a [game rule
module](./Implementing-a-game-rule-system.md) that makes use of it. We will focus on the turn-based
variety here.
## Tutorial overview
This tutorial will implement the slightly more complex turn-based combat system. Our example has the
following properties:
- Combat is initiated with `attack <target>`, this initiates the combat mode.
- Characters may join an ongoing battle using `attack <target>` against a character already in
combat.
- Each turn every combating character will get to enter two commands, their internal order matters
and they are compared one-to-one in the order given by each combatant. Use of `say` and `pose` is
free.
- The commands are (in our example) simple; they can either `hit <target>`, `feint <target>` or
`parry <target>`. They can also `defend`, a generic passive defense. Finally they may choose to
`disengage/flee`.
- When attacking we use a classic [rock-paper-scissors](https://en.wikipedia.org/wiki/Rock-paper-
scissors) mechanic to determine success: `hit` defeats `feint`, which defeats `parry` which defeats
`hit`. `defend` is a general passive action that has a percentage chance to win against `hit`
(only).
- `disengage/flee` must be entered two times in a row and will only succeed if there is no `hit`
against them in that time. If so they will leave combat mode.
- Once every player has entered two commands, all commands are resolved in order and the result is
reported. A new turn then begins.
- If players are too slow the turn will time out and any unset commands will be set to `defend`.
For creating the combat system we will need the following components:
- A combat handler. This is the main mechanic of the system. This is a [Script](../../../Components/Scripts.md) object
created for each combat. It is not assigned to a specific object but is shared by the combating
characters and handles all the combat information. Since Scripts are database entities it also means
that the combat will not be affected by a server reload.
- A combat [command set](../../../Components/Command-Sets.md) with the relevant commands needed for combat, such as the
various attack/defend options and the `flee/disengage` command to leave the combat mode.
- A rule resolution system. The basics of making such a module is described in the [rule system
tutorial](./Implementing-a-game-rule-system.md). We will only sketch such a module here for our end-turn
combat resolution.
- An `attack` [command](../../../Components/Commands.md) for initiating the combat mode. This is added to the default
command set. It will create the combat handler and add the character(s) to it. It will also assign
the combat command set to the characters.
## The combat handler
The _combat handler_ is implemented as a stand-alone [Script](../../../Components/Scripts.md). This Script is created when
the first Character decides to attack another and is deleted when no one is fighting any more. Each
handler represents one instance of combat and one combat only. Each instance of combat can hold any
number of characters but each character can only be part of one combat at a time (a player would
need to disengage from the first combat before they could join another).
The reason we don't store this Script "on" any specific character is because any character may leave
the combat at any time. Instead the script holds references to all characters involved in the
combat. Vice-versa, all characters holds a back-reference to the current combat handler. While we
don't use this very much here this might allow the combat commands on the characters to access and
update the combat handler state directly.
_Note: Another way to implement a combat handler would be to use a normal Python object and handle
time-keeping with the [TickerHandler](../../../Components/TickerHandler.md). This would require either adding custom hook
methods on the character or to implement a custom child of the TickerHandler class to track turns.
Whereas the TickerHandler is easy to use, a Script offers more power in this case._
Here is a basic combat handler. Assuming our game folder is named `mygame`, we store it in
`mygame/typeclasses/combat_handler.py`:
```python
# mygame/typeclasses/combat_handler.py
import random
from evennia import DefaultScript
from world.rules import resolve_combat
class CombatHandler(DefaultScript):
"""
This implements the combat handler.
"""
# standard Script hooks
def at_script_creation(self):
"Called when script is first created"
self.key = f"combat_handler_{random.randint(1, 1000)}"
self.desc = "handles combat"
self.interval = 60 * 2 # two minute timeout
self.start_delay = True
self.persistent = True
# store all combatants
self.db.characters = {}
# store all actions for each turn
self.db.turn_actions = {}
# number of actions entered per combatant
self.db.action_count = {}
def _init_character(self, character):
"""
This initializes handler back-reference
and combat cmdset on a character
"""
character.ndb.combat_handler = self
character.cmdset.add("commands.combat.CombatCmdSet")
def _cleanup_character(self, character):
"""
Remove character from handler and clean
it of the back-reference and cmdset
"""
dbref = character.id
del self.db.characters[dbref]
del self.db.turn_actions[dbref]
del self.db.action_count[dbref]
del character.ndb.combat_handler
character.cmdset.delete("commands.combat.CombatCmdSet")
def at_start(self):
"""
This is called on first start but also when the script is restarted
after a server reboot. We need to re-assign this combat handler to
all characters as well as re-assign the cmdset.
"""
for character in self.db.characters.values():
self._init_character(character)
def at_stop(self):
"Called just before the script is stopped/destroyed."
for character in list(self.db.characters.values()):
# note: the list() call above disconnects list from database
self._cleanup_character(character)
def at_repeat(self):
"""
This is called every self.interval seconds (turn timeout) or
when force_repeat is called (because everyone has entered their
commands). We know this by checking the existence of the
`normal_turn_end` NAttribute, set just before calling
force_repeat.
"""
if self.ndb.normal_turn_end:
# we get here because the turn ended normally
# (force_repeat was called) - no msg output
del self.ndb.normal_turn_end
else:
# turn timeout
self.msg_all("Turn timer timed out. Continuing.")
self.end_turn()
# Combat-handler methods
def add_character(self, character):
"Add combatant to handler"
dbref = character.id
self.db.characters[dbref] = character
self.db.action_count[dbref] = 0
self.db.turn_actions[dbref] = [("defend", character, None),
("defend", character, None)]
# set up back-reference
self._init_character(character)
def remove_character(self, character):
"Remove combatant from handler"
if character.id in self.db.characters:
self._cleanup_character(character)
if not self.db.characters:
# if no more characters in battle, kill this handler
self.stop()
def msg_all(self, message):
"Send message to all combatants"
for character in self.db.characters.values():
character.msg(message)
def add_action(self, action, character, target):
"""
Called by combat commands to register an action with the handler.
action - string identifying the action, like "hit" or "parry"
character - the character performing the action
target - the target character or None
actions are stored in a dictionary keyed to each character, each
of which holds a list of max 2 actions. An action is stored as
a tuple (character, action, target).
"""
dbref = character.id
count = self.db.action_count[dbref]
if 0 <= count <= 1: # only allow 2 actions
self.db.turn_actions[dbref][count] = (action, character, target)
else:
# report if we already used too many actions
return False
self.db.action_count[dbref] += 1
return True
def check_end_turn(self):
"""
Called by the command to eventually trigger
the resolution of the turn. We check if everyone
has added all their actions; if so we call force the
script to repeat immediately (which will call
`self.at_repeat()` while resetting all timers).
"""
if all(count > 1 for count in self.db.action_count.values()):
self.ndb.normal_turn_end = True
self.force_repeat()
def end_turn(self):
"""
This resolves all actions by calling the rules module.
It then resets everything and starts the next turn. It
is called by at_repeat().
"""
resolve_combat(self, self.db.turn_actions)
if len(self.db.characters) < 2:
# less than 2 characters in battle, kill this handler
self.msg_all("Combat has ended")
self.stop()
else:
# reset counters before next turn
for character in self.db.characters.values():
self.db.characters[character.id] = character
self.db.action_count[character.id] = 0
self.db.turn_actions[character.id] = [("defend", character, None),
("defend", character, None)]
self.msg_all("Next turn begins ...")
```
This implements all the useful properties of our combat handler. This Script will survive a reboot
and will automatically re-assert itself when it comes back online. Even the current state of the
combat should be unaffected since it is saved in Attributes at every turn. An important part to note
is the use of the Script's standard `at_repeat` hook and the `force_repeat` method to end each turn.
This allows for everything to go through the same mechanisms with minimal repetition of code.
What is not present in this handler is a way for players to view the actions they set or to change
their actions once they have been added (but before the last one has added theirs). We leave this as
an exercise.
## Combat commands
Our combat commands - the commands that are to be available to us during the combat - are (in our
example) very simple. In a full implementation the commands available might be determined by the
weapon(s) held by the player or by which skills they know.
We create them in `mygame/commands/combat.py`.
```python
# mygame/commands/combat.py
from evennia import Command
class CmdHit(Command):
"""
hit an enemy
Usage:
hit <target>
Strikes the given enemy with your current weapon.
"""
key = "hit"
aliases = ["strike", "slash"]
help_category = "combat"
def func(self):
"Implements the command"
if not self.args:
self.caller.msg("Usage: hit <target>")
return
target = self.caller.search(self.args)
if not target:
return
ok = self.caller.ndb.combat_handler.add_action("hit",
self.caller,
target)
if ok:
self.caller.msg("You add 'hit' to the combat queue")
else:
self.caller.msg("You can only queue two actions per turn!")
# tell the handler to check if turn is over
self.caller.ndb.combat_handler.check_end_turn()
```
The other commands `CmdParry`, `CmdFeint`, `CmdDefend` and `CmdDisengage` look basically the same.
We should also add a custom `help` command to list all the available combat commands and what they
do.
We just need to put them all in a cmdset. We do this at the end of the same module:
```python
# mygame/commands/combat.py
from evennia import CmdSet
from evennia import default_cmds
class CombatCmdSet(CmdSet):
key = "combat_cmdset"
mergetype = "Replace"
priority = 10
no_exits = True
def at_cmdset_creation(self):
self.add(CmdHit())
self.add(CmdParry())
self.add(CmdFeint())
self.add(CmdDefend())
self.add(CmdDisengage())
self.add(CmdHelp())
self.add(default_cmds.CmdPose())
self.add(default_cmds.CmdSay())
```
## Rules module
A general way to implement a rule module is found in the [rule system tutorial](Implementing-a-game-
rule-system). Proper resolution would likely require us to change our Characters to store things
like strength, weapon skills and so on. So for this example we will settle for a very simplistic
rock-paper-scissors kind of setup with some randomness thrown in. We will not deal with damage here
but just announce the results of each turn. In a real system the Character objects would hold stats
to affect their skills, their chosen weapon affect the choices, they would be able to lose health
etc.
Within each turn, there are "sub-turns", each consisting of one action per character. The actions
within each sub-turn happens simultaneously and only once they have all been resolved we move on to
the next sub-turn (or end the full turn).
*Note: In our simple example the sub-turns don't affect each other (except for `disengage/flee`),
nor do any effects carry over between turns. The real power of a turn-based system would be to add
real tactical possibilities here though; For example if your hit got parried you could be out of
balance and your next action would be at a disadvantage. A successful feint would open up for a
subsequent attack and so on ...*
Our rock-paper-scissor setup works like this:
- `hit` beats `feint` and `flee/disengage`. It has a random chance to fail against `defend`.
- `parry` beats `hit`.
- `feint` beats `parry` and is then counted as a `hit`.
- `defend` does nothing but has a chance to beat `hit`.
- `flee/disengage` must succeed two times in a row (i.e. not beaten by a `hit` once during the
turn). If so the character leaves combat.
```python
# mygame/world/rules.py
import random
# messages
def resolve_combat(combat_handler, actiondict):
"""
This is called by the combat handler
actiondict is a dictionary with a list of two actions
for each character:
{char.id:[(action1, char, target), (action2, char, target)], ...}
"""
flee = {} # track number of flee commands per character
for isub in range(2):
# loop over sub-turns
messages = []
for subturn in (sub[isub] for sub in actiondict.values()):
# for each character, resolve the sub-turn
action, char, target = subturn
if target:
taction, tchar, ttarget = actiondict[target.id][isub]
if action == "hit":
if taction == "parry" and ttarget == char:
messages.append(
f"{char} tries to hit {tchar}, but {tchar} parries the attack!"
)
elif taction == "defend" and random.random() < 0.5:
messages.append(
f"{tchar} defends against the attack by {char}."
)
elif taction == "flee":
flee[tchar] = -2
messages.append(
f"{char} stops {tchar} from disengaging, with a hit!"
)
else:
messages.append(
f"{char} hits {tchar}, bypassing their {taction}!"
)
elif action == "parry":
if taction == "hit":
messages.append(f"{char} parries the attack by {tchar}.")
elif taction == "feint":
messages.append(
f"{char} tries to parry, but {tchar} feints and hits!"
)
else:
messages.append(f"{char} parries to no avail.")
elif action == "feint":
if taction == "parry":
messages.append(
f"{char} feints past {tchar}'s parry, landing a hit!"
)
elif taction == "hit":
messages.append(f"{char} feints but is defeated by {tchar}'s hit!")
else:
messages.append(f"{char} feints to no avail.")
elif action == "defend":
messages.append(f"{char} defends.")
elif action == "flee":
if char in flee:
flee[char] += 1
else:
flee[char] = 1
messages.append(
f"{char} tries to disengage (two subsequent turns needed)"
)
# echo results of each subturn
combat_handler.msg_all("\n".join(messages))
# at the end of both sub-turns, test if anyone fled
for (char, fleevalue) in flee.items():
if fleevalue == 2:
combat_handler.msg_all(f"{char} withdraws from combat.")
combat_handler.remove_character(char)
```
To make it simple (and to save space), this example rule module actually resolves each interchange
twice - first when it gets to each character and then again when handling the target. Also, since we
use the combat handler's `msg_all` method here, the system will get pretty spammy. To clean it up,
one could imagine tracking all the possible interactions to make sure each pair is only handled and
reported once.
## Combat initiator command
This is the last component we need, a command to initiate combat. This will tie everything together.
We store this with the other combat commands.
```python
# mygame/commands/combat.py
from evennia import create_script
class CmdAttack(Command):
"""
initiates combat
Usage:
attack <target>
This will initiate combat with <target>. If <target is
already in combat, you will join the combat.
"""
key = "attack"
help_category = "General"
def func(self):
"Handle command"
if not self.args:
self.caller.msg("Usage: attack <target>")
return
target = self.caller.search(self.args)
if not target:
return
# set up combat
if target.ndb.combat_handler:
# target is already in combat - join it
target.ndb.combat_handler.add_character(self.caller)
target.ndb.combat_handler.msg_all(f"{self.caller} joins combat!")
else:
# create a new combat handler
chandler = create_script("combat_handler.CombatHandler")
chandler.add_character(self.caller)
chandler.add_character(target)
self.caller.msg(f"You attack {target}! You are in combat.")
target.msg(f"{self.caller} attacks you! You are in combat.")
```
The `attack` command will not go into the combat cmdset but rather into the default cmdset. See e.g.
the [Adding Command Tutorial](../Part1/Adding-Commands.md) if you are unsure about how to do this.
## Expanding the example
At this point you should have a simple but flexible turn-based combat system. We have taken several
shortcuts and simplifications in this example. The output to the players is likely too verbose
during combat and too limited when it comes to informing about things surrounding it. Methods for
changing your commands or list them, view who is in combat etc is likely needed - this will require
play testing for each game and style. There is also currently no information displayed for other
people happening to be in the same room as the combat - some less detailed information should
probably be echoed to the room to
show others what's going on.