Make certain you've followed all the instructions to get an Evennia sandbox up and running, first of all. Once you know the sandbox is working, we'll begin editing.
Now we'll log into our Evennia game as our administrator self. If you type who
, you'll see that Evennia already has a command for this. The output will be something like:
Accounts:
+--------------+--------+------+-----------+-------+------+----------+-----------+
| Account Name | On for | Idle | Puppeting | Room | Cmds | Protocol | Host |
+~~~~~~~~~~~~~~+~~~~~~~~+~~~~~~+~~~~~~~~~~~+~~~~~~~+~~~~~~+~~~~~~~~~~+~~~~~~~~~~~+
| Pax | 00:00 | 2s | Pax | Limbo | 1 | telnet | 127.0.0.1 |
+--------------+--------+------+-----------+-------+------+----------+-----------+
One unique account logged in.
This is because we're an administrator; if you type doing
you'll see what a player sees. (If you were a player, the two commands would do the same thing.) For a player, it looks something like this:
Accounts:
+--------------+--------+------+
| Account name | On for | Idle |
+~~~~~~~~~~~~~~+~~~~~~~~+~~~~~~+
| Pax | 00:00 | 25s |
+--------------+--------+------+
One unique account logged in.
Now let's look at how we can replace this command with something of our own design that adds a 'doing' column like on MUSH's default WHO
command.
The first thing to know about Evennia commands is that they are themselves classes. These classes are added to a Command Set. These command sets 'stack'.
Each command set also has a 'priority'; this determines in what order they stack. If I have a command set with a priority of 2 and one with a priority of -20 both active and both have a foo
command, when I type foo
it will take it from the set with priority 2.
What command sets you have available to you depends entirely on the situation you're in; there's one set of commands for sessions that haven't logged in yet, which is available at the login screen. You have the default commands available to a player. But a room might have a high-priority command set on it which becomes available to players in that room; perhaps the room is pitch black, so you override the look
command in that room to simply return "You can't see anything."
Stacking command sets is a powerful tool, and it allows us to override built-in Evennia commands without having to modify the base server.
There are different subclasses of the Evennia default command class which provide different parsers. The command class we're going to be using is MuxCommand, which uses a TinyMUX-style parser. If we type, for instance, foo/bar baz=blah
then the parser will separate that into a command (foo), a series of one or more switches (bar), a left-hand side (baz) and a right-hand side (blah) which it will make available to our code. This is the command class that all of Evennia's default commands use anyway, and it should work well for our use case.
Let's start by creating a nice file to contain our commands. Since this is going to have commands that override built-in system commands, let's call it overrides.py
. Technically we could put this file anywhere, but since there's already a commands
directory containing things dealing with custom commands for our server, it makes sense to put it there.
Now, let's put some content into that file:
from evennia.commands.default.muxcommand import MuxCommand
class CmdWho(MuxCommand):
"""
who
This command shows who is presently online.
"""
key = "who"
aliases = ["doing", ]
locks = "cmd:all()"
account_caller = True
def func(self):
self.msg("This is the who command")
Okay, let's break this down.
First, we're making certain that MuxCommand
is in scope for our file, or else we can't refer to it.
Next, we define a class called CmdWho. We could call it anything we want, really, but by convention Evennia command classes usually have names beginning with Cmd. Our class inherits from the MuxCommand
class we imported, so we pick up all of its functionality.
Next, you'll notice there's a multi-line comment at the top of the class definition. In Python, this provides an blurb for automatic documentation; Evennia uses this to generate the help
entry for your command. Generally we'll want to write something more detailed than this, but for now this is sufficient.
Now for the meat of things. As noted earlier, every command is a class encapsulating its functionality; when a command is executed, several things happen.
at_pre_cmd()
method is called to check whether the command should be blocked. If it returns True
, the command aborts.parse()
method is called, to break the line that was entered apart into its useful component pieces.func()
method is called, to do the actual work of the command.at_post_cmd()
method is called, to do any last cleanup.In addition, there are several fields which are important in the command: key
is a string which is used as the command name, aliases
is an array of strings which are other names for the command, and locks
is an Evennia permission string.
MuxCommand
already provides an implementation of parse()
for us, as well as empty at_pre_cmd()
and at_post_cmd()
methods, so we don't need to implement those. We do, however, obviously need to add the fields and the func
method!
In this case, we're setting up the command to be called who
and adding a single alias of doing
.
Understanding lock strings is a little beyond the scope of this first command; in this case, just know that they're in the format permission:function(params)
, and that the cmd
permission is what's necessary for someone to be able to use the command. There are various built-in lock functions and you can add new ones, but in this case the built-in all()
function is sufficient since everyone should be able to use the command. Thus cmd:all()
is a string that says everyone gets the cmd
permission for that lock.
There's also a special case in MuxCommand for commands which we know might be called from an account (rather than a character), so we'll set account_caller
to true.
Now on to our func
method. Right now, we're going to do something very basic; in this case, there's a convenient msg
method on our parent class, which will send text to whatever called the command. So we're just going to send a single line of text, our own equivalent of "Hello, world."
However, we're not quite done! The command isn't in any command set, so no one can use it.
At any given time, there's at least one command set (and often more) available to a user.
The moment you connect, you have the SessionCmdSet
. Then while you're at the login screen, the contents of the UnloggedinCmdSet
are also added to that which provides commands like connect
to users. Once you log in, you have AccountCmdSet
which might contain account-specific commands like changing your password. Then there's CharacterCmdSet
, once you have a body on the game grid. (Evennia, remember, has a separate concept of "accounts" and "characters"; one account could have multiple characters.)
You can always add more command sets for other scenarios, though that's beyond the scope of this. Right now, let's add this to the AccountCmdSet
In this same directory, let's open default_cmdsets.py
, which contains the definitions of Evennia's default command sets. Look for the AccountCmdSet
definition:
class AccountCmdSet(default_cmds.AccountCmdSet):
"""
This is the cmdset available to the Account at all times. It is
combined with the `CharacterCmdSet` when the Account puppets a
Character. It holds game-account-specific commands, channel
commands, etc.
"""
key = "DefaultAccount"
def at_cmdset_creation(self):
"""
Populates the cmdset
"""
super(AccountCmdSet, self).at_cmdset_creation()
#
# any commands you add below will overload the default ones.
#
As it says at the bottom, we want to add our own command to the command set to override the existing one. To do that, we need to add the following line:
self.add(overrides.CmdWho())
We're creating an instance of that class and adding it to the command set. Since it's in the overrides
file, we refer to it by that name.
However, like with MuxCommand
in our overrides file, we need to make sure we've imported this. Go back up to the top of the file, and you'll see an existing line importing default_cmds from the Evennia library. Add another line below that of simply:
import overrides
Rather than importing a specific set of classes or commands, we're going to just import the entire overrides
file so that, as we add new commands, they're already in scope for us.
Now we're ready!
Start your Evennia copy back up. If the game is already running, either type @reload
as a character with Developer permissions on-game in Evennia (such as the God character you created when you first started) or, with your Evennia python environment active, type evennia reload
.
When you type who
you'll see:
This is the who command
Not terribly exciting, but we've created the beginnings of the command!
Let's go back to overrides.py
and make this command a little more interesting. We're going to import an global instance of a special Evennia class called the session handler, which keeps track of all connected sessions. In order to do that, let's go back up to the top of the file and add an import to get SESSIONS
from the evennia.server.sessionhandler
package.
Then let's change the body of our command function like so:
def func(self):
session_list = SESSIONS.get_sessions()
self.msg("Sessions: {}".format(session_list))
Reload the server, and try who
again. You'll see something like
Sessions: [<evennia.server.serversession.ServerSession object at 0x104e5d950>]
If you type doing
, you'll see the same output, because we defined doing
as an alias of who
.
Now, we can see a nice list of all the connected sessions—in this case, one—but that's not a terribly useful format. But each of those sessions is an instance of evennia.server.serversession.ServerSession
, as you can see in that list. This is not a class you'll generally interact with except in the special case of the SESSIONS
variable you can import to get all connected sessions.
If you open the evennia package and look at evennia/server/serversession.py
for the ServerSession
class, you'll see there's a ton of functionality there. What's relevant to us, however, is that ServerSession
has a method called get_account()
, which returns the logged-in account for that session if there is one.
Account
is one of the major Evennia classes you can interact with. It's a Django model; this means it's stored in the database, can have attributes, and has a primary key. In this case, the primary key will be the name.
Let's change our command's body once again:
def func(self):
session_list = SESSIONS.get_sessions()
account_names = [sess.get_account().key for sess in session_list]
self.msg("Accounts: {}".format(account_names))
The line that transforms the session list into a list of account names is fairly standard Python. It simply means "this array contains the account
field's key
field for each instance of an object in session_list". (Learning all aspects of Python is a bit beyond the scope of this lesson, but I'll try to cover things where I can.)
Now if you restart and run who
again, you'll see it prints an list of unicode strings, one account name for each logged-in session.
Now let's make this more useful.
Okay, now let's alter our overrides.py
. We'll make use of several Evennia utilities (crop
and time_format
), the default Python time
library, and the Evennia EvTable
class which can easily format tables for display.
Here's our new overrides.py
:
from evennia.commands.default.muxcommand import MuxCommand
from evennia.server.sessionhandler import SESSIONS
from evennia.utils import utils, evtable
import time
class CmdWho(MuxCommand):
"""
who
This command shows who is presently online.
"""
key = "who"
aliases = ["doing", ]
locks = "cmd:all()"
account_caller = True
def func(self):
# Get the list of sessions
session_list = SESSIONS.get_sessions()
# Sort the list by the time connected
#
# The 'key' parameter is a reference to a function that takes one
# argument and returns a value that the list should be sorted by.
# In this case, there's no convenient function to reference, so
# we'll define what's called a lambda function: an anonymous inline
# function. In this case, given a single 'sess' argument, it just
# returns the conn_time field from sess.
#
# To read more on lambas, check out:
# https://www.programiz.com/python-programming/anonymous-function
#
session_list = sorted(session_list, key=lambda sess: sess.conn_time)
# Create an instance of our Evennia table helper class with four
# columns.
table = evtable.EvTable("Account Name", "On For", "Idle", "Doing")
# Iterate across the sessions
for session in session_list:
# If this session isn't logged in -- i.e. is at the login screen
# or something -- just skip it.
if not session.logged_in:
continue
# How long has it been since their last command?
#
# time.time() returns the current UNIX time -- in the same format
# as MUSH softcode's secs() function -- while ServerSession's
# cmd_last_visible field is the timestamp of when we saw the last
# command. We'll store the difference between the two.
#
delta_cmd = time.time() - session.cmd_last_visible
# How long has this session been connected?
#
# Once again, we'll store the difference between right now
# and the time they first connected.
#
delta_conn = time.time() - session.conn_time
# Let's store the account just for easy reference, so we don't
# have to do get_account() everywhere. Saves on typing.
account = session.get_account()
# An account's 'key' is the name of the account. There's no
# reason to store this in a string, really, instead of using
# account.key when we wanted to reference it, but this lets
# me comment on it.
account_name = account.key
# The 'db' field on Evennia Accounts, Players, and Objects contains
# a special object that contains all the attributes you've set on-game
# using the @set command. Think of it like the attributes on objects in
# MUSH.
#
# If the 'who_doing' attribute on the account isn't empty,
# let's store it in a new variable called doing_string, otherwise
# let's store an empty string.
#
# The format we're using here is called a 'ternary conditional operator',
# and you can read a bit more about it at:
# https://www.pythoncentral.io/one-line-if-statement-in-python-ternary-conditional-operator/
#
doing_string = account.db.who_doing if account.db.who_doing else ""
# Now we have all our data gathered! Let's add a row to the table for
# this session, using a few Evennia utilities to crop strings and format
# times.
#
# crop takes a string and a maximum length, to crop it to.
#
# time_format takes a value in seconds, and a style; 0 is hours:minutes,
# 1 is a natural language string like '50m' or '2h'.
#
table.add_row(utils.crop(account_name, 25),
utils.time_format(delta_conn, 0),
utils.time_format(delta_cmd, 1),
utils.crop(doing_string, 35))
# Send the table to our user.
self.msg(table)
This is a bit longer, but rather than do it step-by-step I added comments to the source showing everything explaining what it does.
Most of it is pretty straightforward. crop()
takes a string and a length and ensures the string isn't longer than that length. time_format()
takes a number of seconds and a style; style '1' will show a human-readable bit of text like '50m' while style '0' will show it in hours:minutes. EvTable
takes a list of columns when you initialize it, and then every add_row()
call takes one parameter for each column; in addition, EvTable
has a convenience function where it will output the table anywhere you try to use the table instance as a string; as a result, we just pass the table to self.msg()
and it treats it like a string.
The only non-obvious part is the lambda
function in our sorted()
call. Lambdas are a way of encapsulating a function; in this case, we're sorting the list of sessions and telling it the sort key should be generated by the lambda function we provided. In this case, the lambda says that given an object sess
, return sess.conn_time
. When it sorts the list, that function will be executed for each element, and the result used as the sort key. The upshot of this is that we get a our original list of session objects re-sorted by how long the person has been connected.
If you reload again and type who
now, you'll get something like:
+--------------+--------+------+-------+
| Account Name | On For | Idle | Doing |
+~~~~~~~~~~~~~~+~~~~~~~~+~~~~~~+~~~~~~~+
| Pax | 00:00 | 8s | |
+--------------+--------+------+-------+
This looks more like a real WHO command! And now, let me do:
@set *Pax/who_doing=Writing a tutorial!
Do who
again, and we get:
+--------------+--------+------+---------------------+
| Account Name | On For | Idle | Doing |
+~~~~~~~~~~~~~~+~~~~~~~~+~~~~~~+~~~~~~~~~~~~~~~~~~~~~+
| Pax | 00:00 | 1s | Writing a tutorial! |
+--------------+--------+------+---------------------+
Next, let's make it so we can pass a parameter.
Python is what as known as a functional programming language; this means every function is itself an object. You can assign functions to variables and call them directly, and you can also define functions within a function.
This is useful when you have a block of code you want to reuse multiple times -- a function, clearly -- but you don't really need that code anywhere but the function you're writing. This means we can work with inner functions.
Let's write an inner function to return a filtered list of Evennia sessions. Right above where you have session_list = SESSIONS.get_sessions()
, add this function. Make certain it's indented one level more than the func
function is, so that it's contained within func
!
def get_sessions(prefix=None):
"""
Given an optional prefix for account names, return a list of currently connected sessions.
This is just a convenience function since we might use it several places.
:param prefix: A prefix that the account name must start with to be included in the list.
:return:
"""
# Get the raw list of sessions from Evennia
sessions = SESSIONS.get_sessions()
# If our prefix is None -- Python's equivalent of NULL or nil -- then
# it counts as false.
if prefix:
sessions = \
filter(lambda sess: sess.get_account().key.startswith(
prefix) if sess.get_account() is not None else False,
sessions)
# We have our session list, so return it to the user.
return sessions
Most of this is pretty straightforward. We have a single parameter, prefix
, which we were passed, and which defaults to None
if it isn't provided. And you remember SESSIONS.get_sessions()
from before; we're getting our list of sessions.
filter
is a method that takes a list and an optional function to call as the second parameter; if you don't provide the function, then filter() will just return a list with any values that were None
stripped out. In this case, we're providing a lambda again.
This lambda looks pretty awful at first glance (and really, it's not the most readable), but it's just a ternary comparison operation like we already used in our who
command. In effect, we're just saying "given sess
, if the result of sess.get_account()
is not None
, then our value is sess.get_account().key.startswith(prefix)
, otherwise our value is False
.
We already remember that get_account()
returns an Account
, and key
on that Account is the account's name. The name being a string, we can use the string function startswith
to check if the string begins with another string; in this case, we're checking it against prefix
. If get_account()
returned None for some reason, though—a session not being logged in, for instance—then we'd be trying to call key
on a None
value, and our program might crash. Hence our ternary comparison operator.
Now that we've got this function, we can change our main CmdWho
so that, instead of
session_list = SESSIONS.get_sessions()
we now use
session_list = get_sessions(self.args)
That args
value on our MuxCommand
is everything we passed to the command. Let's say that we entered "foo/test bar=baz"
cmdstring
is the command we entered. In this case, "foo"switches
is an array of all the switches we used. In this case, there was only one, so we would get a list one item long, containing "test"args
is everything we passed to the command, with no extra parsing done. In this case, it would be "bar=baz"lhs
is the left-hand side of our args expression, anything that comes before the first = sign. In this case, it would be "bar"rhs
is the right-hand side of our args expression, anything that comes after the first = sign. In this case, it would be "baz"So, to get back to our WHO command, if I entered "WHO P", then args
would be "P". If I entered just "WHO", then args
will be None
. You'll recognize these are precisely the sort of values we coded our get_sessions()
to handle.
Now our CmdWho should look something like this, with all the comments from earlier stripped out.
def func(self):
def get_sessions(prefix=None):
"""
Given an optional prefix for account names, return a list of currently connected sessions.
This is just a convenience function since we might use it several places.
:param prefix: A prefix that the account name must start with to be included in the list.
:return:
"""
sessions = SESSIONS.get_sessions()
if prefix:
sessions = \
filter(lambda sess: sess.get_account().key.startswith(
prefix) if sess.get_account() is not None else False,
sessions)
return sessions
session_list = get_sessions(self.args)
session_list = sorted(session_list, key=lambda sess: sess.conn_time)
table = evtable.EvTable("Account Name", "On For", "Idle", "Doing")
for session in session_list:
# If this session isn't logged in -- i.e. is at the login screen
# or something -- just skip it.
if not session.logged_in:
continue
delta_cmd = time.time() - session.cmd_last_visible
delta_conn = time.time() - session.conn_time
account = session.get_account()
account_name = account.key
doing_string = account.db.who_doing if account.db.who_doing else ""
table.add_row(utils.crop(account_name, 25),
utils.time_format(delta_conn, 0),
utils.time_format(delta_cmd, 1),
utils.crop(doing_string, 35))
self.msg(table)
You may notice that we're only calling get_sessions
once, so there's really no value to making it a function of its own. However, it was useful to demonstrate inner functions, which can be incredibly useful at times.
If we really want to replicate the MUSH "who" command (or for that matter, replace the Evennia one with something that has roughly the same capabilities), we need to have the version that a staff member gets show more information. But how do we do that?
Well, let's add this right above our session iterator:
if self.cmdstring == "doing":
show_admin_data = False
else:
show_admin_data= self.account.check_permstring("Developer") or self.account.check_permstring("Admins")
We already covered cmdstring
earlier; all this does is make sure that if we used the doing command instead of who, the 'showadmindata' value is False
. Otherwise, we check if the Account of the person running the command has either the "Developer" or "Admins" permissions. (You can easily define new permissions in your own code, such as making a "Storyteller" permission or something else, but these are two that come stock with Evennia.)
If the account has either of those permissions, we're going to show the staff version of this command.
So, one of the next things we'll want to do is make that table have a few more columns. Replace our table definition with:
if show_admin_data:
# Create an instance of our Evennia table helper class with six
# columns.
table = evtable.EvTable("Account Name", "On For", "Idle", "Location", "Client", "Address")
else:
# Create an instance of our Evennia table helper class with four
# columns.
This is pretty good, but now we need to actually populate those columns. Let's go down into the session iterator and replace our add_row
call:
# If the session has a puppet (an associated Character), get that.
# Accounts are never on-grid, but Characters are, so we'll need this
# to get the location column value.
character = session.get_puppet()
if show_admin_data:
table.add_row(utils.crop(account_name, 25),
utils.time_format(delta_conn, 0),
utils.time_format(delta_conn, 1),
"#{}".format(character.location.id) if character else "",
"Web Client" if session.protocol_key == "websocket" else session.protocol_flags['CLIENTNAME'],
session.address[0] if isinstance(session.address, tuple) else session.address)
else:
table.add_row(utils.crop(account_name, 25),
utils.time_format(delta_conn, 0),
utils.time_format(delta_cmd, 1),
utils.crop(doing_string, 35))
You can see the second half of that block is the same add_row
call we had before, in case the admin data isn't being shown. Above, we have a different add_row call. The first three columns are the same, but the last three ones are new. Let's take them one at a time.
"#{}".format(character.location.id) if character else ""
Anything which descends from Object
(which includes Character
) has a unique object id. This is, effectively, a dbref, and in fact you can use it as such with a number of Evennia builder commands. Your God character is #1, the Limbo room you logged into is #2.
In this case, we're just using another ternary conditional operation to check if character isn't None, and if it isn't, we take the character's location, and that location's id, and format it with a # so it looks like a dbref. If there's no character, we use an empty string.
The format
function on a string is very useful, and can be looked up in Python documentation, but the short form is that you can provide any number of {}
blocks in a string, and the same number of parameters to format, and those {}
will be replaced by the parameters. In this case, we replace {}
with the ID number of the room the character is in -- thus, it looks like a familiar dbref.
"Web Client" if session.protocol_key == "websocket" else session.protocol_flags['CLIENTNAME']
The ServerSession
class has a protocol_key
field which will be "websocket"
or "telnet"
or "ssh"
. If we're a websocket from the built-in webclient, we just use "Web Client" for this column. Otherwise, we pull the 'CLIENTNAME'
value from the session's protocol_flags
. These are all the various bits of goo that the client negotiated during connection; client name, what the window size is, and so on. In most cases you never need to use these, but in this case we want to get the client name.
session.address[0] if isinstance(session.address, tuple) else session.address
Lastly, we have the session address. Unfortunately, this can be either a single address or, in some cases, a collection of several of addresses. So we check if it's an instance of tuple
(basically a list or array), and if so we get the first element. Otherwise, we just show the session address.
Reload your server, and try typing who again.
+--------------+--------+------+----------+----------+-----------+
| Account Name | On For | Idle | Location | Client | Address |
+~~~~~~~~~~~~~~+~~~~~~~~+~~~~~~+~~~~~~~~~~+~~~~~~~~~~+~~~~~~~~~~~+
| Pax | 00:00 | 7s | #2 | ATLANTIS | 127.0.0.1 |
+--------------+--------+------+----------+----------+-----------+
Since I logged in using Atlantis, you can see it in my client column. If I type doing, however, I still get:
+--------------+--------+------+---------------------+
| Account Name | On For | Idle | Doing |
+~~~~~~~~~~~~~~+~~~~~~~~+~~~~~~+~~~~~~~~~~~~~~~~~~~~~+
| Pax | 00:00 | 1s | Writing a tutorial! |
+--------------+--------+------+---------------------+
But we still don't have a way for anyone who isn't a builder (i.e. has access to the @set
command on the game) to set their Doing field. Let's change that.
Let's make a switch called 'set', so that I can do doing/set <whatever>
to set a new value, or doing/set
alone to clear it.
Back in your code, go to just above where we set session_list
and add the following:
if self.cmdstring == "doing" and "set" in self.switches:
if self.args == "":
self.msg("Doing field cleared.")
self.account.db.who_doing = None
else:
self.msg("Doing field set: {}".format(self.args))
self.account.db.who_doing = self.args
return
This is pretty straightforward. We already know about cmdstring
from before, and switches
is just an array of any switches provided. So if the command was "doing" and "set" is in the collection of switches, we'll call this code.
We also know about args
from our work with filtering on a prefix, so that's easy enough. And we also know about the db
field on Account
(and anything descended from Object
). And the account
field on any command is the Account
of the command caller. Therefore, we just need to set or clear who_doing
on self.account
's db
field.
We also return a message, because it's only polite.
But there's one last step!
Python has automatic documentation commenting, where you can use blocks enclosed by """
to generate documentation for a class or function. Evennia uses the class documentation for a given command class as the helpfile entry for that command. So, let's go back up right under CmdWho, where we have a really basic helpfile we wrote:
"""
who
This command shows who is presently online.
"""
That's not a very good helpfile. We've added a bunch more functionality! Let's rewrite it:
"""
who [prefix]
doing [prefix]
doing/set [pithy quote]
This command shows who is presently online. If a prefix is given, it will
only show the accounts whose names start with that prefix. For admins, if
you wish to see the player-side list, type 'doing'. For all players, if
you'd like to set a pithy quote to show up in the last column, use
'doing/set'. If you don't provide a value to doing/set, it will clear yours.
"""
Save the file, reload your server, and type "help who":
------------------------------------------------------------------------------
Help for who (aliases: doing)
who [prefix]
doing [prefix]
doing/set [pithy quote]
This command shows who is presently online. If a prefix is given, it will
only show the accounts whose names start with that prefix. For admins, if
you wish to see the player-side list, type 'doing'. For all players, if
you'd like to set a pithy quote to show up in the last column, use
'doing/set'. If you don't provide a value to doing/set, it will clear yours.
Suggested: @cwho
------------------------------------------------------------------------------
Now we're done!
If you need a full copy of the overrides.py file either with or without comments, you can find it on the website.
Hopefully this was helpful!