Updated HTML docs.

This commit is contained in:
Griatch 2022-11-15 19:43:25 +00:00
parent 59e50f3fa5
commit 06bc3c8bcd
663 changed files with 2 additions and 61705 deletions

View file

@ -1,414 +0,0 @@
# Player Characters
In the [previous lesson about rules and dice rolling](./Beginner-Tutorial-Rules.md) we made some
assumptions about the "Player Character" entity:
- It should store Abilities on itself as `character.strength`, `character.constitution` etc.
- It should have a `.heal(amount)` method.
So we have some guidelines of how it should look! A Character is a database entity with values that
should be able to be changed over time. It makes sense to base it off Evennia's
[DefaultCharacter Typeclass](../../../Components/Typeclasses.md). The Character class is like a 'character sheet' in a tabletop
RPG, it will hold everything relevant to that PC.
## Inheritance structure
Player Characters (PCs) are not the only "living" things in our world. We also have _NPCs_
(like shopkeepers and other friendlies) as well as _monsters_ (mobs) that can attack us.
In code, there are a few ways we could structure this. If NPCs/monsters were just special cases of PCs,
we could use a class inheritance like this:
```python
from evennia import DefaultCharacter
class EvAdventureCharacter(DefaultCharacter):
# stuff
class EvAdventureNPC(EvAdventureCharacter):
# more stuff
class EvAdventureMob(EvAdventureNPC):
# more stuff
```
All code we put on the `Character` class would now be inherited to `NPC` and `Mob` automatically.
However, in _Knave_, NPCs and particularly monsters are _not_ using the same rules as PCs - they are
simplified to use a Hit-Die (HD) concept. So while still character-like, NPCs should be separate from
PCs like this:
```python
from evennia import DefaultCharacter
class EvAdventureCharacter(DefaultCharacter):
# stuff
class EvAdventureNPC(DefaultCharacter):
# separate stuff
class EvAdventureMob(EvadventureNPC):
# more separate stuff
```
Nevertheless, there are some things that _should_ be common for all 'living things':
- All can take damage.
- All can die.
- All can heal
- All can hold and lose coins
- All can loot their fallen foes.
- All can get looted when defeated.
We don't want to code this separately for every class but we no longer have a common parent
class to put it on. So instead we'll use the concept of a _mixin_ class:
```python
from evennia import DefaultCharacter
class LivingMixin:
# stuff common for all living things
class EvAdventureCharacter(LivingMixin, DefaultCharacter):
# stuff
class EvAdventureNPC(LivingMixin, DefaultCharacter):
# stuff
class EvAdventureMob(LivingMixin, EvadventureNPC):
# more stuff
```
```{sidebar}
In [evennia/contrib/tutorials/evadventure/characters.py](../../../api/evennia.contrib.tutorials.evadventure.characters.md)
is an example of a character class structure.
```
Above, the `LivingMixin` class cannot work on its own - it just 'patches' the other classes with some
extra functionality all living things should be able to do. This is an example of
_multiple inheritance_. It's useful to know about, but one should not over-do multiple inheritance
since it can also get confusing to follow the code.
## Living mixin class
> Create a new module `mygame/evadventure/characters.py`
Let's get some useful common methods all living things should have in our game.
```python
# in mygame/evadventure/characters.py
from .rules import dice
class LivingMixin:
# makes it easy for mobs to know to attack PCs
is_pc = False
def heal(self, hp):
"""
Heal hp amount of health, not allowing to exceed our max hp
"""
damage = self.hp_max - self.hp
healed = min(damage, hp)
self.hp += healed
self.msg("You heal for {healed} HP.")
def at_pay(self, amount):
"""When paying coins, make sure to never detract more than we have"""
amount = min(amount, self.coins)
self.coins -= amount
return amount
def at_damage(self, damage, attacker=None):
"""Called when attacked and taking damage."""
self.hp -= damage
def at_defeat(self):
"""Called when defeated. By default this means death."""
self.at_death()
def at_death(self):
"""Called when this thing dies."""
# this will mean different things for different living things
pass
def at_do_loot(self, looted):
"""Called when looting another entity"""
looted.at_looted(self)
def at_looted(self, looter):
"""Called when looted by another entity"""
# default to stealing some coins
max_steal = dice.roll("1d10")
stolen = self.at_pay(max_steal)
looter.coins += stolen
```
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.
## Character class
We will now start making the basic Character class, based on what we need from _Knave_.
```python
# in mygame/evadventure/characters.py
from evennia import DefaultCharacter, AttributeProperty
from .rules import dice
class LivingMixin:
# ...
class EvAdventureCharacter(LivingMixin, DefaultCharacter):
"""
A character to use for EvAdventure.
"""
is_pc = True
strength = AttributeProperty(1)
dexterity = AttributeProperty(1)
constitution = AttributeProperty(1)
intelligence = AttributeProperty(1)
wisdom = AttributeProperty(1)
charisma = AttributeProperty(1)
hp = AttributeProperty(8)
hp_max = AttributeProperty(8)
level = AttributeProperty(1)
xp = AttributeProperty(0)
coins = AttributeProperty(0)
def at_defeat(self):
"""Characters roll on the death table"""
if self.location.allow_death:
# this allow rooms to have non-lethal battles
dice.roll_death(self)
else:
self.location.msg_contents(
"$You() $conj(collapse) in a heap, alive but beaten.",
from_obj=self)
self.heal(self.hp_max)
def at_death(self):
"""We rolled 'dead' on the death table."""
self.location.msg_contents(
"$You() collapse in a heap, embraced by death.",
from_obj=self)
# TODO - go back into chargen to make a new character!
```
We make an assumption about our rooms here - that they have a property `.allow_death`. We need
to make a note to actually add such a property to rooms later!
In our `Character` class we implement all attributes we want to simulate from the _Knave_ ruleset.
The `AttributeProperty` is one way to add an Attribute in a field-like way; these will be accessible
on every character in several ways:
- As `character.strength`
- As `character.db.strength`
- As `character.attributes.get("strength")`
See [Attributes](../../../Components/Attributes.md) for seeing how Attributes work.
Unlike in base _Knave_, we store `coins` as a separate Attribute rather than as items in the inventory,
this makes it easier to handle barter and trading later.
We implement the Player Character versions of `at_defeat` and `at_death`. We also make use of `.heal()`
from the `LivingMixin` class.
### Funcparser inlines
This piece of code is worth some more explanation:
```python
self.location.msg_contents(
"$You() $conj(collapse) in a heap, alive but beaten.",
from_obj=self)
```
Remember that `self` is the Character instance here. So `self.location.msg_contents` means "send a
message to everything inside my current location". In other words, send a message to everyone
in the same place as the character.
The `$You() $conj(collapse)` are [FuncParser inlines](../../../Components/FuncParser.md). These are functions that
execute
in the string. The resulting string may look different for different audiences. The `$You()` inline
function will use `from_obj` to figure out who 'you' are and either show your name or 'You'.
The `$conj()` (verb conjugator) will tweak the (English) verb to match.
- You will see: `"You collapse in a heap, alive but beaten."`
- Others in the room will see: `"Thomas collapses in a heap, alive but beaten."`
Note how `$conj()` chose `collapse/collapses` to make the sentences grammatically correct.
### Backtracking
We make our first use of the `rules.dice` roller to roll on the death table! As you may recall, in the
previous lesson, we didn't know just what to do when rolling 'dead' on this table. Now we know - we
should be calling `at_death` on the character. So let's add that where we had TODOs before:
```python
# mygame/evadventure/rules.py
class EvAdventureRollEngine:
# ...
def roll_death(self, character):
ability_name = self.roll_random_table("1d8", death_table)
if ability_name == "dead":
# kill the character!
character.at_death() # <------ TODO no more
else:
# ...
if current_ability < -10:
# kill the character!
character.at_death() # <------- TODO no more
else:
# ...
```
## Connecting the Character with Evennia
You can easily make yourself an `EvAdventureCharacter` in-game by using the
`type` command:
type self = evadventure.characters.EvAdventureCharacter
You can now do `examine self` to check your type updated.
If you want _all_ new Characters to be of this type you need to tell Evennia about it. Evennia
uses a global setting `BASE_CHARACTER_TYPECLASS` to know which typeclass to use when creating
Characters (when logging in, for example). This defaults to `typeclasses.characters.Character` (that is,
the `Character` class in `mygame/typeclasses/characters.py`).
There are thus two ways to weave your new Character class into Evennia:
1. Change `mygame/server/conf/settings.py` and add `BASE_CHARACTER_CLASS = "evadventure.characters.EvAdventureCharacter"`.
2. Or, change `typeclasses.characters.Character` to inherit from `EvAdventureCharacter`.
You must always reload the server for changes like this to take effect.
```{important}
In this tutorial we are making all changes in a folder `mygame/evadventure/`. This means we can isolate
our code but means we need to do some extra steps to tie the character (and other objects) into Evennia.
For your own game it would be just fine to start editing `mygame/typeclasses/characters.py` directly
instead.
```
## Unit Testing
> Create a new module `mygame/evadventure/tests/test_characters.py`
For testing, we just need to create a new EvAdventure character and check
that calling the methods on it doesn't error out.
```python
# mygame/evadventure/tests/test_characters.py
from evennia.utils import create
from evennia.utils.test_resources import BaseEvenniaTest
from ..characters import EvAdventureCharacter
class TestCharacters(BaseEvenniaTest):
def setUp(self):
super().setUp()
self.character = create.create_object(EvAdventureCharacter, key="testchar")
def test_heal(self):
self.character.hp = 0
self.character.hp_max = 8
self.character.heal(1)
self.assertEqual(self.character.hp, 1)
# make sure we can't heal more than max
self.character.heal(100)
self.assertEqual(self.character.hp, 8)
def test_at_pay(self):
self.character.coins = 100
result = self.character.at_pay(60)
self.assertEqual(result, 60)
self.assertEqual(self.character.coins, 40)
# can't get more coins than we have
result = self.character.at_pay(100)
self.assertEqual(result, 40)
self.assertEqual(self.character.coins, 0)
# tests for other methods ...
```
If you followed the previous lessons, these tests should look familiar. Consider adding
tests for other methods as practice. Refer to previous lessons for details.
For running the tests you do:
evennia test --settings settings.py .evadventure.tests.test_character
## About races and classes
_Knave_ doesn't have any D&D-style _classes_ (like Thief, Fighter etc). It also does not bother with
_races_ (like dwarves, elves etc). This makes the tutorial shorter, but you may ask yourself how you'd
add these functions.
In the framework we have sketched out for _Knave_, it would be simple - you'd add your race/class as
an Attribute on your Character:
```python
# mygame/evadventure/characters.py
from evennia import DefaultCharacter, AttributeProperty
# ...
class EvAdventureCharacter(LivingMixin, DefaultCharacter):
# ...
charclass = AttributeProperty("Fighter")
charrace = AttributeProperty("Human")
```
We use `charclass` rather than `class` here, because `class` is a reserved Python keyword. Naming
`race` as `charrace` thus matches in style.
We'd then need to expand our [rules module](./Beginner-Tutorial-Rules.md) (and later
[character generation](./Beginner-Tutorial-Chargen.md) to check and include what these classes mean.
## Summary
With the `EvAdventureCharacter` class in place, we have a better understanding of how our PCs will look
like under _Knave_.
For now, we only have bits and pieces and haven't been testing this code in-game. But if you want
you can swap yourself into `EvAdventureCharacter` right now. Log into your game and run
the command
type self = evadventure.characters.EvAdventureCharacter
If all went well, `ex self` will now show your typeclass as being `EvAdventureCharacter`.
Check out your strength with
py self.strength = 3
```{important}
When doing `ex self` you will _not_ see all your Abilities listed yet. That's because
Attributes added with `AttributeProperty` are not available until they have been accessed at
least once. So once you set (or look at) `.strength` above, `strength` will show in `examine` from
then on.
```

View file

@ -1,711 +0,0 @@
# Character Generation
In previous lessons we have established how a character looks. Now we need to give the player a
chance to create one.
## How it will work
A fresh Evennia install will automatically create a new Character with the same name as your
Account when you log in. This is quick and simple and mimics older MUD styles. You could picture
doing this, and then customizing the Character in-place.
We will be a little more sophisticated though. We want the user to be able to create a character
using a menu when they log in.
We do this by editing `mygame/server/conf/settings.py` and adding the line
AUTO_CREATE_CHARACTER_WITH_ACCOUNT = False
When doing this, connecting with the game with a new account will land you in "OOC" mode. The
ooc-version of `look` (sitting in the Account cmdset) will show a list of available characters
if you have any. You can also enter `charcreate` to make a new character. The `charcreate` is a
simple command coming with Evennia that just lets you make a new character with a given name and
description. We will later modify that to kick off our chargen. For now we'll just keep in mind
that's how we'll start off the menu.
In _Knave_, most of the character-generation is random. This means this tutorial can be pretty
compact while still showing the basic idea. What we will create is a menu looking like this:
```
Silas
STR +1
DEX +2
CON +1
INT +3
WIS +1
CHA +2
You are lanky with a sunken face and filthy hair, breathy speech, and foreign clothing.
You were a herbalist, but you were pursued and ended up a knave. You are honest but also
suspicious. You are of the neutral alignment.
Your belongings:
Brigandine armor, ration, ration, sword, torch, torch, torch, torch, torch,
tinderbox, chisel, whistle
----------------------------------------------------------------------------------------
1. Change your name
2. Swap two of your ability scores (once)
3. Accept and create character
```
If you select 1, you get a new menu node:
```
Your current name is Silas. Enter a new name or leave empty to abort.
-----------------------------------------------------------------------------------------
```
You can now enter a new name. When pressing return you'll get back to the first menu node
showing your character, now with the new name.
If you select 2, you go to another menu node:
```
Your current abilities:
STR +1
DEX +2
CON +1
INT +3
WIS +1
CHA +2
You can swap the values of two abilities around.
You can only do this once, so choose carefully!
To swap the values of e.g. STR and INT, write 'STR INT'. Empty to abort.
------------------------------------------------------------------------------------------
```
If you enter `WIS CHA` here, WIS will become `+2` and `CHA` `+1`. You will then again go back
to the main node to see your new character, but this time the option to swap will no longer be
available (you can only do it once).
If you finally select the `Accept and create character` option, the character will be created
and you'll leave the menu;
Character was created!
## Random tables
```{sidebar}
Full Knave random tables are found in
[evennia/contrib/tutorials/evadventure/random_tables.py](../../../api/evennia.contrib.tutorials.evadventure.random_tables.md).
```
> Make a new module `mygame/evadventure/random_tables.py`.
Since most of _Knave_'s character generation is random we will need to roll on random tables
from the _Knave_ rulebook. While we added the ability to roll on a random table back in the
[Rules Tutorial](./Beginner-Tutorial-Rules.md), we haven't added the relevant tables yet.
```
# in mygame/evadventure/random_tables.py
chargen_tables = {
"physique": [
"athletic", "brawny", "corpulent", "delicate", "gaunt", "hulking", "lanky",
"ripped", "rugged", "scrawny", "short", "sinewy", "slender", "flabby",
"statuesque", "stout", "tiny", "towering", "willowy", "wiry",
],
"face": [
"bloated", "blunt", "bony", # ...
], # ...
}
```
The tables are just copied from the _Knave_ rules. We group the aspects in a dict
`character_generation` to separate chargen-only tables from other random tables we'll also
keep in here.
## Storing state of the menu
```{sidebar}
There is a full implementation of the chargen in
[evennia/contrib/tutorials/evadventure/chargen.py](../../../api/evennia.contrib.tutorials.evadventure.chargen.md).
```
> create a new module `mygame/evadventure/chargen.py`.
During character generation we will need an entity to store/retain the changes, like a
'temporary character sheet'.
```python
# in mygame/evadventure/chargen.py
from .random_tables import chargen_tables
from .rules import dice
class TemporaryCharacterSheet:
def _random_ability(self):
return min(dice.roll("1d6"), dice.roll("1d6"), dice.roll("1d6"))
def __init__(self):
self.ability_changes = 0 # how many times we tried swap abilities
# name will likely be modified later
self.name = dice.roll_random_table("1d282", chargen_tables["name"])
# base attribute values
self.strength = self._random_ability()
self.dexterity = self._random_ability()
self.constitution = self._random_ability()
self.intelligence = self._random_ability()
self.wisdom = self._random_ability()
self.charisma = self._random_ability()
# physical attributes (only for rp purposes)
physique = dice.roll_random_table("1d20", chargen_tables["physique"])
face = dice.roll_random_table("1d20", chargen_tables["face"])
skin = dice.roll_random_table("1d20", chargen_tables["skin"])
hair = dice.roll_random_table("1d20", chargen_tables["hair"])
clothing = dice.roll_random_table("1d20", chargen_tables["clothing"])
speech = dice.roll_random_table("1d20", chargen_tables["speech"])
virtue = dice.roll_random_table("1d20", chargen_tables["virtue"])
vice = dice.roll_random_table("1d20", chargen_tables["vice"])
background = dice.roll_random_table("1d20", chargen_tables["background"])
misfortune = dice.roll_random_table("1d20", chargen_tables["misfortune"])
alignment = dice.roll_random_table("1d20", chargen_tables["alignment"])
self.desc = (
f"You are {physique} with a {face} face, {skin} skin, {hair} hair, {speech} speech,"
f" and {clothing} clothing. You were a {background.title()}, but you were"
f" {misfortune} and ended up a knave. You are {virtue} but also {vice}. You are of the"
f" {alignment} alignment."
)
#
self.hp_max = max(5, dice.roll("1d8"))
self.hp = self.hp_max
self.xp = 0
self.level = 1
# random equipment
self.armor = dice.roll_random_table("1d20", chargen_tables["armor"])
_helmet_and_shield = dice.roll_random_table("1d20", chargen_tables["helmets and shields"])
self.helmet = "helmet" if "helmet" in _helmet_and_shield else "none"
self.shield = "shield" if "shield" in _helmet_and_shield else "none"
self.weapon = dice.roll_random_table("1d20", chargen_tables["starting weapon"])
self.backpack = [
"ration",
"ration",
dice.roll_random_table("1d20", chargen_tables["dungeoning gear"]),
dice.roll_random_table("1d20", chargen_tables["dungeoning gear"]),
dice.roll_random_table("1d20", chargen_tables["general gear 1"]),
dice.roll_random_table("1d20", chargen_tables["general gear 2"]),
]
```
Here we have followed the _Knave_ rulebook to randomize abilities, description and equipment.
The `dice.roll()` and `dice.roll_random_table` methods now become very useful! Everything here
should be easy to follow.
The main difference from baseline _Knave_ is that we make a table of "starting weapon" (in Knave
you can pick whatever you like).
We also initialize `.ability_changes = 0`. Knave only allows us to swap the values of two
Abilities _once_. We will use this to know if it has been done or not.
### Showing the sheet
Now that we have our temporary character sheet, we should make it easy to visualize it.
```python
# in mygame/evadventure/chargen.py
_TEMP_SHEET = """
{name}
STR +{strength}
DEX +{dexterity}
CON +{constitution}
INT +{intelligence}
WIS +{wisdom}
CHA +{charisma}
{description}
Your belongings:
{equipment}
"""
class TemporaryCharacterSheet:
# ...
def show_sheet(self):
equipment = (
str(item)
for item in [self.armor, self.helmet, self.shield, self.weapon] + self.backpack
if item
)
return _TEMP_SHEET.format(
name=self.name,
strength=self.strength,
dexterity=self.dexterity,
constitution=self.constitution,
intelligence=self.intelligence,
wisdom=self.wisdom,
charisma=self.charisma,
description=self.desc,
equipment=", ".join(equipment),
)
```
The new `show_sheet` method collect the data from the temporary sheet and return it in a pretty
form. Making a 'template' string like `_TEMP_SHEET` makes it easier to change things later if you want
to change how things look.
### Apply character
Once we are happy with our character, we need to actually create it with the stats we chose.
This is a bit more involved.
```python
# in mygame/evadventure/chargen.py
# ...
from .characters import EvAdventureCharacter
from evennia import create_object
from evennia.prototypes.spawner import spawn
class TemporaryCharacterSheet:
# ...
def apply(self):
# create character object with given abilities
new_character = create_object(
EvAdventureCharacter,
key=self.name,
attrs=(
("strength", self.strength),
("dexterity", self.dexterity),
("constitution", self.constitution),
("intelligence", self.intelligence),
("wisdom", self.wisdom),
("charisma", self.wisdom),
("hp", self.hp),
("hp_max", self.hp_max),
("desc", self.desc),
),
)
# spawn equipment (will require prototypes created before it works)
if self.weapon:
weapon = spawn(self.weapon)
new_character.equipment.move(weapon)
if self.shield:
shield = spawn(self.shield)
new_character.equipment.move(shield)
if self.armor:
armor = spawn(self.armor)
new_character.equipment.move(armor)
if self.helmet:
helmet = spawn(self.helmet)
new_character.equipment.move(helmet)
for item in self.backpack:
item = spawn(item)
new_character.equipment.store(item)
return new_character
```
We use `create_object` to create a new `EvAdventureCharacter`. We feed it with all relevant data
from the temporary character sheet. This is when these become an actual character.
```{sidebar}
A prototype is basically a `dict` describing how the object should be created. Since
it's just a piece of code, it can stored in a Python module and used to quickly _spawn_ (create)
things from those prototypes.
```
Each piece of equipment is an object in in its own right. We will here assume that all game
items are defined as [Prototypes](../../../Components/Prototypes.md) keyed to its name, such as "sword", "brigandine
armor" etc.
We haven't actually created those prototypes yet, so for now we'll need to assume they are there.
Once a piece of equipment has been spawned, we make sure to move it into the `EquipmentHandler` we
created in the [Equipment lesson](./Beginner-Tutorial-Equipment.md).
## Initializing EvMenu
Evennia comes with a full menu-generation system based on [Command sets](../../../Components/Command-Sets.md), called
[EvMenu](../../../Components/EvMenu.md).
```python
# in mygame/evadventure/chargen.py
from evennia import EvMenu
# ...
# chargen menu
# this goes to the bottom of the module
def start_chargen(caller, session=None):
"""
This is a start point for spinning up the chargen from a command later.
"""
menutree = {} # TODO!
# this generates all random components of the character
tmp_character = TemporaryCharacterSheet()
EvMenu(caller, menutree, session=session, tmp_character=tmp_character)
```
This first function is what we will call from elsewhere (for example from a custom `charcreate`
command) to kick the menu into gear.
It takes the `caller` (the one to want to start the menu) and a `session` argument. The latter will help
track just which client-connection we are using (depending on Evennia settings, you could be
connecting with multiple clients).
We create a `TemporaryCharacterSheet` and call `.generate()` to make a random character. We then
feed all this into `EvMenu`.
The moment this happens, the user will be in the menu, there are no further steps needed.
The `menutree` is what we'll create next. It describes which menu 'nodes' are available to jump
between.
## Main Node: Choosing what to do
This is the first menu node. It will act as a central hub, from which one can choose different
actions.
```python
# in mygame/evadventure/chargen.py
# ...
# at the end of the module, but before the `start_chargen` function
def node_chargen(caller, raw_string, **kwargs):
tmp_character = kwargs["tmp_character"]
text = tmp_character.show_sheet()
options = [
{
"desc": "Change your name",
"goto": ("node_change_name", kwargs)
}
]
if tmp_character.ability_changes <= 0:
options.append(
{
"desc": "Swap two of your ability scores (once)",
"goto": ("node_swap_abilities", kwargs),
}
)
options.append(
{
"desc": "Accept and create character",
"goto": ("node_apply_character", kwargs)
},
)
return text, options
# ...
```
A lot to unpack here! In Evennia, it's convention to name your node-functions `node_*`. While
not required, it helps you track what is a node and not.
Every menu-node, should accept `caller, raw_string, **kwargs` as arguments. Here `caller` is the
`caller` you passed into the `EvMenu` call. `raw_string` is the input given by the user in order
to _get to this node_, so currently empty. The `**kwargs` are all extra keyword arguments passed
into `EvMenu`. They can also be passed between nodes. In this case, we passed the
keyword `tmp_character` to `EvMenu`. We now have the temporary character sheet available in the
node!
An `EvMenu` node must always return two things - `text` and `options`. The `text` is what will
show to the user when looking at this node. The `options` are, well, what options should be
presented to move on from here to some other place.
For the text, we simply get a pretty-print of the temporary character sheet. A single option is
defined as a `dict` like this:
```python
{
"key": ("name". "alias1", "alias2", ...), # if skipped, auto-show a number
"desc": "text to describe what happens when selecting option",.
"goto": ("name of node or a callable", kwargs_to_pass_into_next_node_or_callable)
}
```
Multiple option-dicts are returned in a list or tuple. The `goto` option-key is important to
understand. The job of this is to either point directly to another node (by giving its name), or
by pointing to a Python callable (like a function) _that then returns that name_. You can also
pass kwargs (as a dict). This will be made available as `**kwargs` in the callable or next node.
While an option can have a `key`, you can also skip it to just get a running number.
In our `node_chargen` node, we point to three nodes by name: `node_change_name`,
`node_swap_abilities`, and `node_apply_character`. We also make sure to pass along `kwargs`
to each node, since that contains our temporary character sheet.
The middle of these options only appear if we haven't already switched two abilities around - to
know this, we check the `.ability_changes` property to make sure it's still 0.
## Node: Changing your name
This is where you end up if you opted to change your name in `node_chargen`.
```python
# in mygame/evadventure/chargen.py
# ...
# after previous node
def _update_name(caller, raw_string, **kwargs):
"""
Used by node_change_name below to check what user
entered and update the name if appropriate.
"""
if raw_string:
tmp_character = kwargs["tmp_character"]
tmp_character.name = raw_string.lower().capitalize()
return "node_chargen", kwargs
def node_change_name(caller, raw_string, **kwargs):
"""
Change the random name of the character.
"""
tmp_character = kwargs["tmp_character"]
text = (
f"Your current name is |w{tmp_character.name}|n. "
"Enter a new name or leave empty to abort."
)
options = {
"key": "_default",
"goto": (_update_name, kwargs)
}
return text, options
```
There are two functions here - the menu node itself (`node_change_name`) and a
helper _goto_function_ (`_update_name`) to handle the user's input.
For the (single) option, we use a special `key` named `_default`. This makes this option
a catch-all: If the user enters something that does not match any other option, this is
the option that will be used.
Since we have no other options here, we will always use this option no matter what the user enters.
Also note that the `goto` part of the option points to the `_update_name` callable rather than to
the name of a node. It's important we keep passing `kwargs` along to it!
When a user writes anything at this node, the `_update_name` callable will be called. This has
the same arguments as a node, but it is _not_ a node - we will only use it to _figure out_ which
node to go to next.
In `_update_name` we now have a use for the `raw_string` argument - this is what was written by
the user on the previous node, remember? This is now either an empty string (meaning to ignore
it) or the new name of the character.
A goto-function like `_update_name` must return the name of the next node to use. It can also
optionally return the `kwargs` to pass into that node - we want to always do this, so we don't
loose our temporary character sheet. Here we will always go back to the `node_chargen`.
> Hint: If returning `None` from a goto-callable, you will always return to the last node you
> were at.
## Node: Swapping Abilities around
You get here by selecting the second option from the `node_chargen` node.
```python
# in mygame/evadventure/chargen.py
# ...
# after previous node
_ABILITIES = {
"STR": "strength",
"DEX": "dexterity",
"CON": "constitution",
"INT": "intelligence",
"WIS": "wisdom",
"CHA": "charisma",
}
def _swap_abilities(caller, raw_string, **kwargs):
"""
Used by node_swap_abilities to parse the user's input and swap ability
values.
"""
if raw_string:
abi1, *abi2 = raw_string.split(" ", 1)
if not abi2:
caller.msg("That doesn't look right.")
return None, kwargs
abi2 = abi2[0]
abi1, abi2 = abi1.upper().strip(), abi2.upper().strip()
if abi1 not in _ABILITIES or abi2 not in _ABILITIES:
caller.msg("Not a familiar set of abilites.")
return None, kwargs
# looks okay = swap values. We need to convert STR to strength etc
tmp_character = kwargs["tmp_character"]
abi1 = _ABILITIES[abi1]
abi2 = _ABILITIES[abi2]
abival1 = getattr(tmp_character, abi1)
abival2 = getattr(tmp_character, abi2)
setattr(tmp_character, abi1, abival2)
setattr(tmp_character, abi2, abival1)
tmp_character.ability_changes += 1
return "node_chargen", kwargs
def node_swap_abilities(caller, raw_string, **kwargs):
"""
One is allowed to swap the values of two abilities around, once.
"""
tmp_character = kwargs["tmp_character"]
text = f"""
Your current abilities:
STR +{tmp_character.strength}
DEX +{tmp_character.dexterity}
CON +{tmp_character.constitution}
INT +{tmp_character.intelligence}
WIS +{tmp_character.wisdom}
CHA +{tmp_character.charisma}
You can swap the values of two abilities around.
You can only do this once, so choose carefully!
To swap the values of e.g. STR and INT, write |wSTR INT|n. Empty to abort.
"""
options = {"key": "_default", "goto": (_swap_abilities, kwargs)}
return text, options
```
This is more code, but the logic is the same - we have a node (`node_swap_abilities`) and
and a goto-callable helper (`_swap_abilities`). We catch everything the user writes on the
node (such as `WIS CON`) and feed it into the helper.
In `_swap_abilities`, we need to analyze the `raw_string` from the user to see what they
want to do.
Most code in the helper is validating the user didn't enter nonsense. If they did,
we use `caller.msg()` to tell them and then return `None, kwargs`, which re-runs the same node (the
name-selection) all over again.
Since we want users to be able to write "CON" instead of the longer "constitution", we need a
mapping `_ABILITIES` to easily convert between the two (it's stored as `consitution` on the
temporary character sheet). Once we know which abilities they want to swap, we do so and tick up
the `.ability_changes` counter. This means this option will no longer be available from the main
node.
Finally, we return to `node_chargen` again.
## Node: Creating the Character
We get here from the main node by opting to finish chargen.
```python
node_apply_character(caller, raw_string, **kwargs):
"""
End chargen and create the character. We will also puppet it.
"""
tmp_character = kwargs["tmp_character"]
new_character = tmp_character.apply(caller)
caller.account.db._playable_characters = [new_character]
text = "Character created!"
return text, None
```
When entering the node, we will take the Temporary character sheet and use its `.appy` method to
create a new Character with all equipment.
This is what is called an _end node_, because it returns `None` instead of options. After this,
the menu will exit. We will be back to the default character selection screen. The characters
found on that screen are the ones listed in the `_playable_characters` Attribute, so we need to
also the new character to it.
## Tying the nodes together
```python
def start_chargen(caller, session=None):
"""
This is a start point for spinning up the chargen from a command later.
"""
menutree = { # <----- can now add this!
"node_chargen": node_chargen,
"node_change_name": node_change_name,
"node_swap_abilities": node_swap_abilities,
"node_apply_character": node_apply_character
}
# this generates all random components of the character
tmp_character = TemporaryCharacterSheet()
tmp_character.generate()
EvMenu(caller, menutree, session=session,
startnode="node_chargen", # <-----
tmp_character=tmp_character)
```
Now that we have all the nodes, we add them to the `menutree` we left empty before. We only add
the nodes, _not_ the goto-helpers! The keys we set in the `menutree` dictionary are the names we
should use to point to nodes from inside the menu (and we did).
We also add a keyword argument `startnode` pointing to the `node_chargen` node. This tells EvMenu
to first jump into that node when the menu is starting up.
## Conclusions
This lesson taught us how to use `EvMenu` to make an interactive character generator. In an RPG
more complex than _Knave_, the menu would be bigger and more intricate, but the same principles
apply.
Together with the previous lessons we have now fished most of the basics around player
characters - how they store their stats, handle their equipment and how to create them.
In the next lesson we'll address how EvAdventure _Rooms_ work.

View file

@ -1,5 +0,0 @@
# In-game Commands
```{warning}
This part of the Beginner tutorial is still being developed.
```

View file

@ -1,5 +0,0 @@
# Dynamically generated Dungeon
```{warning}
This part of the Beginner tutorial is still being developed.
```

View file

@ -1,599 +0,0 @@
# Handling Equipment
In _Knave_, you have a certain number of inventory "slots". The amount of slots is given by `CON + 10`.
All items (except coins) have a `size`, indicating how many slots it uses. You can't carry more items
than you have slot-space for. Also items wielded or worn count towards the slots.
We still need to track what the character is using however: What weapon they have readied affects the damage
they can do. The shield, helmet and armor they use affects their defense.
We have already set up the possible 'wear/wield locations' when we defined our Objects
[in the previous lesson](./Beginner-Tutorial-Objects.md). This is what we have in `enums.py`:
```python
# mygame/evadventure/enums.py
# ...
class WieldLocation(Enum):
BACKPACK = "backpack"
WEAPON_HAND = "weapon_hand"
SHIELD_HAND = "shield_hand"
TWO_HANDS = "two_handed_weapons"
BODY = "body" # armor
HEAD = "head" # helmets
```
Basically, all the weapon/armor locations are exclusive - you can only have one item in each (or none).
The BACKPACK is special - it contains any number of items (up to the maximum slot usage).
## EquipmentHandler that saves
> Create a new module `mygame/evadventure/equipment.py`.
```{sidebar}
If you want to understand more about behind how Evennia uses handlers, there is a
[dedicated tutorial](../../Tutorial-Persistent-Handler.md) talking about the principle.
```
In default Evennia, everything you pick up will end up "inside" your character object (that is, have
you as its `.location`). This is called your _inventory_ and has no limit. We will keep 'moving items into us'
when we pick them up, but we will add more functionality using an _Equipment handler_.
A handler is (for our purposes) an object that sits "on" another entity, containing functionality
for doing one specific thing (managing equipment, in our case).
This is the start of our handler:
```python
# in mygame/evadventure/equipment.py
from .enums import WieldLocation
class EquipmentHandler:
save_attribute = "inventory_slots"
def __init__(self, obj):
# here obj is the character we store the handler on
self.obj = obj
self._load()
def _load(self):
"""Load our data from an Attribute on `self.obj`"""
self.slots = self.obj.attributes.get(
self.save_attribute,
category="inventory",
default={
WieldLocation.WEAPON_HAND: None,
WieldLocation.SHIELD_HAND: None,
WieldLocation.TWO_HANDS: None,
WieldLocation.BODY: None,
WieldLocation.HEAD: None,
WieldLocation.BACKPACK: []
}
)
def _save(self):
"""Save our data back to the same Attribute"""
self.obj.attributes.add(self.save_attribute, self.slots, category="inventory")
```
This is a compact and functional little handler. Before analyzing how it works, this is how
we will add it to the Character:
```python
# mygame/evadventure/characters.py
# ...
from evennia.utils.utils import lazy_property
from .equipment import EquipmentHandler
# ...
class EvAdventureCharacter(LivingMixin, DefaultCharacter):
# ...
@lazy_property
def equipment(self):
return EquipmentHandler(self)
```
After reloading the server, the equipment-handler will now be accessible on character-instances as
character.equipment
The `@lazy_property` works such that it will not load the handler until someone actually tries to
fetch it with `character.equipment`. When that
happens, we start up the handler and feed it `self` (the `Character` instance itself). This is what
enters `__init__` as `.obj` in the `EquipmentHandler` code above.
So we now have a handler on the character, and the handler has a back-reference to the character it sits
on.
Since the handler itself is just a regular Python object, we need to use the `Character` to store
our data - our _Knave_ "slots". We must save them to the database, because we want the server to remember
them even after reloading.
Using `self.obj.attributes.add()` and `.get()` we save the data to the Character in a specially named
[Attribute](../../../Components/Attributes.md). Since we use a `category`, we are unlikely to collide with
other Attributes.
Our storage structure is a `dict` with keys after our available `WieldLocation` enums. Each can only
have one item except `WieldLocation.BACKPACK`, which is a list.
## Connecting the EquipmentHandler
Whenever an object leaves from one location to the next, Evennia will call a set of _hooks_ (methods) on the
object that moves, on the source-location and on its destination. This is the same for all moving things -
whether it's a character moving between rooms or an item being dropping from your hand to the ground.
We need to tie our new `EquipmentHandler` into this system. By reading the doc page on [Objects](../../../Components/Objects.md),
or looking at the [DefaultObject.move_to](evennia.objects.objects.DefaultObject.move_to) docstring, we'll
find out what hooks Evennia will call. Here `self` is the object being moved from
`source_location` to `destination`:
1. `self.at_pre_move(destination)` (abort if return False)
2. `source_location.at_pre_object_leave(self, destination)` (abort if return False)
3. `destination.at_pre_object_receive(self, source_location)` (abort if return False)
4. `source_location.at_object_leave(self, destination)`
5. `self.announce_move_from(destination)`
6. (move happens here)
7. `self.announce_move_to(source_location)`
8. `destination.at_object_receive(self, source_location)`
9. `self.at_post_move(source_location)`
All of these hooks can be overridden to customize movement behavior. In this case we are interested in
controlling how items 'enter' and 'leave' our character - being 'inside' the character is the same as
them 'carrying' it. We have three good hook-candidates to use for this.
- `.at_pre_object_receive` - used to check if you can actually pick something up, or if your equipment-store is full.
- `.at_object_receive` - used to add the item to the equipmenthandler
- `.at_object_leave` - used to remove the item from the equipmenthandler
You could also picture using `.at_pre_object_leave` to restrict dropping (cursed?) items, but
we will skip that for this tutorial.
```python
# mygame/evadventure/character.py
# ...
class EvAdventureCharacter(LivingMixin, DefaultCharacter):
# ...
def at_pre_object_receive(self, moved_object, source_location, **kwargs):
"""Called by Evennia before object arrives 'in' this character (that is,
if they pick up something). If it returns False, move is aborted.
"""
return self.equipment.validate_slot_usage(moved_object)
def at_object_receive(self, moved_object, source_location, **kwargs):
"""
Called by Evennia when an object arrives 'in' the character.
"""
self.equipment.add(moved_object)
def at_object_leave(self, moved_object, destination, **kwargs):
"""
Called by Evennia when object leaves the Character.
"""
self.equipment.remove(moved_object)
```
Above we have assumed the `EquipmentHandler` (`.equipment`) has methods `.validate_slot_usage`,
`.add` and `.remove`. But we haven't actually added them yet - we just put some reasonable names! Before
we can use this, we need to go actually adding those methods.
## Expanding the Equipmenthandler
## `.validate_slot_usage`
Let's start with implementing the first method we came up with above, `validate_slot_usage`:
```python
# mygame/evadventure/equipment.py
from .enums import WieldLocation, Ability
class EquipmentError(TypeError):
"""All types of equipment-errors"""
pass
class EquipmentHandler:
# ...
@property
def max_slots(self):
"""Max amount of slots, based on CON defense (CON + 10)"""
return getattr(self.obj, Ability.CON.value, 1) + 10
def count_slots(self):
"""Count current slot usage"""
slots = self.slots
wield_usage = sum(
getattr(slotobj, "size", 0) or 0
for slot, slotobj in slots.items()
if slot is not WieldLocation.BACKPACK
)
backpack_usage = sum(
getattr(slotobj, "size", 0) or 0 for slotobj in slots[WieldLocation.BACKPACK]
)
return wield_usage + backpack_usage
def validate_slot_usage(self, obj):
"""
Check if obj can fit in equipment, based on its size.
"""
if not inherits_from(obj, EvAdventureObject):
# in case we mix with non-evadventure objects
raise EquipmentError(f"{obj.key} is not something that can be equipped.")
size = obj.size
max_slots = self.max_slots
current_slot_usage = self.count_slots()
return current_slot_usage + size <= max_slots:
```
```{sidebar}
The `@property` decorator turns a method into a property so you don't need to 'call' it.
That is, you can access `.max_slots` instead of `.max_slots()`. In this case, it's just a
little less to type.
```
We add two helpers - the `max_slots` _property_ and `count_slots`, a method that calculate the current
slots being in use. Let's figure out how they work.
### `.max_slots`
For `max_slots`, remember that `.obj` on the handler is a back-reference to the `EvAdventureCharacter` we
put this handler on. `getattr` is a Python method for retrieving a named property on an object.
The `Enum` `Ability.CON.value` is the string `Constitution` (check out the
[first Utility and Enums tutorial](./Beginner-Tutorial-Utilities.md) if you don't recall).
So to be clear,
```python
getattr(self.obj, Ability.CON.value) + 10
```
is the same as writing
```python
getattr(your_character, "Constitution") + 10
```
which is the same as doing something like this:
```python
your_character.Constitution + 10
```
In our code we write `getattr(self.obj, Ability.CON.value, 1)` - that extra `1` means that if there
should happen to _not_ be a property "Constitution" on `self.obj`, we should not error out but just
return 1.
### `.count_slots`
In this helper we use two Python tools - the `sum()` function and a
[list comprehension](https://www.w3schools.com/python/python_lists_comprehension.asp). The former
simply adds the values of any iterable together. The latter is a more efficient way to create a list:
new_list = [item for item in some_iterable if condition]
all_above_5 = [num for num in range(10) if num > 5] # [6, 7, 8, 9]
all_below_5 = [num for num in range(10) if num < 5] # [0, 1, 2, 3, 4]
To make it easier to understand, try reading the last line above as "for every number in the range 0-9,
pick all with a value below 5 and make a list of them". You can also embed such comprehensions
directly in a function call like `sum()` without using `[]` around it.
In `count_slots` we have this code:
```python
wield_usage = sum(
getattr(slotobj, "size", 0)
for slot, slotobj in slots.items()
if slot is not WieldLocation.BACKPACK
)
```
We should be able to follow all except `slots.items()`. Since `slots` is a `dict`, we can use `.items()`
to get a sequence of `(key, value)` pairs. We store these in `slot` and `slotobj`. So the above can
be understood as "for every `slot` and `slotobj`-pair in `slots`, check which slot location it is.
If it is _not_ in the backpack, get its size and add it to the list. Sum over all these
sizes".
A less compact but maybe more readonable way to write this would be:
```python
backpack_item_sizes = []
for slot, slotobj in slots.items():
if slot is not WieldLocation.BACKPACK:
size = getattr(slotobj, "size", 0)
backpack_item_sizes.append(size)
wield_usage = sum(backpack_item_sizes)
```
The same is done for the items actually in the BACKPACK slot. The total sizes are added
together.
### Validating slots
With these helpers in place, `validate_slot_usage` now becomes simple. We use `max_slots` to see how much we can carry.
We then get how many slots we are already using (with `count_slots`) and see if our new `obj`'s size
would be too much for us.
## `.add` and `.remove`
We will make it so `.add` puts something in the `BACKPACK` location and `remove` drops it, wherever
it is (even if it was in your hands).
```python
# mygame/evadventure/equipment.py
from .enums import WieldLocation, Ability
# ...
class EquipmentHandler:
# ...
def add(self, obj):
"""
Put something in the backpack.
"""
self.validate_slot_usage(obj)
self.slots[WieldLocation.BACKPACK].append(obj)
self._save()
def remove(self, slot):
"""
Remove contents of a particular slot, for
example `equipment.remove(WieldLocation.SHIELD_HAND)`
"""
slots = self.slots
ret = []
if slot is WieldLocation.BACKPACK:
# empty entire backpack!
ret.extend(slots[slot])
slots[slot] = []
else:
ret.append(slots[slot])
slots[slot] = None
if ret:
self._save()
return ret
```
Both of these should be straight forward to follow. In `.add`, we make use of `validate_slot_usage` to
double-check we can actually fit the thing, then we add the item to the backpack.
In `.delete`, we allow emptying by `WieldLocation` - we figure out what slot it is and return
the item within (if any). If we gave `BACKPACK` as the slot, we empty the backpack and
return all items.
Whenever we change the equipment loadout we must make sure to `._save()` the result, or it will
be lost after a server reload.
## Moving things around
With the help of `.remove()` and `.add()` we can get things in and out of the `BACKPACK` equipment
location. We also need to grab stuff from the backpack and wield or wear it. We add a `.move` method
on the `EquipmentHandler` to do this:
```python
# mygame/evadventure/equipment.py
from .enums import WieldLocation, Ability
# ...
class EquipmentHandler:
# ...
def move(self, obj):
"""Move object from backpack to its intended `inventory_use_slot`."""
# make sure to remove from equipment/backpack first, to avoid double-adding
self.remove(obj)
slots = self.slots
use_slot = getattr(obj, "inventory_use_slot", WieldLocation.BACKPACK)
to_backpack = []
if use_slot is WieldLocation.TWO_HANDS:
# two-handed weapons can't co-exist with weapon/shield-hand used items
to_backpack = [slots[WieldLocation.WEAPON_HAND], slots[WieldLocation.SHIELD_HAND]]
slots[WieldLocation.WEAPON_HAND] = slots[WieldLocation.SHIELD_HAND] = None
slots[use_slot] = obj
elif use_slot in (WieldLocation.WEAPON_HAND, WieldLocation.SHIELD_HAND):
# can't keep a two-handed weapon if adding a one-handed weapon or shield
to_backpack = [slots[WieldLocation.TWO_HANDS]]
slots[WieldLocation.TWO_HANDS] = None
slots[use_slot] = obj
elif use_slot is WieldLocation.BACKPACK:
# it belongs in backpack, so goes back to it
to_backpack = [obj]
else:
# for others (body, head), just replace whatever's there
replaced = [obj]
slots[use_slot] = obj
for to_backpack_obj in to_backpack:
# put stuff in backpack
slots[use_slot].append(to_backpack_obj)
# store new state
self._save()
```
Here we remember that every `EvAdventureObject` has an `inventory_use_slot` property that tells us where
it goes. So we just need to move the object to that slot, replacing whatever is in that place
from before. Anything we replace goes back to the backpack.
## Get everything
In order to visualize our inventory, we need some method to get everything we are carrying.
```python
# mygame/evadventure/equipment.py
from .enums import WieldLocation, Ability
# ...
class EquipmentHandler:
# ...
def all(self):
"""
Get all objects in inventory, regardless of location.
"""
slots = self.slots
lst = [
(slots[WieldLocation.WEAPON_HAND], WieldLocation.WEAPON_HAND),
(slots[WieldLocation.SHIELD_HAND], WieldLocation.SHIELD_HAND),
(slots[WieldLocation.TWO_HANDS], WieldLocation.TWO_HANDS),
(slots[WieldLocation.BODY], WieldLocation.BODY),
(slots[WieldLocation.HEAD], WieldLocation.HEAD),
] + [(item, WieldLocation.BACKPACK) for item in slots[WieldLocation.BACKPACK]]
return lst
```
Here we get all the equipment locations and add their contents together into a list of tuples
`[(item, WieldLocation), ...]`. This is convenient for display.
## Weapon and armor
It's convenient to have the `EquipmentHandler` easily tell you what weapon is currently wielded
and what _armor_ level all worn equipment provides. Otherwise you'd need to figure out what item is
in which wield-slot and to add up armor slots manually every time you need to know.
```python
# mygame/evadventure/equipment.py
from .objects import WeaponEmptyHand
from .enums import WieldLocation, Ability
# ...
class EquipmentHandler:
# ...
@property
def armor(self):
slots = self.slots
return sum(
(
# armor is listed using its defense, so we remove 10 from it
# (11 is base no-armor value in Knave)
getattr(slots[WieldLocation.BODY], "armor", 1),
# shields and helmets are listed by their bonus to armor
getattr(slots[WieldLocation.SHIELD_HAND], "armor", 0),
getattr(slots[WieldLocation.HEAD], "armor", 0),
)
)
@property
def weapon(self):
# first checks two-handed wield, then one-handed; the two
# should never appear simultaneously anyhow (checked in `move` method).
slots = self.slots
weapon = slots[WieldLocation.TWO_HANDS]
if not weapon:
weapon = slots[WieldLocation.WEAPON_HAND]
if not weapon:
weapon = WeaponEmptyHand()
return weapon
```
In the `.armor()` method we get the item (if any) out of each relevant wield-slot (body, shield, head),
and grab their `armor` Attribute. We then `sum()` them all up.
In `.weapon()`, we simply check which of the possible weapon slots (weapon-hand or two-hands) have
something in them. If not we fall back to the 'fake' weapon `WeaponEmptyHand` which is just a 'dummy'
object that represents your bare hands with damage and all.
(created in [The Object tutorial](./Beginner-Tutorial-Objects.md#your-bare-hands) earlier).
## Extra credits
This covers the basic functionality of the equipment handler. There are other useful methods that
can be added:
- Given an item, figure out which equipment slot it is currently in
- Make a string representing the current loadout
- Get everything in the backpack (only)
- Get all wieldable items (weapons, shields) from backpack
- Get all usable items (items with a use-location of `BACKPACK`) from the backpack
Experiment with adding those. A full example is found in
[evennia/contrib/tutorials/evadventure/equipment.py](../../../api/evennia.contrib.tutorials.evadventure.equipment.md).
## Unit Testing
> Create a new module `mygame/evadventure/tests/test_equipment.py`.
```{sidebar}
See [evennia/contrib/tutorials/evadventure/tests/test_equipment.py](../../../api/evennia.contrib.tutorials.evadventure.tests.test_equipment.md)
for a finished testing example.
```
To test the `EquipmentHandler`, easiest is create an `EvAdventureCharacter` (this should by now
have `EquipmentHandler` available on itself as `.equipment`) and a few test objects; then test
passing these into the handler's methods.
```python
# mygame/evadventure/tests/test_equipment.py
from evennia.utils import create
from evennia.utils.test_resources import BaseEvenniaTest
from ..objects import EvAdventureRoom
from ..enums import WieldLocation
class TestEquipment(BaseEvenniaTest):
def setUp(self):
self.character = create.create_object(EvAdventureCharacter, key='testchar')
self.helmet = create.create_object(EvAdventureHelmet, key="helmet")
self.weapon = create.create_object(EvAdventureWeapon, key="weapon")
def test_add_remove):
self.character.equipment.add(self.helmet)
self.assertEqual(
self.character.equipment.slots[WieldLocation.BACKPACK],
[self.helmet]
)
self.character.equipment.remove(self.helmet)
self.assertEqual(self.character.equipment.slots[WieldLocation.BACKPACK], [])
# ...
```
## Summary
_Handlers_ are useful for grouping functionality together. Now that we spent our time making the
`EquipmentHandler`, we shouldn't need to worry about item-slots anymore - the handler 'handles' all
the details for us. As long as we call its methods, the details can be forgotten about.
We also learned to use _hooks_ to tie _Knave_'s custom equipment handling into Evennia.
With `Characters`, `Objects` and now `Equipment` in place, we should be able to move on to character
generation - where players get to make their own character!

View file

@ -1,5 +0,0 @@
# Non-Player-Characters (NPCs)
```{warning}
This part of the Beginner tutorial is still being developed.
```

View file

@ -1,360 +0,0 @@
# In-game Objects and items
In the previous lesson we established what a 'Character' is in our game. Before we continue
we also need to have a notion what an 'item' or 'object' is.
Looking at _Knave_'s item lists, we can get some ideas of what we need to track:
- `size` - this is how many 'slots' the item uses in the character's inventory.
- `value` - a base value if we want to sell or buy the item.
- `inventory_use_slot` - some items can be worn or wielded. For example, a helmet needs to be
worn on the head and a shield in the shield hand. Some items can't be used this way at all, but
only belong in the backpack.
- `obj_type` - Which 'type' of item this is.
## New Enums
We added a few enumberations for Abilities back in the [Utilities tutorial](./Beginner-Tutorial-Utilities.md).
Before we continue, let's expand with enums for use-slots and object types.
```python
# mygame/evadventure/enums.py
# ...
class WieldLocation(Enum):
BACKPACK = "backpack"
WEAPON_HAND = "weapon_hand"
SHIELD_HAND = "shield_hand"
TWO_HANDS = "two_handed_weapons"
BODY = "body" # armor
HEAD = "head" # helmets
class ObjType(Enum):
WEAPON = "weapon"
ARMOR = "armor"
SHIELD = "shield"
HELMET = "helmet"
CONSUMABLE = "consumable"
GEAR = "gear"
MAGIC = "magic"
QUEST = "quest"
TREASURE = "treasure"
```
Once we have these enums, we will use them for referencing things.
## The base object
> Create a new module `mygame/evadventure/objects.py`
```{sidebar}
[evennia/contrib/tutorials/evadventure/objects.py](../../../api/evennia.contrib.tutorials.evadventure.objects.md) has
a full set of objects implemented.
```
<div style="clear: right;"></div>
We will make a base `EvAdventureObject` class off Evennia's standard `DefaultObject`. We will then add
child classes to represent the relevant types:
```python
# mygame/evadventure/objects.py
from evennia import AttributeProperty, DefaultObject
from evennia.utils.utils import make_iter
from .utils import get_obj_stats
from .enums import WieldLocation, ObjType
class EvAdventureObject(DefaultObject):
"""
Base for all evadventure objects.
"""
inventory_use_slot = WieldLocation.BACKPACK
size = AttributeProperty(1, autocreate=False)
value = AttributeProperty(0, autocreate=False)
# this can be either a single type or a list of types (for objects able to be
# act as multiple). This is used to tag this object during creation.
obj_type = ObjType.GEAR
def at_object_creation(self):
"""Called when this object is first created. We convert the .obj_type
property to a database tag."""
for obj_type in make_iter(self.obj_type):
self.tags.add(self.obj_type.value, category="obj_type")
def get_help(self):
"""Get any help text for this item"""
return "No help for this item"
```
### Using Attributes or not
In theory, `size` and `value` does not change and _could_ also be just set as a regular Python
property on the class:
```python
class EvAdventureObject(DefaultObject):
inventory_use_slot = WieldLocation.BACKPACK
size = 1
value = 0
```
The problem with this is that if we want to make a new object of `size 3` and `value 20`, we have to
make a new class for it. We can't change it on the fly because the change would only be in memory and
be lost on next server reload.
Because we use `AttributeProperties`, we can set `size` and `value` to whatever we like when we
create the object (or later), and the Attributes will remember our changes to that object indefinitely.
To make this a little more efficient, we use `autocreate=False`. Normally when you create a
new object with defined `AttributeProperties`, a matching `Attribute` is immediately created at
the same time. So normally, the object would be created along with two Attributes `size` and `value`.
With `autocreate=False`, no Attribute will be created _unless the default is changed_. That is, as
long as your object has `size=1` no database `Attribute` will be created at all. This saves time and
resources when creating large number of objects.
The drawback is that since no Attribute is created you can't refer to it
with `obj.db.size` or `obj.attributes.get("size")` _unless you change its default_. You also can't query
the database for all objects with `size=1`, since most objects would not yet have an in-database
`size` Attribute to search for.
In our case, we'll only refer to these properties as `obj.size` etc, and have no need to find
all objects of a particular size. So we should be safe.
### Creating tags in `at_object_creation`
The `at_object_creation` is a method Evennia calls on every child of `DefaultObject` whenever it is
first created.
We do a tricky thing here, converting our `.obj_type` to one or more [Tags](../../../Components/Tags.md). Tagging the
object like this means you can later efficiently find all objects of a given type (or combination of
types) with Evennia's search functions:
```python
from .enums import ObjType
from evennia.utils import search
# get all shields in the game
all_shields = search.search_object_by_tag(ObjType.SHIELD.value, category="obj_type")
```
We allow `.obj_type` to be given as a single value or a list of values. We use `make_iter` from the
evennia utility library to make sure we don't balk at either. This means you could have a Shield that
is also Magical, for example.
## Other object types
Some of the other object types are very simple so far.
```python
# mygame/evadventure/objects.py
from evennia import AttributeProperty, DefaultObject
from .enums import ObjType
class EvAdventureObject(DefaultObject):
# ...
class EvAdventureQuestObject(EvAdventureObject):
"""Quest objects should usually not be possible to sell or trade."""
obj_type = ObjType.QUEST
class EvAdventureTreasure(EvAdventureObject):
"""Treasure is usually just for selling for coin"""
obj_type = ObjType.TREASURE
value = AttributeProperty(100, autocreate=False)
```
## Consumables
A 'consumable' is an item that has a certain number of 'uses'. Once fully consumed, it can't be used
anymore. An example would be a health potion.
```python
# mygame/evadventure/objects.py
# ...
class EvAdventureConsumable(EvAdventureObject):
"""An item that can be used up"""
obj_type = ObjType.CONSUMABLE
value = AttributeProperty(0.25, autocreate=False)
uses = AttributeProperty(1, autocreate=False)
def at_pre_use(self, user, *args, **kwargs):
"""Called before using. If returning False, abort use."""
return uses > 0
def at_use(self, user, *args, **kwargs):
"""Called when using the item"""
pass
def at_post_use(self. user, *args, **kwargs):
"""Called after using the item"""
# detract a usage, deleting the item if used up.
self.uses -= 1
if self.uses <= 0:
user.msg(f"{self.key} was used up.")
self.delete()
```
What exactly each consumable does will vary - we will need to implement children of this class
later, overriding `at_use` with different effects.
## Weapons
All weapons need properties that describe how efficient they are in battle.
```python
# mygame/evadventure/objects.py
from .enums import WieldLocation, ObjType, Ability
# ...
class EvAdventureWeapon(EvAdventureObject):
"""Base class for all weapons"""
obj_type = ObjType.WEAPON
inventory_use_slot = AttributeProperty(WieldLocation.WEAPON_HAND, autocreate=False)
quality = AttributeProperty(3, autocreate=False)
attack_type = AttibuteProperty(Ability.STR, autocreate=False)
defend_type = AttibuteProperty(Ability.ARMOR, autocreate=False)
damage_roll = AttibuteProperty("1d6", autocreate=False)
```
The `quality` is something we need to track in _Knave_. When getting critical failures on attacks,
a weapon's quality will go down. When it reaches 0, it will break.
The attack/defend type tracks how we resolve attacks with the weapon, like `roll + STR vs ARMOR + 10`.
## Magic
In _Knave_, anyone can use magic if they are wielding a rune stone (our name for spell books) in both
hands. You can only use a rune stone once per rest. So a rune stone is an example of a 'magical weapon'
that is also a 'consumable' of sorts.
```python
# mygame/evadventure/objects.py
# ...
class EvAdventureConsumable(EvAdventureObject):
# ...
class EvAdventureWeapon(EvAdventureObject):
# ...
class EvAdventureRuneStone(EvAdventureWeapon, EvAdventureConsumable):
"""Base for all magical rune stones"""
obj_type = (ObjType.WEAPON, ObjType.MAGIC)
inventory_use_slot = WieldLocation.TWO_HANDS # always two hands for magic
quality = AttributeProperty(3, autocreate=False)
attack_type = AttibuteProperty(Ability.INT, autocreate=False)
defend_type = AttibuteProperty(Ability.DEX, autocreate=False)
damage_roll = AttibuteProperty("1d8", autocreate=False)
def at_post_use(self, user, *args, **kwargs):
"""Called after usage/spell was cast"""
self.uses -= 1
# we don't delete the rune stone here, but
# it must be reset on next rest.
def refresh(self):
"""Refresh the rune stone (normally after rest)"""
self.uses = 1
```
We make the rune stone a mix of weapon and consumable. Note that we don't have to add `.uses`
again, it's inherited from `EvAdventureConsumable` parent. The `at_pre_use` and `at_use` methods
are also inherited; we only override `at_post_use` since we don't want the runestone to be deleted
when it runs out of uses.
We add a little convenience method `refresh` - we should call this when the character rests, to
make the runestone active again.
Exactly what rune stones _do_ will be implemented in the `at_use` methods of subclasses to this
base class. Since magic in _Knave_ tends to be pretty custom, it makes sense that it will lead to a lot
of custom code.
## Armor
Armor, shields and helmets increase the `ARMOR` stat of the character. In _Knave_, what is stored is the
defense value of the armor (values 11-20). We will instead store the 'armor bonus' (1-10). As we know,
defending is always `bonus + 10`, so the result will be the same - this means
we can use `Ability.ARMOR` as any other defensive ability without worrying about a special case.
``
```python
# mygame/evadventure/objects.py
# ...
class EvAdventureAmor(EvAdventureObject):
obj_type = ObjType.ARMOR
inventory_use_slot = WieldLocation.BODY
armor = AttributeProperty(1, autocreate=False)
quality = AttributeProperty(3, autocreate=False)
class EvAdventureShield(EvAdventureArmor):
obj_type = ObjType.SHIELD
inventory_use_slot = WieldLocation.SHIELD_HAND
class EvAdventureHelmet(EvAdventureArmor):
obj_type = ObjType.HELMET
inventory_use_slot = WieldLocation.HEAD
```
## Your Bare hands
This is a 'dummy' object that is not stored in the database. We will use this in the upcoming
[Equipment tutorial lesson](./Beginner-Tutorial-Equipment.md) to represent when you have 'nothing'
in your hands. This way we don't need to add any special case for this.
```python
class WeaponEmptyHand:
obj_type = ObjType.WEAPON
key = "Empty Fists"
inventory_use_slot = WieldLocation.WEAPON_HAND
attack_type = Ability.STR
defense_type = Ability.ARMOR
damage_roll = "1d4"
quality = 100000 # let's assume fists are always available ...
def __repr__(self):
return "<WeaponEmptyHand>"
```
## Testing and Extra credits
Remember the `get_obj_stats` function from the [Utility Tutorial](./Beginner-Tutorial-Utilities.md) earlier?
We had to use dummy-values since we didn't yet know how we would store properties on Objects in the game.
Well, we just figured out all we need! You can go back and update `get_obj_stats` to properly read the data
from the object it receives.
When you change this function you must also update the related unit test - so your existing test becomes a
nice way to test your new Objects as well! Add more tests showing the output of feeding different object-types
to `get_obj_stats`.
Try it out yourself. If you need help, a finished utility example is found in [evennia/contrib/tutorials/evadventure/utils.py](get_obj_stats).

View file

@ -1,79 +0,0 @@
# Part 3: How we get there
```{warning}
The tutorial game is under development and is not yet complete, nor tested. Use the existing
lessons as inspiration and to help get you going, but don't expect out-of-the-box perfection
from it at this time.
```
```{sidebar} Beginner Tutorial Parts
- [Introduction](../Beginner-Tutorial-Intro.md)
<br>Getting set up.
- Part 1: [What we have](../Part1/Beginner-Tutorial-Part1-Intro.md)
<br>A tour of Evennia and how to use the tools, including an introduction to Python.
- Part 2: [What we want](../Part2/Beginner-Tutorial-Part2-Intro.md)
<br>Planning our tutorial game and what to think about when planning your own in the future.
- **Part 3: [How we get there](./Beginner-Tutorial-Part3-Intro.md)**
<br>Getting down to the meat of extending Evennia to make our game
- Part 4: [Using what we created](../Part4/Beginner-Tutorial-Part4-Intro.md)
<br>Building a tech-demo and world content to go with our code
- Part 5: [Showing the world](../Part5/Beginner-Tutorial-Part5-Intro.md)
<br>Taking our new game online and let players try it out
```
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.
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 will
create.
Even if this is not the game-style you are interested in, following along will give you a lot
of experience using Evennia and 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](../../../api/evennia.contrib.tutorials.evadventure.md) package.
## Lessons
```{toctree}
:maxdepth: 1
Beginner-Tutorial-Utilities
Beginner-Tutorial-Rules
Beginner-Tutorial-Characters
Beginner-Tutorial-Objects
Beginner-Tutorial-Equipment
Beginner-Tutorial-Chargen
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
```{toctree}
Beginner-Tutorial-Utilities
Beginner-Tutorial-Rules
Beginner-Tutorial-Characters
Beginner-Tutorial-Objects
Beginner-Tutorial-Equipment
Beginner-Tutorial-Chargen
Beginner-Tutorial-Rooms
Beginner-Tutorial-NPCs
Beginner-Tutorial-Turnbased-Combat
Beginner-Tutorial-Quests
Beginner-Tutorial-Shops
Beginner-Tutorial-Dungeon
Beginner-Tutorial-Commands
```

View file

@ -1,5 +0,0 @@
# Game Quests
```{warning}
This part of the Beginner tutorial is still being developed.
```

View file

@ -1,5 +0,0 @@
# In-game Rooms
```{warning}
This part of the Beginner tutorial is still being developed.
```

View file

@ -1,633 +0,0 @@
# Rules and dice rolling
In _EvAdventure_ we have decided to use the [Knave](https://www.drivethrurpg.com/product/250888/Knave)
RPG ruleset. This is commercial, but released under Creative Commons 4.0, meaning it's okay to share and
adapt _Knave_ for any purpose, even commercially. If you don't want to buy it but still follow
along, you can find a [free fan-version here](http://abominablefancy.blogspot.com/2018/10/knaves-fancypants.html).
## Summary of _Knave_ rules
Knave, being inspired by early Dungeons & Dragons, is very simple.
- It uses six Ability bonuses
_Strength_ (STR), _Dexterity_ (DEX), _Constitution_ (CON), _Intelligence_ (INT), _Wisdom_ (WIS)
and _Charisma_ (CHA). These are rated from `+1` to `+10`.
- Rolls are made with a twenty-sided die (`1d20`), usually adding a suitable Ability bonus to the roll.
- If you roll _with advantage_, you roll `2d20` and pick the
_highest_ value, If you roll _with disadvantage_, you roll `2d20` and pick the _lowest_.
- Rolling a natural `1` is a _critical failure_. A natural `20` is a _critical success_. Rolling such
in combat means your weapon or armor loses quality, which will eventually destroy it.
- A _saving throw_ (trying to succeed against the environment) means making a roll to beat `15` (always).
So if you are lifting a heavy stone and have `STR +2`, you'd roll `1d20 + 2` and hope the result
is higher than `15`.
- An _opposed saving throw_ means beating the enemy's suitable Ability 'defense', which is always their
`Ability bonus + 10`. So if you have `STR +1` and are arm wrestling someone with `STR +2`, you roll
`1d20 + 1` and hope to roll higher than `2 + 10 = 12`.
- A special bonus is `Armor`, `+1` is unarmored, additional armor is given by equipment. Melee attacks
test `STR` versus the `Armor` defense value while ranged attacks uses `WIS` vs `Armor`.
- _Knave_ has no skills or classes. Everyone can use all items and using magic means having a special
'rune stone' in your hands; one spell per stone and day.
- A character has `CON + 10` carry 'slots'. Most normal items uses one slot, armor and large weapons uses
two or three.
- Healing is random, `1d8 + CON` health healed after food and sleep.
- Monster difficulty is listed by hy many 1d8 HP they have; this is called their "hit die" or HD. If
needing to test Abilities, monsters have HD bonus in every Ability.
- Monsters have a _morale rating_. When things go bad, they have a chance to panic and flee if
rolling `2d6` over their morale rating.
- All Characters in _Knave_ are mostly randomly generated. HP is `<level>d8` but we give every
new character max HP to start.
- _Knave_ also have random tables, such as for starting equipment and to see if dying when
hitting 0. Death, if it happens, is permanent.
## Making a rule module
> Create a new module mygame/evadventure/rules.py
```{sidebar}
A complete version of the rule module is found in
[evennia/contrib/tutorials/evadventure/rules.py](../../../api/evennia.contrib.tutorials.evadventure.rules.md).
```
There are three broad sets of rules for most RPGS:
- Character generation rules, often only used during character creation
- Regular gameplay rules - rolling dice and resolving game situations
- Character improvement - getting and spending experience to improve the character
We want our `rules` module to cover as many aspeects of what we'd otherwise would have to look up
in a rulebook.
## Rolling dice
We will start by making a dice roller. Let's group all of our dice rolling into a structure like this
(not functional code yet):
```python
class EvAdventureRollEngine:
def roll(...):
# get result of one generic roll, for any type and number of dice
def roll_with_advantage_or_disadvantage(...)
# get result of normal d20 roll, with advantage/disadvantage (or not)
def saving_throw(...):
# do a saving throw against a specific target number
def opposed_saving_throw(...):
# do an opposed saving throw against a target's defense
def roll_random_table(...):
# make a roll against a random table (loaded elsewere)
def morale_check(...):
# roll a 2d6 morale check for a target
def heal_from_rest(...):
# heal 1d8 when resting+eating, but not more than max value.
def roll_death(...):
# roll to determine penalty when hitting 0 HP.
dice = EvAdventureRollEngine()
```
```{sidebar}
This groups all dice-related code into one 'container' that is easy to import. But it's mostly a matter
of taste. You _could_ also break up the class' methods into normal functions at the top-level of the
module if you wanted.
```
This structure (called a _singleton_) means we group all dice rolls into one class that we then initiate
into a variable `dice` at the end of the module. This means that we can do the following from other
modules:
```python
from .rules import dice
dice.roll("1d8")
```
### Generic dice roller
We want to be able to do `roll("1d20")` and get a random result back from the roll.
```python
# in mygame/evadventure/rules.py
from random import randint
class EvAdventureRollEngine:
def roll(self, roll_string):
"""
Roll XdY dice, where X is the number of dice
and Y the number of sides per die.
Args:
roll_string (str): A dice string on the form XdY.
Returns:
int: The result of the roll.
"""
# split the XdY input on the 'd' one time
number, diesize = roll_string.split("d", 1)
# convert from string to integers
number = int(number)
diesize = int(diesize)
# make the roll
return sum(randint(1, diesize) for _ in range(number))
```
```{sidebar}
For this tutorial we have opted to not use any contribs, so we create
our own dice roller. But normally you could instead use the [dice](../../../Contribs/Contrib-Dice.md) contrib for this.
We'll point out possible helpful contribs in sidebars as we proceed.
```
The `randint` standard Python library module produces a random integer
in a specific range. The line
```python
sum(randint(1, diesize) for _ in range(number))
```
works like this:
- For a certain `number` of times ...
- ... create a random integer between `1` and `diesize` ...
- ... and `sum` all those integers together.
You could write the same thing less compactly like this:
```python
rolls = []
for _ in range(number):
random_result = randint(1, diesize)
rolls.append(random_result)
return sum(rolls)
```
```{sidebar}
Note that `range` generates a value `0...number-1`. We use `_` in the `for` loop to
indicate we don't really care what this value is - we just want to repeat the loop
a certain amount of times.
```
We don't ever expect end users to call this method; if we did, we would have to validate the inputs
much more - We would have to make sure that `number` or `diesize` are valid inputs and not
crazy big so the loop takes forever!
### Rolling with advantage
Now that we have the generic roller, we can start using it to do a more complex roll.
```
# in mygame/evadventure/rules.py
# ...
class EvAdventureRollEngine:
def roll(roll_string):
# ...
def roll_with_advantage_or_disadvantage(self, advantage=False, disadvantage=False):
if not (advantage or disadvantage) or (advantage and disadvantage):
# normal roll - advantage/disadvantage not set or they cancel
# each other out
return self.roll("1d20")
elif advantage:
# highest of two d20 rolls
return max(self.roll("1d20"), self.roll("1d20"))
else:
# disadvantage - lowest of two d20 rolls
return min(self.roll("1d20"), self.roll("1d20"))
```
The `min()` and `max()` functions are standard Python fare for getting the biggest/smallest
of two arguments.
### Saving throws
We want the saving throw to itself figure out if it succeeded or not. This means it needs to know
the Ability bonus (like STR `+1`). It would be convenient if we could just pass the entity
doing the saving throw to this method, tell it what type of save was needed, and then
have it figure things out:
```python
result, quality = dice.saving_throw(character, Ability.STR)
```
The return will be a boolean `True/False` if they pass, as well as a `quality` that tells us if
a perfect fail/success was rolled or not.
To make the saving throw method this clever, we need to think some more about how we want to store our
data on the character.
For our purposes it sounds reasonable that we will be using [Attributes](../../../Components/Attributes.md) for storing
the Ability scores. To make it easy, we will name them the same as the
[Enum values](./Beginner-Tutorial-Utilities.md#enums) we set up in the previous lesson. So if we have
an enum `STR = "strength"`, we want to store the Ability on the character as an Attribute `strength`.
From the Attribute documentation, we can see that we can use `AttributeProperty` to make it so the
Attribute is available as `character.strength`, and this is what we will do.
So, in short, we'll create the saving throws method with the assumption that we will be able to do
`character.strength`, `character.constitution`, `character.charisma` etc to get the relevant Abilities.
```python
# in mygame/evadventure/rules.py
# ...
from .enums import Ability
class EvAdventureRollEngine:
def roll(...)
# ...
def roll_with_advantage_or_disadvantage(...)
# ...
def saving_throw(self, character, bonus_type=Ability.STR, target=15,
advantage=False, disadvantage=False):
"""
Do a saving throw, trying to beat a target.
Args:
character (Character): A character (assumed to have Ability bonuses
stored on itself as Attributes).
bonus_type (Ability): A valid Ability bonus enum.
target (int): The target number to beat. Always 15 in Knave.
advantage (bool): If character has advantage on this roll.
disadvantage (bool): If character has disadvantage on this roll.
Returns:
tuple: A tuple (bool, Ability), showing if the throw succeeded and
the quality is one of None or Ability.CRITICAL_FAILURE/SUCCESS
"""
# make a roll
dice_roll = self.roll_with_advantage_or_disadvantage(advantage, disadvantage)
# figure out if we had critical failure/success
quality = None
if dice_roll == 1:
quality = Ability.CRITICAL_FAILURE
elif dice_roll == 20:
quality = Ability.CRITICAL_SUCCESS
# figure out bonus
bonus = getattr(character, bonus_type.value, 1)
# return a tuple (bool, quality)
return (dice_roll + bonus) > target, quality
```
The `getattr(obj, attrname, default)` function is a very useful Python tool for getting an attribute
off an object and getting a default value if the attribute is not defined.
### Opposed saving throw
With the building pieces we already created, this method is simple. Remember that the defense you have
to beat is always the relevant bonus + 10 in _Knave_. So if the enemy defends with `STR +3`, you must
roll higher than `13`.
```python
# in mygame/evadventure/rules.py
from .enums import Ability
class EvAdventureRollEngine:
def roll(...):
# ...
def roll_with_advantage_or_disadvantage(...):
# ...
def saving_throw(...):
# ...
def opposed_saving_throw(self, attacker, defender,
attack_type=Ability.STR, defense_type=Ability.ARMOR,
advantage=False, disadvantage=False):
defender_defense = getattr(defender, defense_type.value, 1) + 10
result, quality = self.saving_throw(attacker, bonus_type=attack_type,
target=defender_defense,
advantage=advantave, disadvantage=disadvantage)
return result, quality
```
### Morale check
We will make the assumption that the `morale` value is available from the creature simply as
`monster.morale` - we need to remember to make this so later!
In _Knave_, a creature have roll with `2d6` equal or under its morale to not flee or surrender
when things go south. The standard morale value is 9.
```python
# in mygame/evadventure/rules.py
class EvAdventureRollEngine:
# ...
def morale_check(self, defender):
return self.roll("2d6") <= getattr(defender, "morale", 9)
```
### Roll for Healing
To be able to handle healing, we need to make some more assumptions about how we store
health on game entities. We will need `hp_max` (the total amount of available HP) and `hp`
(the current health value). We again assume these will be available as `obj.hp` and `obj.hp_max`.
According to the rules, after consuming a ration and having a full night's sleep, a character regains
`1d8 + CON` HP.
```python
# in mygame/evadventure/rules.py
from .enums import Ability
class EvAdventureRollEngine:
# ...
def heal_from_rest(self, character):
"""
A night's rest retains 1d8 + CON HP
"""
con_bonus = getattr(character, Ability.CON.value, 1)
character.heal(self.roll("1d8") + con_bonus)
```
We make another assumption here - that `character.heal()` is a thing. We tell this function how
much the character should heal, and it will do so, making sure to not heal more than its max
number of HPs
> Knowing what is available on the character and what rule rolls we need is a bit of a chicken-and-egg
> problem. We will make sure to implement the matching _Character_ class next lesson.
### Rolling on a table
We occasionally need to roll on a 'table' - a selection of choices. There are two main table-types
we need to support:
Simply one element per row of the table (same odds to get each result).
| Result |
|:------:|
| item1 |
| item2 |
| item3 |
| item4 |
This we will simply represent as a plain list
```python
["item1", "item2", "item3", "item4"]
```
Ranges per item (varying odds per result):
| Range | Result |
|:-----:|:------:|
| 1-5 | item1 |
| 6-15 | item2 |
| 16-19 | item3 |
| 20 | item4 |
This we will represent as a list of tuples:
```python
[("1-5", "item1"), ("6-15", "item2"), ("16-19", "item4"), ("20", "item5")]
```
We also need to know what die to roll to get a result on the table (it may not always
be obvious, and in some games you could be asked to roll a lower dice to only get
early table results, for example).
```python
# in mygame/evadventure/rules.py
from random import randint, choice
class EvAdventureRollEngine:
# ...
def roll_random_table(self, dieroll, table_choices):
"""
Args:
dieroll (str): A die roll string, like "1d20".
table_choices (iterable): A list of either single elements or
of tuples.
Returns:
Any: A random result from the given list of choices.
Raises:
RuntimeError: If rolling dice giving results outside the table.
"""
roll_result = self.roll(dieroll)
if isinstance(table_choices[0], (tuple, list)):
# the first element is a tuple/list; treat as on the form [("1-5", "item"),...]
for (valrange, choice) in table_choices:
minval, *maxval = valrange.split("-", 1)
minval = abs(int(minval))
maxval = abs(int(maxval[0]) if maxval else minval)
if minval <= roll_result <= maxval:
return choice
# if we get here we must have set a dieroll producing a value
# outside of the table boundaries - raise error
raise RuntimeError("roll_random_table: Invalid die roll")
else:
# a simple regular list
roll_result = max(1, min(len(table_choices), roll_result))
return table_choices[roll_result - 1]
```
Check that you understand what this does.
This may be confusing:
```python
minval, *maxval = valrange.split("-", 1)
minval = abs(int(minval))
maxval = abs(int(maxval[0]) if maxval else minval)
```
If `valrange` is the string `1-5`, then `valrange.split("-", 1)` would result in a tuple `("1", "5")`.
But if the string was in fact just `"20"` (possible for a single entry in an RPG table), this would
lead to an error since it would only split out a single element - and we expected two.
By using `*maxval` (with the `*`), `maxval` is told to expect _0 or more_ elements in a tuple.
So the result for `1-5` will be `("1", ("5",))` and for `20` it will become `("20", ())`. In the line
```python
maxval = abs(int(maxval[0]) if maxval else minval)
```
we check if `maxval` actually has a value `("5",)` or if its empty `()`. The result is either
`"5"` or the value of `minval`.
### Roll for death
While original Knave suggests hitting 0 HP means insta-death, we will grab the optional "death table"
from the "prettified" Knave's optional rules to make it a little less punishing. We also changed the
result of `2` to 'dead' since we don't simulate 'dismemberment' in this tutorial:
| Roll | Result | -1d4 Loss of Ability |
|:---: |:--------:|:--------------------:|
| 1-2 | dead | -
| 3 | weakened | STR |
|4 | unsteady | DEX |
| 5 | sickly | CON |
| 6 | addled | INT |
| 7 | rattled | WIS |
| 8 | disfigured | CHA |
All the non-dead values map to a loss of 1d4 in one of the six Abilities (but you get HP back).
We need to map back to this from the above table. One also cannot have less than -10 Ability bonus,
if you do, you die too.
```python
# in mygame/evadventure/rules.py
death_table = (
("1-2", "dead"),
("3": "strength",
("4": "dexterity"),
("5": "constitution"),
("6": "intelligence"),
("7": "wisdom"),
("8": "charisma"),
)
class EvAdventureRollEngine:
# ...
def roll_random_table(...)
# ...
def roll_death(self, character):
ability_name = self.roll_random_table("1d8", death_table)
if ability_name == "dead":
# TODO - kill the character!
pass
else:
loss = self.roll("1d4")
current_ability = getattr(character, ability_name)
current_ability -= loss
if current_ability < -10:
# TODO - kill the character!
pass
else:
# refresh 1d4 health, but suffer 1d4 ability loss
self.heal(character, self.roll("1d4")
setattr(character, ability_name, current_ability)
character.msg(
"You survive your brush with death, and while you recover "
f"some health, you permanently lose {loss} {ability_name} instead."
)
dice = EvAdventureRollEngine()
```
Here we roll on the 'death table' from the rules to see what happens. We give the character
a message if they survive, to let them know what happened.
We don't yet know what 'killing the character' technically means, so we mark this as `TODO` and
return to it in a later lesson. We just know that we need to do _something_ here to kill off the
character!
## Testing
> Make a new module `mygame/evadventure/tests/test_rules.py`
Testing the `rules` module will also showcase some very useful tools when testing.
```python
# mygame/evadventure/tests/test_rules.py
from unittest.mock import patch
from evennia.utils.test_resources import BaseEvenniaTest
from .. import rules
class TestEvAdventureRuleEngine(BaseEvenniaTest):
def setUp(self):
"""Called before every test method"""
super().setUp()
self.roll_engine = rules.EvAdventureRollEngine()
@patch("evadventure.rules.randint")
def test_roll(self, mock_randint):
mock_randint.return_value = 4
self.assertEqual(self.roll_engine.roll("1d6", 4)
self.assertEqual(self.roll_engine.roll("2d6", 2 * 4)
# test of the other rule methods below ...
```
As before, run the specific test with
evennia test --settings settings.py .evadventure.tests.test_rules
### Mocking and patching
```{sidebar}
In [evennia/contrib/tutorials/evadventure/tests/test_rules.py](../../../api/evennia.contrib.tutorials.evadventure.tests.test_rules.md)
has a complete example of rule testing.
```
The `setUp` method is a special method of the testing class. It will be run before every
test method. We use `super().setUp()` to make sure the parent class' version of this method
always fire. Then we create a fresh `EvAdventureRollEngine` we can test with.
In our test, we import `patch` from the `unittest.mock` library. This is a very useful tool for testing.
Normally the `randint` function we imported in `rules` will return a random value. That's very hard to
test for, since the value will be different every test.
With `@patch` (this is called a _decorator_), we temporarily replace `rules.randint` with a 'mock' - a
dummy entity. This mock is passed into the testing method. We then take this `mock_randint` and set
`.return_value = 4` on it.
Adding `return_value` to the mock means that every time this mock is called, it will return 4. For the
duration of the test we can now check with `self.assertEqual` that our `roll` method always returns a
result as-if the random result was 4.
There are [many resources for understanding mock](https://realpython.com/python-mock-library/), refer to
them for further help.
> The `EvAdventureRollEngine` have many methods to test. We leave this as an extra exercise!
## Summary
This concludes all the core rule mechanics of _Knave_ - the rules used during play. We noticed here
that we are going to soon need to establish how our _Character_ actually stores data. So we will
address that next.

View file

@ -1,5 +0,0 @@
# In-game Shops
```{warning}
This part of the Beginner tutorial is still being developed.
```

View file

@ -1,5 +0,0 @@
# Turn-based combat
```{warning}
This part of the Beginner tutorial is still being developed.
```

View file

@ -1,322 +0,0 @@
# 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](../../../api/evennia.contrib.tutorials.evadventure.enums.md).
```
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"
CRITICAL_FAILURE = "critical_failure"
CRITICAL_SUCCESS = "critical_success"
ALLEGIANCE_HOSTILE = "hostile"
ALLEGIANCE_NEUTRAL = "neutral"
ALLEGIANCE_FRIENDLY = "friendly"
```
Here the `Ability` class holds basic properties of a character sheet.
## 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](../../../api/evennia.contrib.tutorials.evadventure.utils.md)
```
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
```{important}
It's useful for any game dev to know how to effectively test their code. So we'll try to include a
*Testing* section at the end of each of the implementation lessons to follow. Writing tests for your code
is optional but highly recommended; it can feel a little cumbersome at first, but you'll thank yourself later.
```
> 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/tutorials/evadventure/tests/test_utils.py](evennia.contrib.tutorials.
evadventure.tests.test_utils)
is an example of the testing 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!