Add tutorial doc for Rooms. Finish tests for combat

This commit is contained in:
Griatch 2023-04-30 00:09:03 +02:00
parent 998cbb870b
commit bdc3f37954
16 changed files with 627 additions and 275 deletions

View file

@ -16,12 +16,7 @@ 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.
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:
@ -202,12 +197,9 @@ class TemporaryCharacterSheet:
]
```
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.
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).
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.
@ -260,9 +252,7 @@ class TemporaryCharacterSheet:
```
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.
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
@ -321,8 +311,7 @@ class TemporaryCharacterSheet:
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.
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
@ -334,9 +323,7 @@ Each piece of equipment is an object in in its own right. We will here assume th
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).
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
@ -374,12 +361,9 @@ def start_chargen(caller, session=None):
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).
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`.
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.
@ -432,12 +416,7 @@ def node_chargen(caller, raw_string, **kwargs):
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!
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
@ -465,8 +444,7 @@ 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.
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
@ -518,8 +496,7 @@ 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.
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!
@ -528,9 +505,7 @@ When a user writes anything at this node, the `_update_name` callable will be ca
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.
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
@ -628,14 +603,9 @@ In `_swap_abilities`, we need to analyze the `raw_string` from the user to see w
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.
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.
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.
@ -658,13 +628,9 @@ node_apply_character(caller, raw_string, **kwargs):
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.
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.
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
@ -692,18 +658,13 @@ This is a start point for spinning up the chargen from a command later.
```
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).
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.
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.
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.

View file

@ -1,11 +1,8 @@
# 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.
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 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`:
@ -25,8 +22,7 @@ class WieldLocation(Enum):
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).
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
@ -36,12 +32,9 @@ The BACKPACK is special - it contains any number of items (up to the maximum slo
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_.
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).
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:
@ -104,35 +97,23 @@ After reloading the server, the equipment-handler will now be accessible on char
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.
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.
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.
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
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.
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.
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`:
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)
@ -145,9 +126,7 @@ find out what hooks Evennia will call. Here `self` is the object being moved fro
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.
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
@ -187,16 +166,13 @@ class EvAdventureCharacter(LivingMixin, DefaultCharacter):
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.
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
@ -249,15 +225,11 @@ The `@property` decorator turns a method into a property so you don't need to 'c
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.
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).
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,
@ -276,24 +248,18 @@ which is the same as doing something like this:
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.
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:
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.
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:
@ -305,10 +271,7 @@ wield_usage = sum(
)
```
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
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:
@ -327,14 +290,11 @@ 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.
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).
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
@ -381,14 +341,11 @@ In `.delete`, we allow emptying by `WieldLocation` - we figure out what slot it
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.
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:
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
@ -437,9 +394,7 @@ class EquipmentHandler:
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.
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
@ -477,15 +432,12 @@ Here we get all the equipment locations and add their contents together into a l
## 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.
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
# ...
@ -516,14 +468,12 @@ class EquipmentHandler:
weapon = slots[WieldLocation.TWO_HANDS]
if not weapon:
weapon = slots[WieldLocation.WEAPON_HAND]
if not weapon:
weapon = WeaponEmptyHand()
# if we still don't have a weapon, we return None here
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 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'
@ -589,11 +539,8 @@ class TestEquipment(BaseEvenniaTest):
## 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.
_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!
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

@ -7,9 +7,7 @@ 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.
- `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.
@ -57,8 +55,7 @@ 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:
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
@ -81,14 +78,42 @@ class EvAdventureObject(DefaultObject):
# 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
# default evennia hooks
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_display_header(self, looker, **kwargs):
"""The top of the description"""
return ""
def get_display_desc(self, looker, **kwargs)
"""The main display - show object stats"""
return get_obj_stats(self, owner=looker)
# custom evadventure methods
def has_obj_type(self, objtype):
"""Check if object is of a certain type"""
return objtype.value in make_iter(self.obj_type)
def at_pre_use(self, *args, **kwargs):
"""Called before use. If returning False, can't be used"""
return True
def use(self, *args, **kwargs):
"""Use this object, whatever that means"""
pass
def post_use(self, *args, **kwargs):
"""Always called after use."""
pass
def get_help(self):
"""Get any help text for this item"""
return "No help for this item"
@ -106,23 +131,13 @@ class EvAdventureObject(DefaultObject):
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.
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.
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.
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
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
@ -130,11 +145,9 @@ 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.
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
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
@ -145,9 +158,7 @@ types) with Evennia's search functions:
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.
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
@ -176,8 +187,7 @@ class EvAdventureTreasure(EvAdventureObject):
## 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.
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
@ -192,14 +202,20 @@ class EvAdventureConsumable(EvAdventureObject):
value = AttributeProperty(0.25, autocreate=False)
uses = AttributeProperty(1, autocreate=False)
def at_pre_use(self, user, *args, **kwargs):
def at_pre_use(self, user, target=None, *args, **kwargs):
"""Called before using. If returning False, abort use."""
return uses > 0
def at_use(self, user, *args, **kwargs):
if target and user.location != target.location:
user.msg("You are not close enough to the target!")
return False
if self.uses <= 0:
user.msg(f"|w{self.key} is used up.|n")
return False
def use(self, user, *args, **kwargs):
"""Called when using the item"""
pass
pass
def at_post_use(self. user, *args, **kwargs):
"""Called after using the item"""
# detract a usage, deleting the item if used up.
@ -209,12 +225,13 @@ class EvAdventureConsumable(EvAdventureObject):
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.
In `at_pre_use` we check if we have specified a target (heal someone else or throw a fire bomb at an enemy?), making sure we are in the same location. We also make sure we have `usages` left. In `at_post_use` we make sure to tick off usages.
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.
All weapons need properties that describe how efficient they are in battle. To 'use' a weapon means to attack with it, so we can let the weapon itself handle all logic around performing an attack. Having the attack code on the weapon also means that if we in the future wanted a weapon doing something special on-attack (for example, a vampiric sword that heals the attacker when hurting the enemy), we could easily add that on the weapon subclass in question without modifying other code.
```python
# mygame/evadventure/objects.py
@ -234,18 +251,122 @@ class EvAdventureWeapon(EvAdventureObject):
defend_type = AttibuteProperty(Ability.ARMOR, autocreate=False)
damage_roll = AttibuteProperty("1d6", autocreate=False)
def at_pre_use(self, user, target=None, *args, **kwargs):
if target and user.location != target.location:
# we assume weapons can only be used in the same location
user.msg("You are not close enough to the target!")
return False
if self.quality <= 0:
user.msg(f"{self.get_display_name(user)} is broken and can't be used!")
return False
return super().at_pre_use(user, target=target, *args, **kwargs)
def use(self, attacker, target, *args, advantage=False, disadvantage=False, **kwargs):
"""When a weapon is used, it attacks an opponent"""
location = attacker.location
is_hit, quality, txt = rules.dice.opposed_saving_throw(
attacker,
target,
attack_type=self.attack_type,
defense_type=self.defense_type,
advantage=advantage,
disadvantage=disadvantage,
)
location.msg_contents(
f"$You() $conj(attack) $You({target.key}) with {self.key}: {txt}",
from_obj=attacker,
mapping={target.key: target},
)
if is_hit:
# enemy hit, calculate damage
dmg = rules.dice.roll(self.damage_roll)
if quality is Ability.CRITICAL_SUCCESS:
# doble damage roll for critical success
dmg += rules.dice.roll(self.damage_roll)
message = (
f" $You() |ycritically|n $conj(hit) $You({target.key}) for |r{dmg}|n damage!"
)
else:
message = f" $You() $conj(hit) $You({target.key}) for |r{dmg}|n damage!"
location.msg_contents(message, from_obj=attacker, mapping={target.key: target})
# call hook
target.at_damage(dmg, attacker=attacker)
else:
# a miss
message = f" $You() $conj(miss) $You({target.key})."
if quality is Ability.CRITICAL_FAILURE:
message += ".. it's a |rcritical miss!|n, damaging the weapon."
if self.quality is not None:
self.quality -= 1
location.msg_contents(message, from_obj=attacker, mapping={target.key: target})
def at_post_use(self, user, *args, **kwargs):
if self.quality <= 0:
user.msg(f"|r{self.get_display_name(user)} breaks and can no longer be used!")
```
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.
In EvAdventure, we will assume all weapons (including bows etc) are used in the same location as the target. Weapons also have a `quality` attribute that gets worn down if the user rolls a critical failure. Once quality is down to 0, the weapon is broken and needs to be repaired.
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`.
In the `use` method we make use of the `rules` module we [created earlier](Beginner-Tutorial-Rules) to perform all the dice rolls needed to resolve the attack.
This code requires some additional explanation:
```python
location.msg_contents(
f"$You() $conj(attack) $you({target.key}) with {self.key}: {txt}",
from_obj=attacker,
mapping={target.key: target},
)
```
`location.msg_contents` sends a message to everyone in `location`. Since people will usually notice if you swing a sword at somone, this makes sense to tell people about. This message should however look _different_ depending on who sees it.
I should see:
You attack Grendel with sword: <dice roll results>
Others should see
Beowulf attacks Grendel with sword: <dice roll results>
And Grendel should see
Beowulf attacks you with sword: <dice roll results>
We provide the following string to `msg_contents`:
```python
f"$You() $conj(attack) $You({target.key}) with {self.key}: {txt}"
```
The `{...}` are normal f-string formatting markers like those we have used before. The `$func(...)` bits are [Evennnia FuncParser](FuncParser) function calls. FuncParser calls are executed as functions and the result replaces their position in the string. As this string is parsed by Evennia, this is what happens:
First the f-string markers are replaced, so that we get this:
```python
"$You() $cobj(attack) $you(Grendel) with sword: \n rolled 8 on d20 ..."
```
Next the funcparser functions are run:
- `$You()` becomes the name or `You` depending on if the string is to be sent to that object or not. It uses the `from_obj=` kwarg to the `msg_contents` method to know this. Since `msg_contents=attacker` , this becomes `You` or `Beowulf` in this example.
- `$you(Grendel)` looks for the `mapping=` kwarg to `msg_contents` to determine who should be addressed here. If will replace this with the display name or the lowercase `you`. We have added `mapping={target.key: target}` - that is `{"Grendel": <grendel_obj>}`. So this will become `you` or `Grendel` depending on who sees the string.
- `$conj(attack)` _conjugates_ the verb depending on who sees it. The result will be `You attack ...` or `Beowulf attacks` (note the extra `s`).
A few funcparser calls compacts all these points of view into one string!
## 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.
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
@ -281,25 +402,16 @@ class EvAdventureRuneStone(EvAdventureWeapon, EvAdventureConsumable):
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 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 `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.
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.
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.
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
@ -327,34 +439,42 @@ class EvAdventureHelmet(EvAdventureArmor):
## 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.
When we don't have any weapons, we'll be using our bare fists to fight.
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:
# mygame/evadventure/objects.py
from evennia import search_object, create_object
# ...
class WeaponBareHands(EvAdventureWeapon)
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>"
quality = None # let's assume fists are indestructible ...
# common to put this at the bottom of module
BARE_HANDS = search_object("Bare hands", typeclass=WeaponBareHands).first()
if not BARE_HANDS:
BARE_HANDS = create_object(WeaponBareHands, key="Bare hands")
```
Since everyone's empty hands are the same (in our game), we create _one_ `Bare hands` weapon object that everyone shares. We do this by searching for the object with `search_object` (the `.first()` means we grab the first one even if we should by accident have created multiple hands, see [The Django querying tutorial](Beginner-Tutorial-Django-queries) for more info). If we find none, we create it. This way the `BARE_HANDS` object can be used by everyone (we just need to import `objects.BARE_HANDS`).
## 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.
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.
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`.
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,5 +1,212 @@
# In-game Rooms
```{warning}
This part of the Beginner tutorial is still being developed.
```
A _room_ describes a specific location in the game world. Being an abstract concept, it can represent any area of game content that is convenient to group together. In this lesson we will also create a small in-game automap.
In EvAdventure, we will have two main types of rooms:
- Normal, above-ground rooms. Based on a fixed map, these will be created once and then don't change. We'll cover them in this lesson.
- Dungeon rooms - these will be examples of _procedurally generated_ rooms, created on the fly as the players explore the underworld. Being subclasses of the normal room, we'll get to them in the [Dungeon generation lesson](Beginner-Tutorial-Dungeon).
## The base room
Our Evadventure-rooms need some extra functionality
- We need to know if we can do combat in them, if PvP is ok and if you can die in the room.
- We want to show a little _map_ as part of the room description. For this to work we need to remember to always use cardinal directions to connect rooms (north, east etc).
> Create a new module `evadventure/rooms.py`.
```python
# in evadventure/rooms.py
from evennia import AttributeProperty, DefaultRoom
class EvAdventureRoom(DefaultRoom):
"""
Simple room supporting some EvAdventure-specifics.
"""
allow_combat = AttributeProperty(False, autocreate=False)
allow_pvp = AttributeProperty(False, autocreate=False)
allow_death = AttributeProperty(False, autocreate=False)
```
Our EvadventureRoom is very simple. We create three Attributes that defines if combat is allowed in the room, and if so if pvp is allowed and finally if death is allowed. Later on we must make sure our combat systems honors these values. This allows us to create 'safe' training rooms and the like.
That's really all there we _really_ need for the basic room. It'd make for a very short lesson though, so let's add a map too.
## PvP room
Here's a room that allows non-lethal PvP (sparring):
```python
# in evadventure/rooms.py
class EvAdventurePvPRoom(EvAdventureRoom):
"""
Room where PvP can happen, but noone gets killed.
"""
allow_combat = AttributeProperty(True, autocreate=False)
allow_pvp = AttributeProperty(True, autocreate=False)
def get_display_footer(self, looker, **kwargs):
"""
Customize footer of description.
"""
return "|yNon-lethal PvP combat is allowed here!|n"
```
The return of `get_display_footer` will show after the [main room description](Objects#changing-an-objects-appearance), showing that the room is a sparring room. This means that when a player drops to 0 HP, they will lose the combat, but don't stand any risk of dying (weapons wear out normally during sparring though).
## Adding a room map
We want a dynamic map that visualizes the exits you can use at any moment. Here's how our room will display:
```{shell}
o o o
\|/
o-@-o
|
o
The crossroads
A place where many roads meet.
Exits: north, norteast, south, west, and nortwest
```
> Documentation does not show ansi colors.
Let's expand the base `EvAdventureRoom` with the map.
```python
# in evadventyre/rooms.py
from copy import deepcopy
from evennia import DefaultCharacter
from evennia.utils.utils import inherits_from
CHAR_SYMBOL = "|w@|n"
CHAR_ALT_SYMBOL = "|w>|n"
ROOM_SYMBOL = "|bo|n"
LINK_COLOR = "|B"
_MAP_GRID = [
[" ", " ", " ", " ", " "],
[" ", " ", " ", " ", " "],
[" ", " ", "@", " ", " "],
[" ", " ", " ", " ", " "],
[" ", " ", " ", " ", " "],
]
_EXIT_GRID_SHIFT = {
"north": (0, 1, "||"),
"east": (1, 0, "-"),
"south": (0, -1, "||"),
"west": (-1, 0, "-"),
"northeast": (1, 1, "/"),
"southeast": (1, -1, "\\"),
"southwest": (-1, -1, "/"),
"northwest": (-1, 1, "\\"),
}
class EvAdventureRoom(DefaultRoom):
# ...
def format_appearance(self, appearance, looker, **kwargs):
"""Don't left-strip the appearance string"""
return appearance.rstrip()
def get_display_header(self, looker, **kwargs):
"""
Display the current location as a mini-map.
"""
# make sure to not show make a map for users of screenreaders.
# for optimization we also don't show it to npcs/mobs
if not inherits_from(looker, DefaultCharacter) or (
looker.account and looker.account.uses_screenreader()
):
return ""
# build a map
map_grid = deepcopy(_MAP_GRID)
dx0, dy0 = 2, 2
map_grid[dy0][dx0] = CHAR_SYMBOL
for exi in self.exits:
dx, dy, symbol = _EXIT_GRID_SHIFT.get(exi.key, (None, None, None))
if symbol is None:
# we have a non-cardinal direction to go to - indicate this
map_grid[dy0][dx0] = CHAR_ALT_SYMBOL
continue
map_grid[dy0 + dy][dx0 + dx] = f"{LINK_COLOR}{symbol}|n"
if exi.destination != self:
map_grid[dy0 + dy + dy][dx0 + dx + dx] = ROOM_SYMBOL
# Note that on the grid, dy is really going *downwards* (origo is
# in the top left), so we need to reverse the order at the end to mirror it
# vertically and have it come out right.
return " " + "\n ".join("".join(line) for line in reversed(map_grid))
```
The string returned from `get_display_header` will end up at the top of the [room description](Objects#changing-an-objects-description), a good place to have the map appear!
The map itself consists of the 2D matrix `_MAP_GRID`. This is a 2D area described by a list of Python lists. To find a given place in the list, you first first need to find which of the nested lists to use, and then which element to use in that list. Indices start from 0 in Python. So to draw the `o` symbol for the southermost room, you'd need to do so at `_MAP_GRID[4][2]`.
The `_EXIT_GRID_SHIFT` indicates the direction to go for each cardinal exit, along with the map symbol to draw at that point. So `"east": (1, 0, "-")` means the east exit will be drawn one step in the positive x direction (to the right), using the "-" symbol. For symbols like `|` and "\\" we need to escape with a double-symbol since these would otherwise be interpreted as part of other formatting.
We start by making a `deepcopy` of the `_MAP_GRID`. This is so that we don't modify the original but always have an empty template to work from.
We use `@` to indicate the location of the player (at coordinate `(2, 2)`). We then take the actual exits from the room use their names to figure out what symbols to draw out from the center. Once we have placed all the exit- and room-symbols in the grid, we merge it all together into a single string on the last line:
```python
return " " + "\n ".join("".join(line) for line in reversed(map_grid))
```
At the end we use Python's standard [join](https://www.w3schools.com/python/ref_string_join.asp) to convert the grid into a single string. In doing so we must flip the grid upside down (reverse the outermost list). Why is this? If you think about how a MUD game displays its data - by printing at the bottom and then scrolling upwards - you'll realize that Evennia has to send out the top of your map _first_ and the bottom of it _last_ for it to show correctly to the user.
We want to be able to get on/off the grid if so needed. So if a room has a non-cardinal exit in it (like 'back' or up/down), we'll indicate this by showing the `>` symbol instead of the `@` in your current room.
## Testing
> Create a new module `evadventure/tests/test_rooms.py`.
```{sidebar}
You can find a ready testing module [here in the tutorial folder](evennia.contrib.tutorials.evadventure.tests.test_rooms).
```
The main thing to test with our new rooms is the map. Here's the basic principle for how to do this testing:
```python
# in evadventure/tests/test_rooms.py
from evennia import DefaultExit, create_object
from evennia.utils.test_resources import EvenniaTestCase
from ..characters import EvAdventureCharacter
from ..rooms import EvAdventureRoom
class EvAdventureRoomTest(EvenniaTestCase):
def test_map(self):
center_room = create_object(EvAdventureRoom, key="room_center")
n_room = create_object(EvAdventureRoom, key="room_n)
create_object(DefaultExit,
key="north", location=center_room, destination=n_room)
ne_room = create_object(EvAdventureRoom, key="room=ne")
create_object(DefaultExit,
key="northeast", location=center_room, destination=ne_room)
# ... etc for all cardinal directions
char = create_object(EvAdventureCharacter,
key="TestChar", location=center_room)
desc = center_room.return_appearance(char)
# compare the desc we got with the expected description here
```
## Conclusion
In this lesson we manipulated strings and made a map. Changing the description of an object is a big part of changing the 'graphics' of a text-based game, so checking out the [documentation on this](Objects#changing-an-objects-description) is good extra reading.