Doc refactor/renaming

This commit is contained in:
Griatch 2020-07-11 10:41:33 +02:00
parent 9d8e8d7693
commit b5b265ec3b
115 changed files with 518 additions and 434 deletions

View file

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

View file

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

View file

@ -0,0 +1,654 @@
# Tutorial for basic MUSH like game
This tutorial lets you code a small but complete and functioning MUSH-like game in Evennia. A
[MUSH](http://en.wikipedia.org/wiki/MUSH) is, for our purposes, a class of roleplay-centric games
focused on free form storytelling. Even if you are not interested in MUSH:es, this is still a good
first game-type to try since it's not so code heavy. You will be able to use the same principles for
building other types of games.
The tutorial starts from scratch. If you did the [First Steps Coding](../Starting-Part1) tutorial
already you should have some ideas about how to do some of the steps already.
The following are the (very simplistic and cut-down) features we will implement (this was taken from
a feature request from a MUSH user new to Evennia). A Character in this system should:
- Have a “Power” score from 1 to 10 that measures how strong they are (stand-in for the stat
system).
- Have a command (e.g. `+setpower 4`) that sets their power (stand-in for character generation
code).
- Have a command (e.g. `+attack`) that lets them roll their power and produce a "Combat Score"
between `1` and `10*Power`, displaying the result and editing their object to record this number
(stand-in for `+actions` in the command code).
- Have a command that displays everyone in the room and what their most recent "Combat Score" roll
was (stand-in for the combat code).
- Have a command (e.g. `+createNPC Jenkins`) that creates an NPC with full abilities.
- Have a command to control NPCs, such as `+npc/cmd (name)=(command)` (stand-in for the NPC
controlling code).
In this tutorial we will assume you are starting from an empty database without any previous
modifications.
## Server Settings
To emulate a MUSH, the default `MULTISESSION_MODE=0` is enough (one unique session per
account/character). This is the default so you don't need to change anything. You will still be able
to puppet/unpuppet objects you have permission to, but there is no character selection out of the
box in this mode.
We will assume our game folder is called `mygame` henceforth. You should be fine with the default
SQLite3 database.
## Creating the Character
First thing is to choose how our Character class works. We don't need to define a special NPC object
-- an NPC is after all just a Character without an Account currently controlling them.
Make your changes in the `mygame/typeclasses/characters.py` file:
```python
# mygame/typeclasses/characters.py
from evennia import DefaultCharacter
class Character(DefaultCharacter):
"""
[...]
"""
def at_object_creation(self):
"This is called when object is first created, only."
self.db.power = 1
self.db.combat_score = 1
```
We defined two new [Attributes](../../../Components/Attributes) `power` and `combat_score` and set them to default
values. Make sure to `@reload` the server if you had it already running (you need to reload every
time you update your python code, don't worry, no accounts will be disconnected by the reload).
Note that only *new* characters will see your new Attributes (since the `at_object_creation` hook is
called when the object is first created, existing Characters won't have it). To update yourself,
run
@typeclass/force self
This resets your own typeclass (the `/force` switch is a safety measure to not do this
accidentally), this means that `at_object_creation` is re-run.
examine self
Under the "Persistent attributes" heading you should now find the new Attributes `power` and `score`
set on yourself by `at_object_creation`. If you don't, first make sure you `@reload`ed into the new
code, next look at your server log (in the terminal/console) to see if there were any syntax errors
in your code that may have stopped your new code from loading correctly.
## Character Generation
We assume in this example that Accounts first connect into a "character generation area". Evennia
also supports full OOC menu-driven character generation, but for this example, a simple start room
is enough. When in this room (or rooms) we allow character generation commands. In fact, character
generation commands will *only* be available in such rooms.
Note that this again is made so as to be easy to expand to a full-fledged game. With our simple
example, we could simply set an `is_in_chargen` flag on the account and have the `+setpower` command
check it. Using this method however will make it easy to add more functionality later.
What we need are the following:
- One character generation [Command](../../../Components/Commands) to set the "Power" on the `Character`.
- A chargen [CmdSet](../../../Components/Command-Sets) to hold this command. Lets call it `ChargenCmdset`.
- A custom `ChargenRoom` type that makes this set of commands available to players in such rooms.
- One such room to test things in.
### The +setpower command
For this tutorial we will add all our new commands to `mygame/commands/command.py` but you could
split your commands into multiple module if you prefered.
For this tutorial character generation will only consist of one [Command](../../../Components/Commands) to set the
Character s "power" stat. It will be called on the following MUSH-like form:
+setpower 4
Open `command.py` file. It contains documented empty templates for the base command and the
"MuxCommand" type used by default in Evennia. We will use the plain `Command` type here, the
`MuxCommand` class offers some extra features like stripping whitespace that may be useful - if so,
just import from that instead.
Add the following to the end of the `command.py` file:
```python
# end of command.py
from evennia import Command # just for clarity; already imported above
class CmdSetPower(Command):
"""
set the power of a character
Usage:
+setpower <1-10>
This sets the power of the current character. This can only be
used during character generation.
"""
key = "+setpower"
help_category = "mush"
def func(self):
"This performs the actual command"
errmsg = "You must supply a number between 1 and 10."
if not self.args:
self.caller.msg(errmsg)
return
try:
power = int(self.args)
except ValueError:
self.caller.msg(errmsg)
return
if not (1 <= power <= 10):
self.caller.msg(errmsg)
return
# at this point the argument is tested as valid. Let's set it.
self.caller.db.power = power
self.caller.msg("Your Power was set to %i." % power)
```
This is a pretty straightforward command. We do some error checking, then set the power on ourself.
We use a `help_category` of "mush" for all our commands, just so they are easy to find and separate
in the help list.
Save the file. We will now add it to a new [CmdSet](../../../Components/Command-Sets) so it can be accessed (in a full
chargen system you would of course have more than one command here).
Open `mygame/commands/default_cmdsets.py` and import your `command.py` module at the top. We also
import the default `CmdSet` class for the next step:
```python
from evennia import CmdSet
from commands import command
```
Next scroll down and define a new command set (based on the base `CmdSet` class we just imported at
the end of this file, to hold only our chargen-specific command(s):
```python
# end of default_cmdsets.py
class ChargenCmdset(CmdSet):
"""
This cmdset it used in character generation areas.
"""
key = "Chargen"
def at_cmdset_creation(self):
"This is called at initialization"
self.add(command.CmdSetPower())
```
In the future you can add any number of commands to this cmdset, to expand your character generation
system as you desire. Now we need to actually put that cmdset on something so it's made available to
users. We could put it directly on the Character, but that would make it available all the time.
It's cleaner to put it on a room, so it's only available when players are in that room.
### Chargen areas
We will create a simple Room typeclass to act as a template for all our Chargen areas. Edit
`mygame/typeclasses/rooms.py` next:
```python
from commands.default_cmdsets import ChargenCmdset
# ...
# down at the end of rooms.py
class ChargenRoom(Room):
"""
This room class is used by character-generation rooms. It makes
the ChargenCmdset available.
"""
def at_object_creation(self):
"this is called only at first creation"
self.cmdset.add(ChargenCmdset, permanent=True)
```
Note how new rooms created with this typeclass will always start with `ChargenCmdset` on themselves.
Don't forget the `permanent=True` keyword or you will lose the cmdset after a server reload. For
more information about [Command Sets](../../../Components/Command-Sets) and [Commands](../../../Components/Commands), see the respective
links.
### Testing chargen
First, make sure you have `@reload`ed the server (or use `evennia reload` from the terminal) to have
your new python code added to the game. Check your terminal and fix any errors you see - the error
traceback lists exactly where the error is found - look line numbers in files you have changed.
We can't test things unless we have some chargen areas to test. Log into the game (you should at
this point be using the new, custom Character class). Let's dig a chargen area to test.
@dig chargen:rooms.ChargenRoom = chargen,finish
If you read the help for `@dig` you will find that this will create a new room named `chargen`. The
part after the `:` is the python-path to the Typeclass you want to use. Since Evennia will
automatically try the `typeclasses` folder of our game directory, we just specify
`rooms.ChargenRoom`, meaning it will look inside the module `rooms.py` for a class named
`ChargenRoom` (which is what we created above). The names given after `=` are the names of exits to
and from the room from your current location. You could also append aliases to each one name, such
as `chargen;character generation`.
So in summary, this will create a new room of type ChargenRoom and open an exit `chargen` to it and
an exit back here named `finish`. If you see errors at this stage, you must fix them in your code.
`@reload`
between fixes. Don't continue until the creation seems to have worked okay.
chargen
This should bring you to the chargen room. Being in there you should now have the `+setpower`
command available, so test it out. When you leave (via the `finish` exit), the command will go away
and trying `+setpower` should now give you a command-not-found error. Use `ex me` (as a privileged
user) to check so the `Power` [Attribute](../../../Components/Attributes) has been set correctly.
If things are not working, make sure your typeclasses and commands are free of bugs and that you
have entered the paths to the various command sets and commands correctly. Check the logs or command
line for tracebacks and errors.
## Combat System
We will add our combat command to the default command set, meaning it will be available to everyone
at all times. The combat system consists of a `+attack` command to get how successful our attack is.
We also change the default `look` command to display the current combat score.
### Attacking with the +attack command
Attacking in this simple system means rolling a random "combat score" influenced by the `power` stat
set during Character generation:
> +attack
You +attack with a combat score of 12!
Go back to `mygame/commands/command.py` and add the command to the end like this:
```python
import random
# ...
class CmdAttack(Command):
"""
issues an attack
Usage:
+attack
This will calculate a new combat score based on your Power.
Your combat score is visible to everyone in the same location.
"""
key = "+attack"
help_category = "mush"
def func(self):
"Calculate the random score between 1-10*Power"
caller = self.caller
power = caller.db.power
if not power:
# this can happen if caller is not of
# our custom Character typeclass
power = 1
combat_score = random.randint(1, 10 * power)
caller.db.combat_score = combat_score
# announce
message = "%s +attack%s with a combat score of %s!"
caller.msg(message % ("You", "", combat_score))
caller.location.msg_contents(message %
(caller.key, "s", combat_score),
exclude=caller)
```
What we do here is simply to generate a "combat score" using Python's inbuilt `random.randint()`
function. We then store that and echo the result to everyone involved.
To make the `+attack` command available to you in game, go back to
`mygame/commands/default_cmdsets.py` and scroll down to the `CharacterCmdSet` class. At the correct
place add this line:
```python
self.add(command.CmdAttack())
```
`@reload` Evennia and the `+attack` command should be available to you. Run it and use e.g. `@ex` to
make sure the `combat_score` attribute is saved correctly.
### Have "look" show combat scores
Players should be able to view all current combat scores in the room. We could do this by simply
adding a second command named something like `+combatscores`, but we will instead let the default
`look` command do the heavy lifting for us and display our scores as part of its normal output, like
this:
> look Tom
Tom (combat score: 3)
This is a great warrior.
We don't actually have to modify the `look` command itself however. To understand why, take a look
at how the default `look` is actually defined. It sits in `evennia/commands/default/general.py` (or
browse it online
[here](https://github.com/evennia/evennia/blob/master/evennia/commands/default/general.py#L44)).
You will find that the actual return text is done by the `look` command calling a *hook method*
named `return_appearance` on the object looked at. All the `look` does is to echo whatever this hook
returns. So what we need to do is to edit our custom Character typeclass and overload its
`return_appearance` to return what we want (this is where the advantage of having a custom typeclass
comes into play for real).
Go back to your custom Character typeclass in `mygame/typeclasses/characters.py`. The default
implementation of `return appearance` is found in `evennia.DefaultCharacter` (or online
[here](https://github.com/evennia/evennia/blob/master/evennia/objects/objects.py#L1438)). If you
want to make bigger changes you could copy & paste the whole default thing into our overloading
method. In our case the change is small though:
```python
class Character(DefaultCharacter):
"""
[...]
"""
def at_object_creation(self):
"This is called when object is first created, only."
self.db.power = 1
self.db.combat_score = 1
def return_appearance(self, looker):
"""
The return from this method is what
looker sees when looking at this object.
"""
text = super().return_appearance(looker)
cscore = " (combat score: %s)" % self.db.combat_score
if "\n" in text:
# text is multi-line, add score after first line
first_line, rest = text.split("\n", 1)
text = first_line + cscore + "\n" + rest
else:
# text is only one line; add score to end
text += cscore
return text
```
What we do is to simply let the default `return_appearance` do its thing (`super` will call the
parent's version of the same method). We then split out the first line of this text, append our
`combat_score` and put it back together again.
`@reload` the server and you should be able to look at other Characters and see their current combat
scores.
> Note: A potentially more useful way to do this would be to overload the entire `return_appearance`
of the `Room`s of your mush and change how they list their contents; in that way one could see all
combat scores of all present Characters at the same time as looking at the room. We leave this as an
exercise.
## NPC system
Here we will re-use the Character class by introducing a command that can create NPC objects. We
should also be able to set its Power and order it around.
There are a few ways to define the NPC class. We could in theory create a custom typeclass for it
and put a custom NPC-specific cmdset on all NPCs. This cmdset could hold all manipulation commands.
Since we expect NPC manipulation to be a common occurrence among the user base however, we will
instead put all relevant NPC commands in the default command set and limit eventual access with
[Permissions and Locks](../../../Components/Locks#Permissions).
### Creating an NPC with +createNPC
We need a command for creating the NPC, this is a very straightforward command:
> +createnpc Anna
You created the NPC 'Anna'.
At the end of `command.py`, create our new command:
```python
from evennia import create_object
class CmdCreateNPC(Command):
"""
create a new npc
Usage:
+createNPC <name>
Creates a new, named NPC. The NPC will start with a Power of 1.
"""
key = "+createnpc"
aliases = ["+createNPC"]
locks = "call:not perm(nonpcs)"
help_category = "mush"
def func(self):
"creates the object and names it"
caller = self.caller
if not self.args:
caller.msg("Usage: +createNPC <name>")
return
if not caller.location:
# may not create npc when OOC
caller.msg("You must have a location to create an npc.")
return
# make name always start with capital letter
name = self.args.strip().capitalize()
# create npc in caller's location
npc = create_object("characters.Character",
key=name,
location=caller.location,
locks="edit:id(%i) and perm(Builders);call:false()" % caller.id)
# announce
message = "%s created the NPC '%s'."
caller.msg(message % ("You", name))
caller.location.msg_contents(message % (caller.key, name),
exclude=caller)
```
Here we define a `+createnpc` (`+createNPC` works too) that is callable by everyone *not* having the
`nonpcs` "[permission](../../../Components/Locks#Permissions)" (in Evennia, a "permission" can just as well be used to
block access, it depends on the lock we define). We create the NPC object in the caller's current
location, using our custom `Character` typeclass to do so.
We set an extra lock condition on the NPC, which we will use to check who may edit the NPC later --
we allow the creator to do so, and anyone with the Builders permission (or higher). See
[Locks](../../../Components/Locks) for more information about the lock system.
Note that we just give the object default permissions (by not specifying the `permissions` keyword
to the `create_object()` call). In some games one might want to give the NPC the same permissions
as the Character creating them, this might be a security risk though.
Add this command to your default cmdset the same way you did the `+attack` command earlier.
`@reload` and it will be available to test.
### Editing the NPC with +editNPC
Since we re-used our custom character typeclass, our new NPC already has a *Power* value - it
defaults to 1. How do we change this?
There are a few ways we can do this. The easiest is to remember that the `power` attribute is just a
simple [Attribute](../../../Components/Attributes) stored on the NPC object. So as a Builder or Admin we could set this
right away with the default `@set` command:
@set mynpc/power = 6
The `@set` command is too generally powerful though, and thus only available to staff. We will add a
custom command that only changes the things we want players to be allowed to change. We could in
principle re-work our old `+setpower` command, but let's try something more useful. Let's make a
`+editNPC` command.
> +editNPC Anna/power = 10
Set Anna's property 'power' to 10.
This is a slightly more complex command. It goes at the end of your `command.py` file as before.
```python
class CmdEditNPC(Command):
"""
edit an existing NPC
Usage:
+editnpc <name>[/<attribute> [= value]]
Examples:
+editnpc mynpc/power = 5
+editnpc mynpc/power - displays power value
+editnpc mynpc - shows all editable
attributes and values
This command edits an existing NPC. You must have
permission to edit the NPC to use this.
"""
key = "+editnpc"
aliases = ["+editNPC"]
locks = "cmd:not perm(nonpcs)"
help_category = "mush"
def parse(self):
"We need to do some parsing here"
args = self.args
propname, propval = None, None
if "=" in args:
args, propval = [part.strip() for part in args.rsplit("=", 1)]
if "/" in args:
args, propname = [part.strip() for part in args.rsplit("/", 1)]
# store, so we can access it below in func()
self.name = args
self.propname = propname
# a propval without a propname is meaningless
self.propval = propval if propname else None
def func(self):
"do the editing"
allowed_propnames = ("power", "attribute1", "attribute2")
caller = self.caller
if not self.args or not self.name:
caller.msg("Usage: +editnpc name[/propname][=propval]")
return
npc = caller.search(self.name)
if not npc:
return
if not npc.access(caller, "edit"):
caller.msg("You cannot change this NPC.")
return
if not self.propname:
# this means we just list the values
output = "Properties of %s:" % npc.key
for propname in allowed_propnames:
propvalue = npc.attributes.get(propname, default="N/A")
output += "\n %s = %s" % (propname, propvalue)
caller.msg(output)
elif self.propname not in allowed_propnames:
caller.msg("You may only change %s." %
", ".join(allowed_propnames))
elif self.propval:
# assigning a new propvalue
# in this example, the properties are all integers...
intpropval = int(self.propval)
npc.attributes.add(self.propname, intpropval)
caller.msg("Set %s's property '%s' to %s" %
(npc.key, self.propname, self.propval))
else:
# propname set, but not propval - show current value
caller.msg("%s has property %s = %s" %
(npc.key, self.propname,
npc.attributes.get(self.propname, default="N/A")))
```
This command example shows off the use of more advanced parsing but otherwise it's mostly error
checking. It searches for the given npc in the same room, and checks so the caller actually has
permission to "edit" it before continuing. An account without the proper permission won't even be
able to view the properties on the given NPC. It's up to each game if this is the way it should be.
Add this to the default command set like before and you should be able to try it out.
_Note: If you wanted a player to use this command to change an on-object property like the NPC's
name (the `key` property), you'd need to modify the command since "key" is not an Attribute (it is
not retrievable via `npc.attributes.get` but directly via `npc.key`). We leave this as an optional
exercise._
### Making the NPC do stuff - the +npc command
Finally, we will make a command to order our NPC around. For now, we will limit this command to only
be usable by those having the "edit" permission on the NPC. This can be changed if it's possible for
anyone to use the NPC.
The NPC, since it inherited our Character typeclass has access to most commands a player does. What
it doesn't have access to are Session and Player-based cmdsets (which means, among other things that
they cannot chat on channels, but they could do that if you just added those commands). This makes
the `+npc` command simple:
+npc Anna = say Hello!
Anna says, 'Hello!'
Again, add to the end of your `command.py` module:
```python
class CmdNPC(Command):
"""
controls an NPC
Usage:
+npc <name> = <command>
This causes the npc to perform a command as itself. It will do so
with its own permissions and accesses.
"""
key = "+npc"
locks = "call:not perm(nonpcs)"
help_category = "mush"
def parse(self):
"Simple split of the = sign"
name, cmdname = None, None
if "=" in self.args:
name, cmdname = [part.strip()
for part in self.args.rsplit("=", 1)]
self.name, self.cmdname = name, cmdname
def func(self):
"Run the command"
caller = self.caller
if not self.cmdname:
caller.msg("Usage: +npc <name> = <command>")
return
npc = caller.search(self.name)
if not npc:
return
if not npc.access(caller, "edit"):
caller.msg("You may not order this NPC to do anything.")
return
# send the command order
npc.execute_cmd(self.cmdname)
caller.msg("You told %s to do '%s'." % (npc.key, self.cmdname))
```
Note that if you give an erroneous command, you will not see any error message, since that error
will be returned to the npc object, not to you. If you want players to see this, you can give the
caller's session ID to the `execute_cmd` call, like this:
```python
npc.execute_cmd(self.cmdname, sessid=self.caller.sessid)
```
Another thing to remember is however that this is a very simplistic way to control NPCs. Evennia
supports full puppeting very easily. An Account (assuming the "puppet" permission was set correctly)
could simply do `@ic mynpc` and be able to play the game "as" that NPC. This is in fact just what
happens when an Account takes control of their normal Character as well.
## Concluding remarks
This ends the tutorial. It looks like a lot of text but the amount of code you have to write is
actually relatively short. At this point you should have a basic skeleton of a game and a feel for
what is involved in coding your game.
From here on you could build a few more ChargenRooms and link that to a bigger grid. The `+setpower`
command can either be built upon or accompanied by many more to get a more elaborate character
generation.
The simple "Power" game mechanic should be easily expandable to something more full-fledged and
useful, same is true for the combat score principle. The `+attack` could be made to target a
specific player (or npc) and automatically compare their relevant attributes to determine a result.
To continue from here, you can take a look at the [Tutorial World](../Part1/Tutorial-World-Introduction). For
more specific ideas, see the [other tutorials and hints](../../Howto-Overview) as well
as the [Evennia Component overview](../../../Components/Components-Overview).